Skip to content
Draft
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/3523.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Added
- **Controller Overlay Scale** — New global setting (0.5×–2.0×, default 1.0×) to resize the on-screen controller overlay; accessible via Settings → On-Screen Controller

### Changed
- **Portrait controller scaling** — Portrait mode applies `scaleEffect` to visually resize controls while clipping overflow and constraining hit-testing to the allocated frame area, so buttons genuinely shrink or grow at every scale value
- **Landscape edge-anchored scaling** — In landscape, the left panel scales from the screen's leading edge and the right panel from the trailing edge, so buttons always grow *inward* toward the center instead of off-screen
- **Viewport calculation tracks scale** — Both portrait (`controllerHeight`) and landscape (`sideReserve`) viewport calculations now read `controllerScale` so the game render area exactly matches the space freed/taken by the overlay at every scale setting
- **Live viewport update on scale change** — Changing the scale slider while in-game now immediately re-emits the game viewport so the render area adjusts without requiring rotation or relaunch
- **Portrait overlay opacity covers background** — `controllerOpacity` is now applied to the RetrowaveBackground panel as well as the controls, so the entire overlay fades uniformly
- **Controller fraction clamp range updated to 20–70%** — The portrait controller-area height was changed from a fixed 35% allocation to a clamped 20–70% range (capped at 70%), so a 2× scale setting can be represented without the layout and visual scale disagreeing
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
import UIKit
#endif

//public typealias Defaults = _Defaults

Check warning on line 16 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer at least one space after slashes for comments (comment_spacing)
//public typealias Default = _Default

Check warning on line 17 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer at least one space after slashes for comments (comment_spacing)

fileprivate var IsAppStore: Bool {

Check warning on line 19 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer `private` over `fileprivate` declarations (private_over_fileprivate)
Bundle.main.infoDictionary?["ALTDeviceID"] != nil
}

Expand Down Expand Up @@ -146,6 +146,9 @@
static let allRightShoulders = Key<Bool>("allRightShoulders", default: false)
#endif
static let controllerOpacity = Key<Double>("controllerOpacity", default: 0.8)
/// Scale multiplier for the on-screen controller overlay.
/// Range: 0.5 (half size) – 2.0 (double size). Default 1.0 (normal size).
static let controllerScale = Key<Double>("controllerScale", default: 1.0)

static let pauseButtonIsMenuButton = Key<Bool>("pauseButtonIsMenuButton", default: false)
static let hapticFeedback = Key<Bool>("hapticFeedback", default: true)
Expand Down Expand Up @@ -331,9 +334,9 @@
}

public enum ButtonPressEffect: String, Codable, Equatable, UserDefaultsRepresentable, Defaults.Serializable, CaseIterable {
case bubble = "bubble"

Check warning on line 337 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
case ring = "ring"

Check warning on line 338 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
case glow = "glow"

Check warning on line 339 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)

