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) } }