From 851bd960e90f570bc2e6efe03af091c755549825 Mon Sep 17 00:00:00 2001 From: shaw-baobao Date: Tue, 4 Aug 2026 10:50:14 +0800 Subject: [PATCH] Fix HiDPI modes for portrait displays --- Crisp/Models/DisplayMode.swift | 6 ++-- Crisp/Models/DisplayModeGeometry.swift | 27 +++++++++++++++ Crisp/Views/DisplayModeListView.swift | 8 ++++- CrispTests/DisplayModeGeometryTests.swift | 41 +++++++++++++++++++++++ Makefile | 10 +++++- project.yml | 24 +++++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 Crisp/Models/DisplayModeGeometry.swift create mode 100644 CrispTests/DisplayModeGeometryTests.swift diff --git a/Crisp/Models/DisplayMode.swift b/Crisp/Models/DisplayMode.swift index 32a31dc..6513b43 100644 --- a/Crisp/Models/DisplayMode.swift +++ b/Crisp/Models/DisplayMode.swift @@ -87,8 +87,10 @@ struct DisplayMode: Identifiable, Equatable { // clean scaled resolutions (1600x900, 2048x1152, full 2560x1440 refresh set, ...) that // CGS carries without any override, so we can offer and apply them directly like BetterDisplay. let knownIDs = Set(modes.map { $0.id }) - let nativeAR = rawModes.max(by: { $0.pixelWidth * $0.pixelHeight < $1.pixelWidth * $1.pixelHeight }) - .map { Double($0.pixelWidth) / Double($0.pixelHeight) } ?? 0 + let nativeAR = DisplayModeGeometry.nativeAspect(from: rawModes.map { + DisplayModeGeometry(width: $0.width, height: $0.height, + pixelWidth: $0.pixelWidth, pixelHeight: $0.pixelHeight) + }) modes += cgsHiddenHiDPIModes(for: displayID, excludingIDs: knownIDs, maxPixelWidth: maxPixelWidth, nativeAspect: nativeAR) diff --git a/Crisp/Models/DisplayModeGeometry.swift b/Crisp/Models/DisplayModeGeometry.swift new file mode 100644 index 0000000..82943a3 --- /dev/null +++ b/Crisp/Models/DisplayModeGeometry.swift @@ -0,0 +1,27 @@ +struct DisplayModeGeometry: Equatable { + let width: Int + let height: Int + let pixelWidth: Int + let pixelHeight: Int + + static func nativeAspect(from modes: [DisplayModeGeometry]) -> Double { + let unscaled = modes.filter { + $0.pixelWidth == $0.width && $0.pixelHeight == $0.height + } + let candidates = unscaled.isEmpty ? modes : unscaled + guard let largest = candidates.max(by: { + $0.pixelWidth * $0.pixelHeight < $1.pixelWidth * $1.pixelHeight + }), largest.height > 0 else { return 0 } + return Double(largest.width) / Double(largest.height) + } + + static func isResolutionMenuEligible(width: Int, height: Int) -> Bool { + min(width, height) >= 720 && max(width, height) >= 1280 + } + + static func hasSameOrientation(width: Int, height: Int, + as referenceWidth: Int, _ referenceHeight: Int) -> Bool { + if width == height || referenceWidth == referenceHeight { return true } + return (width > height) == (referenceWidth > referenceHeight) + } +} diff --git a/Crisp/Views/DisplayModeListView.swift b/Crisp/Views/DisplayModeListView.swift index b35afc2..82da240 100644 --- a/Crisp/Views/DisplayModeListView.swift +++ b/Crisp/Views/DisplayModeListView.swift @@ -27,7 +27,10 @@ struct DisplayModeSection: View { let (nativeW, nativeH) = display.nativeResolution let base = display.availableModes.filter { - $0.width >= 1280 && $0.height >= 720 + DisplayModeGeometry.isResolutionMenuEligible(width: $0.width, height: $0.height) + && DisplayModeGeometry.hasSameOrientation( + width: $0.width, height: $0.height, as: nativeW, nativeH + ) } var grouped: [String: [DisplayMode]] = [:] @@ -381,6 +384,9 @@ struct DisplayModeSection: View { var seen = Set() return display.availableModes .filter { + guard DisplayModeGeometry.hasSameOrientation( + width: $0.width, height: $0.height, as: nativeW, nativeH + ) else { return false } if hasNativeDefault, $0.isHiDPI, $0.width == nativeW, $0.height == nativeH { return false } return ($0.isHiDPI && $0.width >= minWidth) || ($0.width == nativeW && $0.height == nativeH) } diff --git a/CrispTests/DisplayModeGeometryTests.swift b/CrispTests/DisplayModeGeometryTests.swift new file mode 100644 index 0000000..6a5ecdc --- /dev/null +++ b/CrispTests/DisplayModeGeometryTests.swift @@ -0,0 +1,41 @@ +import XCTest + +final class DisplayModeGeometryTests: XCTestCase { + func testPortraitNativeAspectIgnoresLargerLandscapeHiDPIBacking() { + let modes = [ + DisplayModeGeometry( + width: 1440, height: 2560, + pixelWidth: 1440, pixelHeight: 2560 + ), + DisplayModeGeometry( + width: 2560, height: 1440, + pixelWidth: 5120, pixelHeight: 2880 + ) + ] + + XCTAssertEqual( + DisplayModeGeometry.nativeAspect(from: modes), + 1440.0 / 2560.0, + accuracy: 0.001 + ) + } + + func testPortraitRetinaPointIsEligibleForResolutionMenu() { + XCTAssertTrue( + DisplayModeGeometry.isResolutionMenuEligible(width: 720, height: 1280) + ) + } + + func testPortraitMenuRejectsLandscapeModes() { + XCTAssertFalse( + DisplayModeGeometry.hasSameOrientation( + width: 1920, height: 1080, as: 1440, 2560 + ) + ) + XCTAssertTrue( + DisplayModeGeometry.hasSameOrientation( + width: 1080, height: 1920, as: 1440, 2560 + ) + ) + } +} diff --git a/Makefile b/Makefile index c636645..f52050e 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ # Fast dev loop (Command Line Tools only, no Xcode): # make dev compile, swap the binary into /Applications/Crisp.app, relaunch # make compile compile the binary only (./Crisp-bin), no swap — quick build check +# make test generate the Xcode project and run unit tests # # Distributable DMG: # make build signed universal (arm64 + x86_64) DMG via scripts/release.sh (dry run) @@ -27,12 +28,13 @@ SWIFTC_FLAGS := -O -swift-version 5 -strict-concurrency=minimal -parse-as-librar -Xlinker -undefined -Xlinker dynamic_lookup .DEFAULT_GOAL := help -.PHONY: help dev compile build dmg release clean +.PHONY: help dev compile test build dmg release clean help: @echo "Crisp — make targets:" @echo " make dev compile + swap into /Applications/Crisp.app + relaunch (dev.sh)" @echo " make compile compile ./Crisp-bin only, no swap (quick build check)" + @echo " make test generate the Xcode project and run unit tests" @echo " make build signed universal DMG, no Xcode (scripts/release.sh v$(VERSION))" @echo " make dmg DMG via Xcode (scripts/build-dmg.sh)" @echo " make release ARGS=\"vX.Y.Z notes.md --publish\" full release (scripts/release.sh)" @@ -46,6 +48,12 @@ compile: swiftc $(SWIFTC_FLAGS) $(SWIFT_SOURCES) -o Crisp-bin @echo "Done. ./Crisp-bin built (not swapped into the app; use 'make dev' for that)." +test: + xcodegen generate + xcodebuild -quiet test -project Crisp.xcodeproj -scheme Crisp \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO \ + SWIFT_VERSION=5 SWIFT_STRICT_CONCURRENCY=minimal + build: ./scripts/release.sh v$(VERSION) diff --git a/project.yml b/project.yml index 2f1ff11..6f6f204 100644 --- a/project.yml +++ b/project.yml @@ -43,3 +43,27 @@ targets: OTHER_LDFLAGS: "-Wl,-U,_SLSConfigureDisplayEnabled -Wl,-U,_SLSGetDisplayList" GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + CrispTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "15.0" + sources: + - path: CrispTests + - path: Crisp/Models/DisplayModeGeometry.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.crisp.tests + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: minimal + TEST_HOST: "" + BUNDLE_LOADER: "" + +schemes: + Crisp: + build: + targets: + Crisp: all + test: + targets: + - CrispTests