public var description: String {
switch self {
Expand Down Expand Up @@ -452,7 +455,7 @@
case iCloudDrive = "iCloudDrive"
#endif
/// Use CloudKit for syncing
case cloudKit = "cloudKit"

Check warning on line 458 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)

public var description: String {
switch self {
Expand Down Expand Up @@ -493,11 +496,11 @@
/// Network conditions for CloudKit sync
public enum CloudKitSyncNetworkMode: String, Codable, Equatable, UserDefaultsRepresentable, Defaults.Serializable, CaseIterable {
/// Sync on WiFi and cellular
case wifiAndCellular = "wifiAndCellular"

Check warning on line 499 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
/// Sync only on WiFi
case wifiOnly = "wifiOnly"

Check warning on line 501 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
/// Sync only on cellular
case cellularOnly = "cellularOnly"

Check warning on line 503 in PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)

public var description: String {
switch self {
Expand Down
19 changes: 19 additions & 0 deletions PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2448,6 +2448,7 @@ private struct TestRumbleButton: View {
#if !os(tvOS)
private struct OnScreenControllerSection: View {
@Default(.controllerOpacity) var controllerOpacity
@Default(.controllerScale) var controllerScale
@Default(.buttonTints) var buttonTints
@Default(.allRightShoulders) var allRightShoulders
@Default(.buttonVibration) var buttonVibration
Expand Down Expand Up @@ -2482,6 +2483,24 @@ private struct OnScreenControllerSection: View {
.foregroundColor(RetroTheme.retroBlue)
})
}
HStack {
Text("Controller Scale")
RetroWaveSlider<Double>(value: $controllerScale,
in: 0.5...2.0,
step: 0.05,
onEditingChanged: { _ in },
label: { Text("Scales on-screen controls and adjusts the game viewport so nothing clips or overlaps.") },
minimumValueLabel: { Text("") },
maximumValueLabel: { Text("") },
leadingIcon: {
Image(systemName: "minus.magnifyingglass")
.foregroundColor(RetroTheme.retroBlue)
},
trailingIcon: {
Image(systemName: "plus.magnifyingglass")
.foregroundColor(RetroTheme.retroBlue)
})
}
ThemedToggle(isOn: $hideOnScreenControlsWithController) {
SettingsRow(title: "Auto-Hide with Controller",
subtitle: "Hide on-screen controls when a physical game controller is connected.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
Bundle.main.infoDictionary?["ALTDeviceID"] != nil
}

public final class PVSettingsViewController: QuickTableViewController {

Check failure on line 36 in PVUI/Sources/PVUIBase/Settings/PVSettingsViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Class body should span 600 lines or less excluding comments and whitespace: currently spans 728 lines (type_body_length)
// Check to see if we are connected to WiFi. Cannot continue otherwise.
let reachability: Reachability = try! Reachability()

Expand Down Expand Up @@ -122,7 +122,7 @@
#endif

@MainActor
func generateTableViewViewModels() {

Check failure on line 125 in PVUI/Sources/PVUIBase/Settings/PVSettingsViewController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function body should span 500 lines or less excluding comments and whitespace: currently spans 503 lines (function_body_length)
typealias TableRow = Row & RowStyle

// MARK: -- Section : App
Expand Down Expand Up @@ -287,6 +287,11 @@
valueLimits: (min: 0.0, max: 1.0),
valueImages: (.sfSymbol("sun.min"), .sfSymbol("sun.max")),
key: .controllerOpacity))
controllerRows.append(PVSettingsSliderRow(text: NSLocalizedString("Scale", comment: "Scale"),
detailText: .subtitle("Size of the on-screen controller overlay."),
valueLimits: (min: 0.5, max: 2.0),
valueImages: (.sfSymbol("minus.magnifyingglass"), .sfSymbol("plus.magnifyingglass")),
key: .controllerScale))

controllerRows.append(contentsOf: [
PVSettingsSwitchRow(text: NSLocalizedString("Button Colors", comment: "Button Colors"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import PVLogging
import PVUIBase
import PVSettings
import Defaults

// MARK: - Retrowave Styling Components

Expand Down Expand Up @@ -151,7 +152,7 @@
}

// Separate view to handle the default controller skin with its own state
struct DefaultControllerSkinView: View {

Check failure on line 155 in PVUI/Sources/PVUIBase/SwiftUI/DeltaSkins/Views/Display/EmulatorWithSkinView+DefaultSkin.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Struct body should span 600 lines or less excluding comments and whitespace: currently spans 1685 lines (type_body_length)
// Initial value from parent
@State private var useJoystickInternal: Bool
let inputHandler: DeltaSkinInputHandler
Expand Down Expand Up @@ -179,6 +180,10 @@
// Bridge to protocol system (replaces notification system)
@State private var viewportBridge: ViewportLayoutProviderBridge?

// User-configurable overlay scale and opacity
@Default(.controllerScale) private var controllerScale
@Default(.controllerOpacity) private var controllerOpacity

init(useJoystick: Bool, inputHandler: DeltaSkinInputHandler, systemId: SystemIdentifier?, coreInstance: PVEmulatorCore) {
self._useJoystickInternal = State(initialValue: useJoystick)
self.inputHandler = inputHandler
Expand Down Expand Up @@ -208,12 +213,27 @@
}
}

/// Returns the fraction of the view height allocated to the controller overlay in portrait mode.
/// Extracted to ensure the SwiftUI layout and the viewport calculation always use the same value.
/// The incoming `scale` value is expected to be in the range 0.5–2.0, but since it comes from
/// persisted Defaults, we defensively sanitize and clamp it to avoid NaN / non-finite layouts.
private static func portraitControllerFraction(scale: Double) -> CGFloat {
// Normalize non-finite values (NaN / ±infinity) to a safe default of 1.0
let finiteScale = scale.isFinite ? scale : 1.0
// Clamp to the expected range from settings UI
let clampedScale = min(2.0, max(0.5, finiteScale))

return min(0.70, max(0.20, 0.35 * CGFloat(clampedScale)))
}

var body: some View {
// Load control layout data when view appears
GeometryReader { geometry in
// Guard against invalid geometry that could cause the view to disappear
let validSize = geometry.size.width > 0 && geometry.size.height > 0
let isLandscape = validSize && geometry.size.width > geometry.size.height
// Compute once so the background and controller layout always use the same fraction
let controllerFraction = DefaultControllerSkinView.portraitControllerFraction(scale: controllerScale)

ZStack {
// Ensure view always renders even with invalid geometry
Expand All @@ -223,9 +243,9 @@
if !isLandscape {
// Portrait mode - show background only in bottom controller area
VStack(spacing: 0) {
// Spacer for screen area (top ~65%) - no background here
// Spacer for screen area — mirrors the game-area spacer below
Spacer()
.frame(maxHeight: geometry.size.height * 0.65)
.frame(maxHeight: geometry.size.height * (1.0 - controllerFraction))

// Controller area background with gradual fade
ZStack {
Expand All @@ -245,32 +265,40 @@
)
)
}
.frame(maxHeight: geometry.size.height * 0.35)
.opacity(controllerOpacity)
.frame(maxHeight: geometry.size.height * controllerFraction)
Comment on lines 250 to +269

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In portrait mode, controllerOpacity is applied to dynamicControllerSkin but not to the controller-area background (RetrowaveBackground + gradient mask). That means the Opacity slider won’t actually fade the whole overlay panel—only the buttons. If the intent is “overlay opacity”, apply the opacity modifier at a container level that includes both background and controls.

Copilot uses AI. Check for mistakes.
.clipped()
}
}

if isLandscape {
// Landscape layout - controls positioned at edges with safe area awareness
// Landscape layout — scale is applied per-panel inside the skin
// builder so each side anchors to its screen edge. No whole-view
// scaleEffect here; that caused controls to clip off-screen.
dynamicLandscapeControllerSkin
.opacity(controllerOpacity)
.onAppear {
loadControlLayoutData()
// Ensure input handler has the core set
inputHandler.setEmulatorCore(coreInstance)
}
.edgesIgnoringSafeArea([]) // Respect safe areas for notch
} else {
// Portrait layout - controls constrained to bottom area
// Screen area is typically top ~65%, controller area is bottom ~35%
// Portrait layout — height allocated proportional to controllerScale;
// scaleEffect applied so buttons actually resize visually.
VStack(spacing: 0) {
// Spacer to push controller to bottom area (top ~65% is screen area)
// Game area — expands when controls shrink, contracts when they grow
Spacer()
.frame(maxHeight: geometry.size.height * 0.65)
.frame(maxHeight: geometry.size.height * (1.0 - controllerFraction))

// Controller area - constrained to bottom portion
// Controller area — scale content visually, clip overflow,
// and constrain hit-testing to the allocated frame area.
dynamicControllerSkin
.frame(maxHeight: geometry.size.height * 0.35)
.opacity(controllerOpacity)
.scaleEffect(CGFloat(controllerScale), anchor: .bottom)
.frame(maxHeight: geometry.size.height * controllerFraction)
.clipped()
.contentShape(Rectangle())
.onAppear {
loadControlLayoutData()
// Ensure input handler has the core set
Expand Down Expand Up @@ -434,6 +462,17 @@
}
}
)
.onChange(of: controllerScale) { _ in
// Re-emit viewport whenever the scale setting changes so the
// game render area immediately matches the new controller height.
guard geometry.size.width > 0 && geometry.size.height > 0 else { return }
let newIsLandscape = geometry.size.width > geometry.size.height
emitDefaultViewportIfNeeded(
size: geometry.size,
safeInsets: geometry.safeAreaInsets,
isLandscape: newIsLandscape
)
}
}
}

Expand Down Expand Up @@ -487,7 +526,8 @@
}
}

// D-pad on the left side
// D-pad on the left side — scale anchored to leading so buttons grow
// toward the center, never past the left screen edge.
VStack {
Spacer()
if useJoystickInternal {
Expand All @@ -497,11 +537,13 @@
}
Spacer()
}
.scaleEffect(CGFloat(controllerScale), anchor: .leading)
.frame(width: 150)
.padding(.leading, 80)
.position(x: 150, y: geometry.size.height / 2)

// Action buttons on the right side
// Action buttons on the right side — scale anchored to trailing so buttons
// grow toward the center, never past the right screen edge.
VStack {
Spacer()
VStack(spacing: 10) {
Expand Down Expand Up @@ -533,6 +575,7 @@
}
Spacer()
}
.scaleEffect(CGFloat(controllerScale), anchor: .trailing)
.frame(width: 150)
.position(x: geometry.size.width - 150, y: geometry.size.height / 2)
}
Expand Down Expand Up @@ -707,11 +750,16 @@
return .zero
}

// Scale the side reserve to match however large the per-panel scaleEffect
// makes the landscape controls. Clamped so the game never disappears.
let controllerScaleVal = CGFloat(Defaults[.controllerScale])

Comment on lines +753 to +756

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateDefaultViewport now depends on controllerScale, but the viewport update pipeline is only triggered by ViewportUpdater on size / safeInsets changes. Changing the scale setting while the emulator view is on-screen won’t call emitDefaultViewportIfNeeded, so the game viewport may not adjust until rotation/relaunch. Consider triggering a viewport recalculation on controllerScale changes (e.g., pass controllerScale into ViewportUpdater, add an .onChange(of: controllerScale) that calls emitDefaultViewportIfNeeded with the last known geometry/insets, or force ViewportUpdater to re-appear via .id(controllerScale)).

Copilot uses AI. Check for mistakes.
let frame: CGRect
if isLandscape {
if nativeScaleEnabled {
/// Native scale: Reserve space for controls on each edge, fit within available space
let sideReserve = max(180, min(240, safeWidth * 0.25))
/// Native scale: Reserve space for controls on each edge, scaled by controllerScale
let baseSideReserve = max(180, min(240, safeWidth * 0.25))
let sideReserve = max(80, min(safeWidth * 0.45, baseSideReserve * controllerScaleVal))
let availableWidth = max(0, safeWidth - (sideReserve * 2))
var width = availableWidth
var height = width / aspectRatio
Expand All @@ -726,10 +774,11 @@
let originX = safeInsets.leading + (safeWidth - width) / 2
let originY = safeInsets.top + (safeHeight - height) / 2
frame = CGRect(x: originX, y: originY, width: width, height: height)
ILOG("🎮 SKIN: Default viewport (landscape, native scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), availableWidth=\(availableWidth), frame=\(frame)")
ILOG("🎮 SKIN: Default viewport (landscape, native scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), controllerScaleVal=\(controllerScaleVal), sideReserve=\(sideReserve), availableWidth=\(availableWidth), frame=\(frame)")
} else {
/// Fullscreen scale: Use more of the available screen space, minimal control reserve
let sideReserve = max(120, min(160, safeWidth * 0.15))
let baseSideReserve = max(120, min(160, safeWidth * 0.15))
let sideReserve = max(60, min(safeWidth * 0.40, baseSideReserve * controllerScaleVal))
let availableWidth = max(0, safeWidth - (sideReserve * 2))

/// Scale to fill available width/height more aggressively
Expand Down Expand Up @@ -761,9 +810,11 @@
ILOG("🎮 SKIN: Default viewport (landscape, fullscreen scale): size=\(size), aspectRatio=\(aspectRatio), safeWidth=\(safeWidth), safeHeight=\(safeHeight), availableWidth=\(availableWidth), frame=\(frame)")
}
} else {
/// Keep the screen in the upper portion, leaving room for controls
/// Reserve 35% for controller area, with some margin
let controllerHeight = safeHeight * 0.35
/// Keep the screen in the upper portion, leaving room for controls.
/// Mirror the same fraction used in the portrait layout view so the
/// game viewport exactly matches the space above the controller area.
let controllerFraction = DefaultControllerSkinView.portraitControllerFraction(scale: Double(controllerScaleVal))
let controllerHeight = safeHeight * controllerFraction
Comment on lines +813 to +817

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controllerFraction computation (with the same magic numbers/clamp logic) is duplicated in the SwiftUI layout code and again in calculateDefaultViewport. This makes it easy for future edits to change one path but not the other and reintroduce viewport/overlay mismatches. Consider extracting this into a single helper (e.g. a private static func/constant) that both call sites use.

Copilot uses AI. Check for mistakes.
/// Ensure minimum top safe area to avoid notch (at least 44pt for status bar/notch area)
let minTopSafeArea: CGFloat = 44
let effectiveTopSafeArea = max(safeInsets.top, minTopSafeArea)
Expand Down Expand Up @@ -1569,7 +1620,8 @@
}
}

// D-pad positioned at left edge
// D-pad positioned at left edge — scale anchored to leading so buttons
// grow toward center, never off the left edge of the screen.
VStack {
Spacer()
HStack {
Expand All @@ -1595,14 +1647,18 @@
.buttonStyle(GameButtonStyle(pressAction: {}, releaseAction: {}))
}
}
// Scale the control content toward the leading (left) edge so
// it grows inward — never clips past the left screen boundary.
.scaleEffect(CGFloat(controllerScale), anchor: .leading)
Spacer()
}
.padding(.leading, 80)
Spacer()
}
.frame(width: geometry.size.width, alignment: .leading)

// Action buttons positioned at right edge using absolute positioning
// Action buttons positioned at right edge — scale anchored to trailing
// so buttons grow inward toward center, never off the right screen edge.
VStack {
Spacer()
// Find all button groups in the layout
Expand Down Expand Up @@ -1714,6 +1770,9 @@
}
Spacer()
}
// Scale the right-panel content toward the trailing (right) edge so it
// grows inward — never clips past the right screen boundary.
.scaleEffect(CGFloat(controllerScale), anchor: .trailing)
.frame(width: 250) // Reduced width to prevent clipping
.position(x: geometry.size.width - 150, y: geometry.size.height / 2)
}
Expand Down
Loading