From e1c120971d50d0aac1310fa6a8ca735f2390ba03 Mon Sep 17 00:00:00 2001 From: trsdn Date: Fri, 25 Sep 2026 10:32:53 +0200 Subject: [PATCH] fix: stop the row-fitting check from overriding the switcher's preview limit The switcher still fell back to icons on a busy Space even under the 0.2.3 "Previews up to" setting, because TileSizing.fit independently forced icon mode whenever windowCount tiles could not fit in three rows without scrolling -- a check unrelated to the configured limit, and one the switcher grid (already inside a ScrollView) never needed. 28 windows, the exact case 0.2.3's commit message named, still hit this: at the overlay's capped 1400pt width, three rows of 120pt tiles fit at most 27. TileSizing.fit now returns the floor width instead of giving up, and the grid scrolls for the rest. TileModePolicy.resolve, driven by switcherPreviewLimit, is the only thing left deciding previews vs. icons. Claude-Session: https://claude.ai/code/session_01Ez14nyXC16CaNvU6z7vN6W Co-authored-by: Claude Sonnet 5 --- CHANGELOG.md | 10 +++++ Sources/OpenSwitchr/SwitcherController.swift | 26 ++++--------- Sources/OpenSwitchrCore/TileModePolicy.swift | 9 +++-- Sources/OpenSwitchrCore/TileSizing.swift | 28 +++++++------- .../TileSizingTests.swift | 37 +++++++++++++------ 5 files changed, 63 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35d950..4bc3bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The switcher still fell back to icons for a busy Space even under the new *Previews up to* + limit from 0.2.3, because a second, unrelated check — fitting every tile into three rows + without scrolling — forced icons on its own once a Space had about 28 windows, on any + display, regardless of the configured limit. Past that width and row ceiling, tiles now + shrink to their floor and the (already scrollable) switcher grid scrolls for the rest, + instead of giving up on previews. Only the *Previews up to* setting decides icons vs. + previews now. + ## [0.2.3] - 2026-09-21 ### Fixed diff --git a/Sources/OpenSwitchr/SwitcherController.swift b/Sources/OpenSwitchr/SwitcherController.swift index d6aa8a5..0766b24 100644 --- a/Sources/OpenSwitchr/SwitcherController.swift +++ b/Sources/OpenSwitchr/SwitcherController.swift @@ -121,29 +121,19 @@ public final class SwitcherController { visibleWindows = baseWindows() previewWidth = preferences.tileWidth - var previewsAreLegible = true if preferences.fitTilesToWindowCount { - switch TileSizing.fit( + previewWidth = TileSizing.fit( windowCount: visibleWindows.count, availableWidth: Self.availableWidth(on: surfaceScreen), configuredWidth: preferences.tileWidth - ) { - case .width(let width): - previewWidth = width - case .tooSmall: - previewsAreLegible = false - } - } - // Below the legible floor the answer is icons, not a smaller image. - tileMode = - previewsAreLegible - ? TileModePolicy.resolve( - preference: preferences.tilePreference, - screenRecordingGranted: CGPreflightScreenCaptureAccess(), - windowCount: visibleWindows.count, - threshold: preferences.switcherPreviewLimit ) - : .icons + } + tileMode = TileModePolicy.resolve( + preference: preferences.tilePreference, + screenRecordingGranted: CGPreflightScreenCaptureAccess(), + windowCount: visibleWindows.count, + threshold: preferences.switcherPreviewLimit + ) selectedIndex = SwitcherSelection.initialIndex( count: visibleWindows.count, diff --git a/Sources/OpenSwitchrCore/TileModePolicy.swift b/Sources/OpenSwitchrCore/TileModePolicy.swift index 1df5f8b..20e96eb 100644 --- a/Sources/OpenSwitchrCore/TileModePolicy.swift +++ b/Sources/OpenSwitchrCore/TileModePolicy.swift @@ -39,10 +39,11 @@ public enum TileModePolicy { /// 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. + /// is a matter of taste, not legibility: `TileSizing` already shrinks tiles + /// to its own floor and lets the grid scroll for the rest, so this is the + /// only thing standing between a great many windows and a great many + /// captures. 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 diff --git a/Sources/OpenSwitchrCore/TileSizing.swift b/Sources/OpenSwitchrCore/TileSizing.swift index efac2a9..5c11796 100644 --- a/Sources/OpenSwitchrCore/TileSizing.swift +++ b/Sources/OpenSwitchrCore/TileSizing.swift @@ -19,8 +19,12 @@ public enum TileSizing { /// The granularity a width is snapped to. public static let step: CGFloat = 20 - /// Below this a preview stops being identifiable; the answer is icon mode, - /// not a smaller image. + /// Below this a preview stops being identifiable, so it is where shrinking + /// stops. It is not where previews stop: a Space with more windows than fit + /// in `maxVisibleRows` at this width scrolls for the rest instead, because + /// the overlay's grid is already scrollable. Whether there are simply too + /// many windows to bother previewing at all is `TileModePolicy`'s call + /// (`switcherPreviewLimit`), not this floor's. public static let minimumPreviewWidth: CGFloat = 120 /// How many rows the overlay shows before it scrolls. @@ -35,26 +39,22 @@ public enum TileSizing { max(1, Int(((availableWidth - gridPadding) / (tileWidth + tileGutter)).rounded(.down))) } - public enum Fit: Equatable, Sendable { - case width(CGFloat) - /// Even the smallest legible tile would need more rows than the overlay - /// shows. The caller should use icon tiles instead. - case tooSmall - } - /// The widest step, no wider than `configuredWidth`, at which `windowCount` - /// tiles fit in `maxVisibleRows` rows. - public static func fit(windowCount: Int, availableWidth: CGFloat, configuredWidth: CGFloat) -> Fit { + /// tiles fit in `maxVisibleRows` rows without scrolling; the floor + /// (`minimumPreviewWidth`) when even that width would need more rows, since + /// scrolling past `maxVisibleRows` is a scroll, not a reason to give up on + /// previews. + public static func fit(windowCount: Int, availableWidth: CGFloat, configuredWidth: CGFloat) -> CGFloat { let cap = max(minimumPreviewWidth, (configuredWidth / step).rounded(.down) * step) - guard windowCount > 0 else { return .width(cap) } + guard windowCount > 0 else { return cap } var width = cap while width >= minimumPreviewWidth { let columns = columns(availableWidth: availableWidth, tileWidth: width) let rows = (windowCount + columns - 1) / columns - if rows <= maxVisibleRows { return .width(width) } + if rows <= maxVisibleRows { return width } width -= step } - return .tooSmall + return minimumPreviewWidth } } diff --git a/Tests/OpenSwitchrCoreTests/TileSizingTests.swift b/Tests/OpenSwitchrCoreTests/TileSizingTests.swift index ff8f412..02dd980 100644 --- a/Tests/OpenSwitchrCoreTests/TileSizingTests.swift +++ b/Tests/OpenSwitchrCoreTests/TileSizingTests.swift @@ -25,36 +25,51 @@ struct TileSizingTests { @Test("A few windows keep the configured width: it is the upper limit, not a floor") func fewWindowsKeepConfiguredWidth() { - #expect(TileSizing.fit(windowCount: 3, availableWidth: available, configuredWidth: 200) == .width(200)) + #expect(TileSizing.fit(windowCount: 3, availableWidth: available, configuredWidth: 200) == 200) } @Test("More windows than fit in the visible rows shrink the tile to the largest step that does") func shrinksToFit() { // At 320 only 3 columns fit, so 12 windows need 4 rows. At 280, 4 columns fit: 3 rows. - #expect(TileSizing.fit(windowCount: 12, availableWidth: available, configuredWidth: 320) == .width(280)) + #expect(TileSizing.fit(windowCount: 12, availableWidth: available, configuredWidth: 320) == 280) } @Test("The result is quantised, so the thumbnail cache keeps hitting") func quantised() { // 305 is not a step; the cap snaps down to 300. - #expect(TileSizing.fit(windowCount: 1, availableWidth: available, configuredWidth: 305) == .width(300)) - if case .width(let width) = TileSizing.fit(windowCount: 9, availableWidth: available, configuredWidth: 320) { - #expect(width.truncatingRemainder(dividingBy: TileSizing.step) == 0) - } + #expect(TileSizing.fit(windowCount: 1, availableWidth: available, configuredWidth: 305) == 300) + let width = TileSizing.fit(windowCount: 9, availableWidth: available, configuredWidth: 320) + #expect(width.truncatingRemainder(dividingBy: TileSizing.step) == 0) } - @Test("Below the legible floor the answer is too small, not a smaller image") - func tooSmall() { - #expect(TileSizing.fit(windowCount: 100, availableWidth: available, configuredWidth: 320) == .tooSmall) + @Test( + "Past the point where even the floor width needs more rows, the floor still applies: the grid scrolls for the rest" + ) + func manyWindowsStillGetTheFloorWidth() { + #expect( + TileSizing.fit(windowCount: 100, availableWidth: available, configuredWidth: 320) + == TileSizing.minimumPreviewWidth) + } + + @Test("A busy Space at the widest possible overlay still gets a legible width, not the icons fallback") + func busySpaceOnLargeDisplayStaysLegible() { + // The overlay's available width is capped at 1400pt regardless of display size + // (see SwitcherController.availableWidth). This is the exact "28 windows on a + // large display" case PR #81 was written to fix: 28 is under the default + // switcherPreviewLimit of 30, so previews must still be possible here, even + // though 28 windows no longer fit in three rows at any width down to the floor. + #expect( + TileSizing.fit(windowCount: 28, availableWidth: 1400, configuredWidth: 200) + == TileSizing.minimumPreviewWidth) } @Test("A configured width below the floor is raised to it") func floorApplies() { - #expect(TileSizing.fit(windowCount: 1, availableWidth: available, configuredWidth: 100) == .width(120)) + #expect(TileSizing.fit(windowCount: 1, availableWidth: available, configuredWidth: 100) == 120) } @Test("No windows keeps the configured width rather than dividing by nothing") func noWindows() { - #expect(TileSizing.fit(windowCount: 0, availableWidth: available, configuredWidth: 200) == .width(200)) + #expect(TileSizing.fit(windowCount: 0, availableWidth: available, configuredWidth: 200) == 200) } }