feat(clients): a safe-area resolution that keeps the picture out of the notch
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
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.
This commit is contained in:
@@ -437,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
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<Int, Int, Int> {
|
||||
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<Int, Int, Int> {
|
||||
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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user