From f364f297f162988f7e1c57f880ee517b5fef36eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 25 Oct 2026 23:51:25 +0100 Subject: [PATCH] Touch controls: Use the full width when the side insets are only for a top corner On the iPhone Duo's cover screen, the camera sits in a box in the top right corner, which gives a side inset down the whole right edge in portrait. The controls are far below it, so they lose width for nothing. iOS 27.1 reports the box as an occlusion region, and from that we report how far down the side insets are needed (SYSPROP_DISPLAY_SIDE_INSETS_BOTTOM). When that's within the top third, the game screen and the touch layout editor skip the side insets. The rest of the UI, including the FPS counter, still keeps them. The regions can show up after the size and insets have settled, so the insets are now recomputed on every layout, and a screen recreates its views when its layout mode changes, since the touch controls are placed for the bounds they were created in. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/System/System.h | 2 ++ Common/UI/Context.cpp | 4 ++++ Common/UI/Screen.h | 1 + Common/UI/UIScreen.cpp | 5 ++++ Common/UI/UIScreen.h | 1 + UI/EmuScreen.cpp | 7 +++--- UI/GamepadEmu.cpp | 9 +++++++ UI/GamepadEmu.h | 2 ++ UI/TouchControlLayoutScreen.cpp | 4 ++++ UI/TouchControlLayoutScreen.h | 3 ++- ios/ViewController.mm | 42 +++++++++++++++++++++++++++++++-- ios/main.mm | 3 +++ 12 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Common/System/System.h b/Common/System/System.h index ac22218c69..466bff7a2e 100644 --- a/Common/System/System.h +++ b/Common/System/System.h @@ -194,6 +194,8 @@ enum SystemProperty { SYSPROP_DISPLAY_SAFE_INSET_RIGHT, SYSPROP_DISPLAY_SAFE_INSET_TOP, SYSPROP_DISPLAY_SAFE_INSET_BOTTOM, + // How far down the screen the side insets are needed (they're for something in a top corner), or -1. + SYSPROP_DISPLAY_SIDE_INSETS_BOTTOM, SYSPROP_DISPLAY_HAS_CAMERA_CUTOUT, SYSPROP_DEVICE_TYPE, diff --git a/Common/UI/Context.cpp b/Common/UI/Context.cpp index 88a165d1f3..1e4832ebc7 100644 --- a/Common/UI/Context.cpp +++ b/Common/UI/Context.cpp @@ -162,6 +162,10 @@ Bounds UIContext::GetLayoutBounds(ViewLayoutMode layoutMode, bool immersiveMode) case ViewLayoutMode::IgnoreBottomInset: bottom = 0.0f; break; + case ViewLayoutMode::IgnoreSideInsets: + left = 0.0f; + right = 0.0f; + break; } // The side insets are only evened out in landscape. In portrait, a camera cutout is at the top, so a diff --git a/Common/UI/Screen.h b/Common/UI/Screen.h index c431a0a669..f42e3c50ad 100644 --- a/Common/UI/Screen.h +++ b/Common/UI/Screen.h @@ -74,6 +74,7 @@ enum class ViewLayoutMode { ApplyInsets = 0, // For gameplay, option #1 IgnoreInsets, // For gameplay, option #2 IgnoreBottomInset, + IgnoreSideInsets, }; enum class QueuedEventType : u8 { diff --git a/Common/UI/UIScreen.cpp b/Common/UI/UIScreen.cpp index 72a7b52201..3de6ea158e 100644 --- a/Common/UI/UIScreen.cpp +++ b/Common/UI/UIScreen.cpp @@ -45,6 +45,7 @@ void UIScreen::DoRecreateViews() { delete root_; root_ = nullptr; SetCreatingViews(true); + lastLayoutMode_ = LayoutMode(); CreateViews(); UI::View *defaultView = root_ ? root_->GetDefaultFocusView() : nullptr; if (defaultView && defaultView->GetVisibility() == UI::V_VISIBLE) { @@ -103,6 +104,10 @@ void UIScreen::update() { RecreateViews(); lastOrientation_ = orientation; } + // Views can be laid out for the bounds they were created in (like the touch controls). + if (LayoutMode() != lastLayoutMode_) { + RecreateViews(); + } DoRecreateViews(); diff --git a/Common/UI/UIScreen.h b/Common/UI/UIScreen.h index 42de88b561..2792bb567c 100644 --- a/Common/UI/UIScreen.h +++ b/Common/UI/UIScreen.h @@ -84,6 +84,7 @@ protected: bool recreateViews_ = true; DeviceOrientation lastOrientation_ = DeviceOrientation::Landscape; + ViewLayoutMode lastLayoutMode_ = ViewLayoutMode::ApplyInsets; }; class UIDialogScreen : public UIScreen { diff --git a/UI/EmuScreen.cpp b/UI/EmuScreen.cpp index dbe7db9b33..4fd09a796c 100644 --- a/UI/EmuScreen.cpp +++ b/UI/EmuScreen.cpp @@ -1418,7 +1418,7 @@ void EmuScreen::OpenChat(bool focus) { int GetChatMessageCount(); ViewLayoutMode EmuScreen::LayoutMode() const { - return ViewLayoutMode::ApplyInsets; + return TouchControlsLayoutMode(); } void EmuScreen::update() { @@ -2022,10 +2022,11 @@ void EmuScreen::renderUI() { if (PSP_IsInited()) { if ((DebugOverlay)g_Config.iDebugOverlay == DebugOverlay::CONTROL) { - DrawControlMapperOverlay(ctx, GetLayoutBounds(*ctx), g_controlMapper); + DrawControlMapperOverlay(ctx, ctx->GetLayoutBounds(ViewLayoutMode::ApplyInsets, UseImmersiveMode()), g_controlMapper); } if (g_Config.iShowStatusFlags) { - DrawFPS(ctx, GetLayoutBounds(*ctx)); + // Top right, so it can't skip the side insets like the controls can. + DrawFPS(ctx, ctx->GetLayoutBounds(ViewLayoutMode::ApplyInsets, UseImmersiveMode())); } } diff --git a/UI/GamepadEmu.cpp b/UI/GamepadEmu.cpp index 8e481b2e5d..388d0d48d7 100644 --- a/UI/GamepadEmu.cpp +++ b/UI/GamepadEmu.cpp @@ -998,6 +998,15 @@ void InitPadLayout(TouchControlConfig *config, DeviceOrientation orientation, fl } } +ViewLayoutMode TouchControlsLayoutMode() { + // The only control the defaults put in the top third is the centered menu button. + const float sideInsetsBottom = System_GetPropertyFloat(SYSPROP_DISPLAY_SIDE_INSETS_BOTTOM); + if (sideInsetsBottom >= 0.0f && sideInsetsBottom < g_display.dp_yres / 3.0f) { + return ViewLayoutMode::IgnoreSideInsets; + } + return ViewLayoutMode::ApplyInsets; +} + UI::ViewGroup *CreatePadLayout(const TouchControlConfig &config, float xres, float yres, bool *pause, ControlMapper *controlMapper) { using namespace UI; return new GamepadEmuView(config, xres, yres, pause, controlMapper, new UI::LayoutParams(UI::FILL_PARENT, UI::FILL_PARENT)); diff --git a/UI/GamepadEmu.h b/UI/GamepadEmu.h index 64598bfe72..165076562f 100644 --- a/UI/GamepadEmu.h +++ b/UI/GamepadEmu.h @@ -179,6 +179,8 @@ struct TouchControlConfig; // Initializes the layout from Config. if a default layout does not exist, it sets up default values void InitPadLayout(TouchControlConfig *config, DeviceOrientation orientation, float xres, float yres, float globalScale = 1.15f); +// Lets the controls use the full width when the side insets don't reach down to them. +ViewLayoutMode TouchControlsLayoutMode(); UI::ViewGroup *CreatePadLayout(const TouchControlConfig &config, float xres, float yres, bool *pause, ControlMapper *controlMapper); const int D_pad_Radius = 50; diff --git a/UI/TouchControlLayoutScreen.cpp b/UI/TouchControlLayoutScreen.cpp index cd845f2f4a..62a6398423 100644 --- a/UI/TouchControlLayoutScreen.cpp +++ b/UI/TouchControlLayoutScreen.cpp @@ -586,6 +586,10 @@ void TouchControlLayoutScreen::onFinish(DialogResult reason) { g_Config.Save("TouchControlLayoutScreen::onFinish"); } +ViewLayoutMode TouchControlLayoutScreen::LayoutMode() const { + return TouchControlsLayoutMode(); +} + void TouchControlLayoutScreen::OnReset(UI::EventParams &e) { INFO_LOG(Log::G3D, "Resetting touch control layout to default."); diff --git a/UI/TouchControlLayoutScreen.h b/UI/TouchControlLayoutScreen.h index 32fbc4ea2f..4ba8efe55d 100644 --- a/UI/TouchControlLayoutScreen.h +++ b/UI/TouchControlLayoutScreen.h @@ -39,7 +39,8 @@ public: const char *tag() const override { return "TouchControlLayout"; } protected: - ViewLayoutMode LayoutMode() const override { return ViewLayoutMode::ApplyInsets; } + // The same as EmuScreen's, so the controls go where they will be in the game. + ViewLayoutMode LayoutMode() const override; void OnReset(UI::EventParams &e); void OnMode(UI::EventParams &e); diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 9092d98272..5c7e3d5bae 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -682,6 +682,7 @@ extern float g_safeInsetLeft; extern float g_safeInsetRight; extern float g_safeInsetTop; extern float g_safeInsetBottom; +extern float g_sideInsetsBottom; - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; @@ -724,6 +725,42 @@ extern float g_safeInsetBottom; g_safeInsetRight = std::max(g_safeInsetRight, cornerInset); } #endif + + const CGFloat sideInsetsBottom = [self sideInsetsBottom]; + g_sideInsetsBottom = sideInsetsBottom < 0.0 ? -1.0f : sideInsetsBottom * scale; +} + +// How far down the screen the side insets are needed, in points, or -1 if all the way. Side insets +// come from occlusions like the camera, and on the iPhone Duo's cover screen that's a box in the top +// right corner, which the touch controls don't need to avoid. +- (CGFloat)sideInsetsBottom { +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 + if (@available(iOS 27.1, *)) { + UIEdgeInsets safe = self.view.safeAreaInsets; + if (safe.left <= 0.0 && safe.right <= 0.0) { + return -1.0; + } + const CGFloat width = self.view.bounds.size.width; + bool leftExplained = safe.left <= 0.0; + bool rightExplained = safe.right <= 0.0; + CGFloat bottom = 0.0; + for (UIViewReservedRegion *region in [self.view reservedRegionsOfKind:UIViewReservedRegionKind.occlusionRegionKind]) { + const CGRect frame = region.frame; + const bool onLeft = safe.left > 0.0 && CGRectGetMinX(frame) < safe.left; + const bool onRight = safe.right > 0.0 && CGRectGetMaxX(frame) > width - safe.right; + if (onLeft || onRight) { + leftExplained |= onLeft; + rightExplained |= onRight; + bottom = std::max(bottom, CGRectGetMaxY(frame)); + } + } + // An inset with no occlusion behind it (like the home indicator's) could be anywhere. + if (leftExplained && rightExplained) { + return bottom; + } + } +#endif + return -1.0; } - (void)shareText:(NSString *)text { @@ -861,13 +898,14 @@ static NSSet *NonKeyboardPresses(NSSet *presses) { // backbuffer matching the view while it animates. - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; + // A rotation doesn't always change the system's insets, but it can change ours. And the reserved + // regions can show up after the size and insets have settled. + [self updateSafeInsets]; CGSize size = self.view.bounds.size; if (size.width <= 0.0 || size.height <= 0.0 || CGSizeEqualToSize(size, lastLayoutSize_)) { return; } lastLayoutSize_ = size; - // A rotation doesn't always change the system's insets, but it can change ours. - [self updateSafeInsets]; #if !PPSSPP_PLATFORM(TVOS) if (self.view.window.windowScene) { [cameraHelper setInterfaceOrientation:self.view.window.windowScene.interfaceOrientation]; diff --git a/ios/main.mm b/ios/main.mm index 28ecf5affe..2ed28c15da 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -249,6 +249,7 @@ float g_safeInsetLeft = 0.0; float g_safeInsetRight = 0.0; float g_safeInsetTop = 0.0; float g_safeInsetBottom = 0.0; +float g_sideInsetsBottom = -1.0; static bool g_jitAvailable = true; @@ -322,6 +323,8 @@ float System_GetPropertyFloat(SystemProperty prop) { return g_safeInsetTop * g_display.dpi_scale_y; case SYSPROP_DISPLAY_SAFE_INSET_BOTTOM: return g_safeInsetBottom * g_display.dpi_scale_y; + case SYSPROP_DISPLAY_SIDE_INSETS_BOTTOM: + return g_sideInsetsBottom < 0.0f ? -1.0f : g_sideInsetsBottom * g_display.dpi_scale_y; default: return -1; }