From 58fa522d48fa40cbaad96e5e1f79cd7f5ff5237f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 24 Oct 2026 14:53:45 +0200 Subject: [PATCH] UI: Limit the width of dialog screens, and keep content off the edges of large screens Dialog screens (tabbed, two-pane, simple and the others built on UIBaseDialogScreen) center their content at up to 1400 wide, instead of stretching settings rows and buttons across very wide windows. On large screens, those screens and the main screen also get an inset on all sides, since controls right at the edge are hard to see there. It grows with the screen's smaller dimension, from nothing at 600 to the full inset at 1000, and is always full on TVs. The game, popups, and the screens that position things to match the game's screen (display layout, touch control layout and tests, custom button and visual mapping) are left as they were. The root's bounds are computed by UIScreen::GetContentBounds, since UI::Margins can't hold widths this large. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/UI/Root.cpp | 17 +++++++++------ Common/UI/Root.h | 1 + Common/UI/UIScreen.cpp | 40 ++++++++++++++++++++++++++++++++-- Common/UI/UIScreen.h | 7 ++++++ UI/BaseScreens.h | 5 +++++ UI/ControlMappingScreen.h | 2 ++ UI/CustomButtonMappingScreen.h | 2 ++ UI/DevScreens.h | 2 ++ UI/DisplayLayoutScreen.h | 2 ++ UI/GPUDriverTestScreen.h | 1 + UI/MiscScreens.h | 1 + UI/TouchControlLayoutScreen.h | 2 ++ 12 files changed, 73 insertions(+), 9 deletions(-) diff --git a/Common/UI/Root.cpp b/Common/UI/Root.cpp index 1bd67325c7..f896770046 100644 --- a/Common/UI/Root.cpp +++ b/Common/UI/Root.cpp @@ -149,17 +149,20 @@ bool IsFocusMovementEnabled() { void LayoutViewHierarchy(const UIContext &dc, const UI::Margins &rootMargins, ViewGroup *root, ViewLayoutMode layoutMode, bool immersiveMode) { Bounds rootBounds = dc.GetLayoutBounds(layoutMode, immersiveMode); - - MeasureSpec horiz(EXACTLY, rootBounds.w - (rootMargins.left + rootMargins.right)); - MeasureSpec vert(EXACTLY, rootBounds.h - (rootMargins.top + rootMargins.bottom)); - - // Two phases - measure contents, layout. - root->Measure(dc, horiz, vert); - // Root has a specified size. Set it, then let root layout all its children. rootBounds.x += rootMargins.left; rootBounds.y += rootMargins.top; rootBounds.w -= rootMargins.left + rootMargins.right; rootBounds.h -= rootMargins.top + rootMargins.bottom; + LayoutViewHierarchy(dc, rootBounds, root); +} + +void LayoutViewHierarchy(const UIContext &dc, const Bounds &rootBounds, ViewGroup *root) { + MeasureSpec horiz(EXACTLY, rootBounds.w); + MeasureSpec vert(EXACTLY, rootBounds.h); + + // Two phases - measure contents, layout. + root->Measure(dc, horiz, vert); + // Root has a specified size. Set it, then let root layout all its children. root->SetBounds(rootBounds); root->Layout(); } diff --git a/Common/UI/Root.h b/Common/UI/Root.h index bf1b0ab76f..d783107194 100644 --- a/Common/UI/Root.h +++ b/Common/UI/Root.h @@ -35,6 +35,7 @@ class ViewGroup; View *FindTabOrderNeighbor(ViewGroup *root, View *focusedView, FocusMove direction); void LayoutViewHierarchy(const UIContext &dc, const UI::Margins &rootMargins, UI::ViewGroup *root, ViewLayoutMode layoutMode, bool immersiveMode); +void LayoutViewHierarchy(const UIContext &dc, const Bounds &rootBounds, UI::ViewGroup *root); DialogResult UpdateViewHierarchy(ViewGroup *root, bool canEnableFocusMovement = true); enum class KeyEventResult { diff --git a/Common/UI/UIScreen.cpp b/Common/UI/UIScreen.cpp index d1547cf460..d2fdfbd185 100644 --- a/Common/UI/UIScreen.cpp +++ b/Common/UI/UIScreen.cpp @@ -58,7 +58,7 @@ void UIScreen::DoRecreateViews() { // Update layout and refocus so things scroll into view. // This is for resizing down, when focused on something now offscreen. - UI::LayoutViewHierarchy(*screenManager()->getUIContext(), RootMargins(), root_, LayoutMode(), UseImmersiveMode()); + UI::LayoutViewHierarchy(*screenManager()->getUIContext(), GetContentBounds(*screenManager()->getUIContext()), root_); UI::View *focused = UI::GetFocusedView(); if (focused) { root_->SubviewFocused(focused); @@ -128,6 +128,42 @@ Bounds UIScreen::GetLayoutBounds(UIContext &dc) const { return dc.GetLayoutBounds(LayoutMode(), UseImmersiveMode()); } +// On TVs and big monitors, controls out at the edges are hard to see and far apart. Grows with the +// screen, from nothing at 600 high (or wide, if smaller) to the full inset at 1000, so resizing a window +// doesn't make it jump. +static float LargeScreenInset() { + const float fullInset = 40.0f; + if (System_GetPropertyInt(SYSPROP_DEVICE_TYPE) == DEVICE_TYPE_TV) { + return fullInset; + } + const float size = (float)std::min(g_display.dp_xres, g_display.dp_yres); + return fullInset * std::clamp((size - 600.0f) / 400.0f, 0.0f, 1.0f); +} + +Bounds UIScreen::GetContentBounds(const UIContext &dc) const { + const UI::Margins margins = RootMargins(); + Bounds bounds = dc.GetLayoutBounds(LayoutMode(), UseImmersiveMode()); + bounds.x += margins.left; + bounds.y += margins.top; + bounds.w -= margins.horiz(); + bounds.h -= margins.vert(); + if (!LimitContentArea()) { + return bounds; + } + + const float inset = LargeScreenInset(); + bounds.x += inset; + bounds.y += inset; + bounds.w -= inset * 2.0f; + bounds.h -= inset * 2.0f; + const float maxWidth = MaxContentWidth(); + if (maxWidth > 0.0f && bounds.w > maxWidth) { + bounds.x += (bounds.w - maxWidth) * 0.5f; + bounds.w = maxWidth; + } + return bounds; +} + ScreenRenderFlags UIScreen::render(ScreenRenderMode mode) { // A screen rendered under a popup must keep its views: the popup's callbacks point into them (the choice // that opened it, the value it writes to). update() recreates them once the screen is on top again. @@ -137,7 +173,7 @@ ScreenRenderFlags UIScreen::render(ScreenRenderMode mode) { UIContext &uiContext = *screenManager()->getUIContext(); if (root_) { - UI::LayoutViewHierarchy(uiContext, RootMargins(), root_, LayoutMode(), UseImmersiveMode()); + UI::LayoutViewHierarchy(uiContext, GetContentBounds(uiContext), root_); } uiContext.PushTransform({translation_, scale_, alpha_}); diff --git a/Common/UI/UIScreen.h b/Common/UI/UIScreen.h index c1bf67f2ac..42de88b561 100644 --- a/Common/UI/UIScreen.h +++ b/Common/UI/UIScreen.h @@ -41,6 +41,11 @@ public: void OnBack(UI::EventParams &e); virtual UI::Margins RootMargins() const { return UI::Margins(0); } + // Whether to keep the content off the edges of large screens (like TVs), and within MaxContentWidth. + // Off for screens that cover the whole display, like the game. + virtual bool LimitContentArea() const { return false; } + // Wider content is centered at this width. 0 for no limit. + virtual float MaxContentWidth() const { return 0.0f; } virtual void focusChanged(ScreenFocusChange focusChange) override { Screen::focusChanged(focusChange); @@ -56,6 +61,8 @@ protected: virtual bool AllowFocusMovement() const { return true; } Bounds GetLayoutBounds(UIContext &dc) const; + // Where root_ goes: the layout bounds, less RootMargins and the limits above. + Bounds GetContentBounds(const UIContext &dc) const; void RecreateViews() override { recreateViews_ = true; } DeviceOrientation GetDeviceOrientation() const; diff --git a/UI/BaseScreens.h b/UI/BaseScreens.h index cb8d1e0918..c9dd203d38 100644 --- a/UI/BaseScreens.h +++ b/UI/BaseScreens.h @@ -12,6 +12,7 @@ public: return ViewLayoutMode::IgnoreBottomInset; } bool UseImmersiveMode() const override; + bool LimitContentArea() const override { return true; } protected: void sendMessage(UIMessage message, const char *value) override; }; @@ -20,6 +21,10 @@ class UIBaseDialogScreen : public UIDialogScreen { public: UIBaseDialogScreen() : UIDialogScreen(), gamePath_() {} explicit UIBaseDialogScreen(const Path &gamePath) : UIDialogScreen(), gamePath_(gamePath) {} + + bool LimitContentArea() const override { return true; } + float MaxContentWidth() const override { return 1400.0f; } + protected: ViewLayoutMode LayoutMode() const override; bool UseImmersiveMode() const override; diff --git a/UI/ControlMappingScreen.h b/UI/ControlMappingScreen.h index 5cb2491748..bbeb4d9287 100644 --- a/UI/ControlMappingScreen.h +++ b/UI/ControlMappingScreen.h @@ -165,6 +165,8 @@ class MockPSP; class VisualMappingScreen : public UIBaseDialogScreen { public: + // Places the controller picture from the screen bounds. + bool LimitContentArea() const override { return false; } VisualMappingScreen(const Path &gamePath) : UIBaseDialogScreen(gamePath) {} const char *tag() const override { return "VisualMapping"; } diff --git a/UI/CustomButtonMappingScreen.h b/UI/CustomButtonMappingScreen.h index 159866f07a..a857884e4c 100644 --- a/UI/CustomButtonMappingScreen.h +++ b/UI/CustomButtonMappingScreen.h @@ -27,6 +27,8 @@ namespace UI { class CustomButtonMappingScreen : public UISimpleBaseDialogScreen { public: + // Places the button preview from the screen bounds. + bool LimitContentArea() const override { return false; } CustomButtonMappingScreen(DeviceOrientation deviceOrientation, const Path &gamePath, int id) : UISimpleBaseDialogScreen(gamePath, SimpleDialogFlags::Default), deviceOrientation_(deviceOrientation), id_(id) {} const char *tag() const override { return "CustomButton"; } diff --git a/UI/DevScreens.h b/UI/DevScreens.h index ee39634592..5613dc5e93 100644 --- a/UI/DevScreens.h +++ b/UI/DevScreens.h @@ -157,6 +157,8 @@ private: class TouchTestScreen : public UIBaseDialogScreen { public: + // Shows touches where they are on the screen. + bool LimitContentArea() const override { return false; } TouchTestScreen(const Path &gamePath) : UIBaseDialogScreen(gamePath) { for (int i = 0; i < MAX_TOUCH_POINTS; i++) { touches_[i].id = -1; diff --git a/UI/DisplayLayoutScreen.h b/UI/DisplayLayoutScreen.h index ac9b07e8c5..dd7b77059b 100644 --- a/UI/DisplayLayoutScreen.h +++ b/UI/DisplayLayoutScreen.h @@ -31,6 +31,8 @@ class ChoiceStrip; class DisplayLayoutScreen : public UIBaseDialogScreen { public: + // The game's image goes where it will be in the game. + bool LimitContentArea() const override { return false; } DisplayLayoutScreen(const Path &filename); void CreateViews() override; void dialogFinished(const Screen *dialog, DialogResult result) override; diff --git a/UI/GPUDriverTestScreen.h b/UI/GPUDriverTestScreen.h index 66e28b9779..95ee4f1099 100644 --- a/UI/GPUDriverTestScreen.h +++ b/UI/GPUDriverTestScreen.h @@ -12,6 +12,7 @@ class GPUDriverTestScreen : public UIBaseDialogScreen { public: + bool LimitContentArea() const override { return false; } GPUDriverTestScreen(); ~GPUDriverTestScreen(); diff --git a/UI/MiscScreens.h b/UI/MiscScreens.h index ceb8b35ae8..ba7d8a53a6 100644 --- a/UI/MiscScreens.h +++ b/UI/MiscScreens.h @@ -99,6 +99,7 @@ enum class AfterLogoScreen { class LogoScreen : public UIBaseScreen { public: + bool LimitContentArea() const override { return false; } LogoScreen(AfterLogoScreen afterLogoScreen = AfterLogoScreen::DEFAULT); bool key(const KeyInput &key) override; diff --git a/UI/TouchControlLayoutScreen.h b/UI/TouchControlLayoutScreen.h index 5287387737..32fbc4ea2f 100644 --- a/UI/TouchControlLayoutScreen.h +++ b/UI/TouchControlLayoutScreen.h @@ -26,6 +26,8 @@ class ControlLayoutView; class TouchControlLayoutScreen : public UIBaseDialogScreen { public: + // The controls go where they will be in the game. + bool LimitContentArea() const override { return false; } TouchControlLayoutScreen(const Path &gamePath) : UIBaseDialogScreen(gamePath) {} void CreateViews() override;