Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 8 additions & 18 deletions Sources/OpenSwitchr/SwitcherController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions Sources/OpenSwitchrCore/TileModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 14 additions & 14 deletions Sources/OpenSwitchrCore/TileSizing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
37 changes: 26 additions & 11 deletions Tests/OpenSwitchrCoreTests/TileSizingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading