From 78ad6755075acb16eacd3c7acf8f14afe7faa622 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:44:01 +0200 Subject: [PATCH] feat(clients): a safe-area resolution that keeps the picture out of the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That is why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions. So the fix is entirely a sizing one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region; pointer mapping follows for free, because both clients derive the picture rect from the live host mode rather than assuming full-bleed. Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device (safe area)" row beside the native one, using Moonlight's formula — full native height, width less the left+right safe insets. The stream is always landscape but the settings screen may be portrait, where the same housing is reported on `top` and the horizontal insets read zero; the portrait top inset stands in, gated so an iPad's status bar never fabricates an inset. Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at connect like the existing `0`=native one. The cutout insets get the same portrait fallback, and the rounded corners are added on top — Android does not count them as cutout, and a full-height picture needs exactly the corner radius of horizontal clearance. Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one, which on Apple lets the existing dedup drop the duplicate row. --- .../main/kotlin/io/unom/punktfunk/Settings.kt | 112 +++++++++++++++++- .../io/unom/punktfunk/SettingsScreen.kt | 17 ++- .../kotlin/io/unom/punktfunk/SafeAreaTest.kt | 48 ++++++++ .../Settings/SettingsOptions.swift | 38 +++++- .../Sources/PunktfunkShared/SafeDisplay.swift | 86 ++++++++++++++ .../PunktfunkKitTests/SafeDisplayTests.swift | 74 ++++++++++++ 6 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt create mode 100644 clients/apple/Sources/PunktfunkShared/SafeDisplay.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 83bf4f61..ea6328d4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -437,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple { return Triple(maxOf(w, h), minOf(w, h), hz) } +/** + * Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture + * clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode], + * exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real + * size; distinct from the UI's `-1` "Custom…" sentinel. + */ +const val SAFE_AREA_MODE = -2 + +/** + * Safe-area stream geometry — the pure part, so it is unit-testable without a Display. + * + * The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded + * corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge + * (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio + * (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect: + * + * * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe + * regions — which is why the presets have always "just worked". + * * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners + * included. That is the mode that loses its corners. + * + * So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing + * aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput + * derives the picture rect from the live video size, not from the window). + */ +object SafeArea { + /** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */ + const val MIN_WIDTH = 320 + + /** + * [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the + * host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and + * on a landscape phone that axis is always the horizontal one — insetting height as well would + * shrink the picture without uncovering anything. + */ + fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int { + val inset = perSideInsetPx.coerceAtLeast(0) + return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2 + } +} + +/** + * The per-side inset, in pixels, that the **landscape** stream must clear on this display. + * + * Two contributions, and the larger wins: + * * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on + * `left`/`right`. The settings screen may be portrait though, where the very same housing is + * reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset + * needed" for exactly the devices that need one. The stream is always landscape, so a vertical + * inset now becomes a horizontal one then: fall back to it. + * * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the + * horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the + * display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is + * the precise requirement for a picture that spans the full height. + * + * `0` when the display has neither, which makes the safe mode identical to the native one. + */ +private fun displaySideInsetPx(context: Context): Int { + val display = probeDisplay(context) ?: return 0 + var inset = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + display.cutout?.let { cut -> + val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight) + val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom) + inset = maxOf(inset, if (horizontal > 0) horizontal else vertical) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (position in intArrayOf( + android.view.RoundedCorner.POSITION_TOP_LEFT, + android.view.RoundedCorner.POSITION_TOP_RIGHT, + android.view.RoundedCorner.POSITION_BOTTOM_LEFT, + android.view.RoundedCorner.POSITION_BOTTOM_RIGHT, + )) { + display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) } + } + } + return inset +} + +/** + * The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE] + * resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode]; + * only the width moves. + */ +fun safeDisplayMode(context: Context): Triple { + val (w, h, hz) = nativeDisplayMode(context) + return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz) +} + /** * True when this device's display can actually present HDR10, so we should advertise HDR to the * host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream @@ -471,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean { return supported } -/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */ +/** + * Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to + * request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one + * mode, not an independent width and height, and mixing half of it with a native height would ask + * for a size neither sentinel means. + */ fun Settings.effectiveMode(context: Context): Triple { - val native = nativeDisplayMode(context) - val w = if (width > 0) width else native.first - val h = if (height > 0) height else native.second - val hz = if (hz > 0) hz else native.third + val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) { + safeDisplayMode(context) + } else { + nativeDisplayMode(context) + } + val w = if (width > 0) width else base.first + val h = if (height > 0) height else base.second + val hz = if (hz > 0) hz else base.third return Triple(w, h, hz) } @@ -530,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) // ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- -/** (width, height, label). `(0,0)` = native display. */ +/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */ val RESOLUTION_OPTIONS = listOf( Triple(0, 0, "Native display"), + Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"), Triple(1280, 720, "1280 × 720"), Triple(1920, 1080, "1920 × 1080"), Triple(2560, 1440, "2560 × 1440"), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 8b902099..b150cca8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { @Composable private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) { val (nw, nh, nhz) = nativeDisplayMode(context) + // The safe-area row carries its resolved size the same way the native row does. On a display with + // no cutout and square corners this equals the native mode — the row stays, honestly showing that + // it changes nothing here, rather than silently vanishing on some devices and not others. + val (sw, sh, _) = safeDisplayMode(context) // "Custom…" picked while the stored size is still a preset — keeps the size fields visible // until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected // from the stored size, never flagged (see [isCustomResolution]), so nothing new persists. @@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an SettingsGroup("Resolution") { SettingDropdown( label = "Resolution", - options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } + + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> + (w to h) to when (w) { + 0 -> "$lbl ($nw × $nh)" + SAFE_AREA_MODE -> "$lbl ($sw × $sh)" + else -> lbl + } + } + // The (-1, -1) sentinel can't collide with a real size; once a custom size is // stored its label carries the live value, like the native row carries ($nw × $nh). ((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"), @@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an caption = "The host makes a display exactly this size — no scaling. Native follows " + "this device's panel.", ) { (w, h) -> - if (w < 0) { + // ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a + // stored mode like any preset — a blanket `w < 0` here would open the custom fields for it + // and overwrite it with a concrete size. + if (w == -1) { // Seed from the current *effective* size so the fields start from something // sensible (the resolved native mode, not the 0 × 0 placeholder). customPicked = true diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt new file mode 100644 index 00000000..0c7075b5 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SafeAreaTest.kt @@ -0,0 +1,48 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it — + * the width-only inset that keeps the picture clear of the cutout and the rounded corners. + * Run: `./gradlew :app:testDebugUnitTest`. + */ +class SafeAreaTest { + @Test + fun insetsBothSidesAndStaysHostValid() { + // A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208. + assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96)) + // Odd results even-floor — the host rejects odd dimensions outright, and an inset + // subtraction lands odd about half the time. + assertEquals(0, SafeArea.insetWidth(2401, 95) % 2) + // No cutout and square corners → the native width, unchanged. + assertEquals(2400, SafeArea.insetWidth(2400, 0)) + } + + @Test + fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() { + assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000)) + // A negative reading is treated as no inset rather than widening past the panel. + assertEquals(1280, SafeArea.insetWidth(1280, -40)) + } + + @Test + fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() { + val native = 2556 + assertTrue(SafeArea.insetWidth(native, 60) < native) + } + + @Test + fun theSentinelIsAPresetAndNeverReadsAsCustom() { + // The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be + // false for it, or the touch settings would open the custom width/height fields on it and + // the gamepad screen would prepend a bogus "Custom · -2 × -2" row. + val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE) + assertTrue(!s.isCustomResolution()) + // And it must be distinct from the UI's own "Custom…" sentinel (-1). + assertTrue(SAFE_AREA_MODE != -1) + assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE }) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index fa23a22c..04fcef9c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -171,14 +171,26 @@ enum SettingsOptions { /// This device's native mode first, then the presets, deduped by dimensions (native wins a /// tie). + /// + /// On iOS the native row is followed by its **safe-area** variant, which is the same mode + /// narrowed so the picture clears the sensor housing and the rounded corners — see + /// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and + /// left to the dedup below: on a device with no housing the two modes are identical, the + /// duplicate is dropped, and no pointless row appears. @MainActor static func resolutionModes() -> [(name: String, w: Int, h: Int)] { var native: [(name: String, w: Int, h: Int)] = [] #if os(iOS) || os(tvOS) let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode) - native = [("This device", - Int(max(bounds.width, bounds.height)), - Int(min(bounds.width, bounds.height)))] + let nativeW = Int(max(bounds.width, bounds.height)) + let nativeH = Int(min(bounds.width, bounds.height)) + native = [("This device", nativeW, nativeH)] + #if os(iOS) + let safe = SafeDisplay.mode( + nativeWidth: nativeW, nativeHeight: nativeH, + sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale) + native.append(("This device (safe area)", safe.width, safe.height)) + #endif #else if let screen = NSScreen.main { let scale = screen.backingScaleFactor @@ -191,6 +203,26 @@ enum SettingsOptions { return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted } } + #if os(iOS) + /// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even + /// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`). + /// + /// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes` + /// dedups the row away, which is the right answer for a device we can't measure. + @MainActor + private static func mainWindowSideInset() -> Double { + let insets = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow }? + .safeAreaInsets + guard let insets else { return 0 } + return SafeDisplay.sideInsetPoints( + left: Double(insets.left), right: Double(insets.right), top: Double(insets.top), + isPhone: UIDevice.current.userInterfaceIdiom == .phone) + } + #endif + /// Refresh rates the device can actually display (no point asking the host to render frames /// the screen can't show), plus any stored custom value so it stays selectable. @MainActor diff --git a/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift new file mode 100644 index 00000000..e0baef82 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift @@ -0,0 +1,86 @@ +// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row. +// +// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four +// rounded corners eat whatever the stream draws underneath them. The session view is deliberately +// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host +// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio: +// +// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe +// regions. That is why 1080p has always "just worked" and never needed a setting. +// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel — +// including the ones behind the housing and under the corner radii. That is the mode that +// loses its corners, and the reason this file exists. +// +// So the fix needs no layout change and no input change: ask the host for a mode that is narrower +// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer +// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from +// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed. +// +// The formula is Moonlight's (its settings' resolution table carries the same row): full native +// height, width reduced by the left+right safe-area insets. Width-only is not a simplification — +// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the +// horizontal one. Insetting the height too would shrink the picture without uncovering anything. + +import Foundation + +public enum SafeDisplay { + /// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in + /// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`. + public static let minWidth = 320 + public static let minHeight = 200 + + /// A portrait top inset at or above this many points means a sensor housing rather than a + /// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older + /// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the + /// horizontal insets are unavailable — see there for why that case exists at all. + public static let housingTopInsetThreshold: Double = 40 + + /// The per-side inset, in points, that the **landscape** stream will be subject to — which is + /// not necessarily the inset the caller can read right now. + /// + /// The stream is always landscape, but the settings screen the resolution row is rendered in may + /// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a + /// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading + /// the horizontal insets there would compute "no inset needed" for exactly the devices that + /// need one. + /// + /// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so + /// content stays centred, so they normally agree; `max` is simply the safe reduction.) + /// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every + /// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that + /// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only + /// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older + /// iPhone's) never fabricates an inset for a device with nothing to avoid. + /// + /// Returns 0 when there is no housing to route around, which makes the safe mode identical to + /// the native one — and the caller's dedup then drops the duplicate row on its own. + public static func sideInsetPoints( + left: Double, right: Double, top: Double, isPhone: Bool + ) -> Double { + let horizontal = max(left, right) + if horizontal > 0 { return horizontal } + if isPhone, top >= housingTopInsetThreshold { return top } + return 0 + } + + /// The landscape safe-area mode in PIXELS: full native height, width reduced by + /// `sideInsetPoints` on each side. + /// + /// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first — + /// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the + /// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`: + /// with Display Zoom on, the two differ and only the former matches `nativeBounds`. + /// + /// Even-floored and clamped so the result is directly host-valid — an odd width is rejected + /// outright by the encoder, and an inset subtraction lands odd about half the time. + public static func mode( + nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double + ) -> (width: Int, height: Int) { + let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides + let width = Double(nativeWidth) - insetPixels + let evenFloor: (Double, Int) -> Int = { value, minimum in + max(Int(value.rounded(.down)), minimum) / 2 * 2 + } + return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift new file mode 100644 index 00000000..74f67c8f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/SafeDisplayTests.swift @@ -0,0 +1,74 @@ +// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native +// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and +// never under 320×200) and the landscape-inset resolution that makes the row correct even when the +// settings screen it is rendered on is currently portrait. + +import XCTest + +import PunktfunkShared +@testable import PunktfunkKit + +final class SafeDisplayTests: XCTestCase { + func testLandscapeUsesTheHorizontalInsets() { + // Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the + // per-side inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59) + // Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44) + } + + func testPortraitFallsBackToTheHousingTopInset() { + // Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading + // the horizontal insets here would compute "no inset" for exactly the devices that need one, + // so the portrait top inset stands in — it is the same physical intrusion. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59) + // A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an + // inset for a device with nothing to route around. + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0) + XCTAssertEqual( + SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0) + } + + func testModeInsetsWidthOnlyAndKeepsFullHeight() { + // A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per + // side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds. + let m = SafeDisplay.mode( + nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3) + XCTAssertEqual(m.width, 2202, "2556 − 2×177") + XCTAssertEqual(m.height, 1178, "odd native heights even-floor") + // The safe mode must be NARROWER than native, or it would still fill the housing. + XCTAssertLessThan(m.width, 2556) + } + + func testNoHousingYieldsTheNativeModeSoTheRowDedups() { + // Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by + // dimensions, so this is what makes the extra row vanish on a device that has no housing + // rather than showing a pointless duplicate. + let m = SafeDisplay.mode( + nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2) + XCTAssertEqual(m.width, 2360) + XCTAssertEqual(m.height, 1640) + } + + func testResultIsAlwaysHostValid() { + // Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset + // subtraction lands odd about half the time. + let odd = SafeDisplay.mode( + nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1) + XCTAssertEqual(odd.width % 2, 0) + XCTAssertEqual(odd.height % 2, 0) + // An absurd inset can't drive the mode under the host's floor. + let tiny = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3) + XCTAssertEqual(tiny.width, SafeDisplay.minWidth) + XCTAssertEqual(tiny.height, 720) + // A negative inset is treated as none rather than widening past the panel. + let neg = SafeDisplay.mode( + nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3) + XCTAssertEqual(neg.width, 1280) + } +} -- 2.54.0