diff --git a/src/Settings/Integration/RitsuLibModSettingsBootstrap.CategoryPages.cs b/src/Settings/Integration/RitsuLibModSettingsBootstrap.CategoryPages.cs index 88c67025..677ad930 100644 --- a/src/Settings/Integration/RitsuLibModSettingsBootstrap.CategoryPages.cs +++ b/src/Settings/Integration/RitsuLibModSettingsBootstrap.CategoryPages.cs @@ -97,7 +97,7 @@ .. RitsuShellThemeCatalog.RegisteredThemeIds T("ritsulib.mainMenuScrolling.enabled.label", "Enable main menu scrolling"), ui.MainMenuScrollingEnabled, T("ritsulib.mainMenuScrolling.enabled.description", - "Keeps long menu lists within the screen and scrolls focused entries into view. Enabled by default. Disable to use the original menu layout and navigation. Restart the game after changing this setting.")); + "Keeps long menu lists within the screen. Overflow shrinks and fades edge buttons; mouse wheel and focus still scroll. Enabled by default. Disable to use the original menu layout and navigation. Restart the game after changing this setting.")); } #if !STS2_AT_LEAST_0_108_0 diff --git a/src/Settings/Localization/ModSettingsUi/eng.json b/src/Settings/Localization/ModSettingsUi/eng.json index f1518948..3f74cb17 100644 --- a/src/Settings/Localization/ModSettingsUi/eng.json +++ b/src/Settings/Localization/ModSettingsUi/eng.json @@ -213,7 +213,7 @@ "ritsulib.mainMenuModSettingsButton.enabled.label": "Show main menu settings shortcut", "ritsulib.mainMenuModSettingsButton.enabled.description": "Shows a RitsuLib settings shortcut under the patch notes button on the main menu.", "ritsulib.mainMenuScrolling.enabled.label": "Enable main menu scrolling", - "ritsulib.mainMenuScrolling.enabled.description": "Keeps long menu lists within the screen and scrolls focused entries into view. Enabled by default. Disable to use the original menu layout and navigation. Restart the game after changing this setting.", + "ritsulib.mainMenuScrolling.enabled.description": "Keeps long menu lists within the screen. Overflow shrinks and fades edge buttons; mouse wheel and focus still scroll. Enabled by default. Disable to use the original menu layout and navigation. Restart the game after changing this setting.", "ritsulib.modSourceHoverTips.enabled.label": "Show content source hover tips", "ritsulib.modSourceHoverTips.enabled.description": "Shows which mod provides cards, relics, potions, events, keywords, and other model hover tips.", "ritsulib.modSourceHoverTips.displayStyle.label": "Source display style", diff --git a/src/Settings/Localization/ModSettingsUi/zhs.json b/src/Settings/Localization/ModSettingsUi/zhs.json index 46a8a95b..ae01fb29 100644 --- a/src/Settings/Localization/ModSettingsUi/zhs.json +++ b/src/Settings/Localization/ModSettingsUi/zhs.json @@ -213,7 +213,7 @@ "ritsulib.mainMenuModSettingsButton.enabled.label": "显示主菜单设置快捷入口", "ritsulib.mainMenuModSettingsButton.enabled.description": "在主菜单更新日志按钮下方显示 RitsuLib 设置快捷入口。", "ritsulib.mainMenuScrolling.enabled.label": "启用主菜单滚动", - "ritsulib.mainMenuScrolling.enabled.description": "将过长的菜单列表限制在屏幕内,并自动滚动显示当前选中项。默认启用;关闭后使用原版菜单布局和导航。修改后请重启游戏。", + "ritsulib.mainMenuScrolling.enabled.description": "将过长的菜单列表限制在屏幕内,边缘按钮会变小变透明,表示还能滚动;滚轮和焦点仍可滚动。默认启用;关闭后使用原版菜单布局和导航。修改后请重启游戏。", "ritsulib.modSourceHoverTips.enabled.label": "显示内容来源悬停提示", "ritsulib.modSourceHoverTips.enabled.description": "为卡牌、遗物、药水、事件、关键词和其他模型悬停提示显示内容来源模组。", "ritsulib.modSourceHoverTips.displayStyle.label": "来源显示样式", diff --git a/src/Ui/MainMenu/NMainMenuScroller.Decorations.cs b/src/Ui/MainMenu/NMainMenuScroller.Decorations.cs index 1659dace..fd800fca 100644 --- a/src/Ui/MainMenu/NMainMenuScroller.Decorations.cs +++ b/src/Ui/MainMenu/NMainMenuScroller.Decorations.cs @@ -60,6 +60,7 @@ internal bool AnimateRunInfo(NContinueRunInfo info, bool show) private void ShowReticles(NMainMenuTextButton button) { + var previous = _reticleButton; _reticleButton = button; _reticleTween?.Kill(); _reticleProgress = 0f; @@ -70,6 +71,7 @@ private void ShowReticles(NMainMenuTextButton button) UpdateDecorations(); }), 0f, 1f, 0.2).SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); UpdateDecorations(); + RefreshEdgeScale(previous, button); } private void HideReticles(NMainMenuTextButton button) @@ -79,14 +81,14 @@ private void HideReticles(NMainMenuTextButton button) _reticleTween?.Kill(); _reticleButton = null; UpdateDecorations(); + RefreshEdgeScale(button); } private void UpdateDecorations() { if (!Initialized) return; - var showReticles = IsActive && IsNavigable(_reticleButton) && - _reticleButton!.GetParent() == this && IsRowFullyVisible(_reticleButton); + var showReticles = IsActive && IsReticleTarget(_reticleButton); if (_reticleLeft != null && _reticleRight != null && IsInstanceValid(_reticleLeft) && IsInstanceValid(_reticleRight)) { @@ -115,8 +117,9 @@ private void UpdateDecorations() var position = _runInfoButton.GlobalPosition + _runInfoOffset + new Vector2(0f, -20f * _runInfoProgress); position.X = Mathf.Clamp(position.X, menuRect.Position.X + EdgePadding, Mathf.Max(menuRect.Position.X + EdgePadding, menuRect.End.X - _runInfo.Size.X - EdgePadding)); - position.Y = Mathf.Clamp(position.Y, GlobalPosition.Y, - Mathf.Max(GlobalPosition.Y, menuRect.End.Y - _runInfo.Size.Y - EdgePadding)); + var topLimit = ScrollEngaged ? GlobalPosition.Y : menuRect.Position.Y + EdgePadding; + position.Y = Mathf.Clamp(position.Y, topLimit, + Mathf.Max(topLimit, menuRect.End.Y - _runInfo.Size.Y - EdgePadding)); _runInfo.GlobalPosition = position; var infoColor = _runInfo.Modulate; infoColor.A = _runInfoProgress; @@ -126,12 +129,14 @@ private void UpdateDecorations() private void HideDecorations() { _reticleTween?.Kill(); + var previous = _reticleButton; _reticleButton = null; _runInfoTween?.Kill(); _runInfoProgress = 0f; if (_runInfo != null && IsInstanceValid(_runInfo)) RunInfoShown(_runInfo) = false; UpdateDecorations(); + RefreshEdgeScale(previous); } private void DisposeDecorations() @@ -139,5 +144,80 @@ private void DisposeDecorations() _reticleTween?.Kill(); _runInfoTween?.Kill(); } + + private bool IsReticleTarget(Control? item) + { + return item != null && item == _reticleButton && IsNavigable(_reticleButton) && + _reticleButton.GetParent() == this && + (_reticleButton.HasFocus() || IsRowFullyVisible(_reticleButton)); + } + + private (float topWeight, float bottomWeight, float start) GetEdgeScaleContext() + { + var start = (ScrollEngaged + ? ContentPadding + : (Size.Y - _contentHeight) / 2f) - _visualScroll; + return (Mathf.SmoothStep(0f, EdgeZone, _visualScroll), + Mathf.SmoothStep(0f, EdgeZone, _scrollLimit - _visualScroll), + start); + } + + private void RefreshEdgeScale(params Control?[] items) + { + if (!Initialized) + return; + var (topWeight, bottomWeight, start) = GetEdgeScaleContext(); + foreach (var item in items) + { + if (item == null || !IsInstanceValid(item) || !_measurements.TryGetValue(item, out var minimum)) + continue; + ApplyEdgeScale(item, start + _rowTops[item], minimum.Y, topWeight, bottomWeight); + } + } + + private void ApplyEdgeScale(Control item, float top, float height, float topWeight, float bottomWeight) + { + if (IsReticleTarget(item)) + { + ResetEdgeScale(item); + return; + } + + var amount = 1f; + var pivotY = height * 0.5f; + if (topWeight > 0f && top < EdgeZone) + BlendEdge(topWeight, top + height * 0.5f, height * 0.5f, height, ref amount, ref pivotY); + if (bottomWeight > 0f && top + height > Size.Y - EdgeZone) + BlendEdge(bottomWeight, Size.Y - (top + height * 0.5f), pivotY, 0f, ref amount, ref pivotY); + + item.PivotOffset = new(item.Size.X * 0.5f, pivotY); + item.Scale = Vector2.One * Mathf.Lerp(EdgeMinScale, 1f, amount); + var color = item.Modulate; + color.A = Mathf.Lerp(EdgeMinAlpha, 1f, amount); + item.Modulate = color; + } + + private static void BlendEdge( + float weight, float distance, float fromPivot, float toPivot, ref float amount, ref float pivotY) + { + var positional = SmootherStep01(distance / EdgeZone); + amount = Mathf.Min(amount, Mathf.Lerp(1f, positional, weight)); + pivotY = Mathf.Lerp(fromPivot, toPivot, weight * (1f - positional)); + } + + private static float SmootherStep01(float t) + { + t = Mathf.Clamp(t, 0f, 1f); + return t * t * t * (t * (t * 6f - 15f) + 10f); + } + + private void ResetEdgeScale(Control item) + { + item.Scale = Vector2.One; + item.PivotOffset = Vector2.Zero; + var color = item.Modulate; + color.A = 1f; + item.Modulate = color; + } } } diff --git a/src/Ui/MainMenu/NMainMenuScroller.Input.cs b/src/Ui/MainMenu/NMainMenuScroller.Input.cs index 240df460..63cd389f 100644 --- a/src/Ui/MainMenu/NMainMenuScroller.Input.cs +++ b/src/Ui/MainMenu/NMainMenuScroller.Input.cs @@ -13,21 +13,32 @@ namespace STS2RitsuLib.Ui.MainMenu { internal sealed partial class NMainMenuScroller { + private const float HeightTweenDuration = 0.2f; private static readonly AccessTools.FieldRef Pressed = AccessTools.FieldRefAccess("_isPressed"); - - private static readonly AccessTools.FieldRef ScrollBarDragging = - AccessTools.FieldRefAccess("_isDragging"); + private static readonly AccessTools.FieldRef Hovered = + AccessTools.FieldRefAccess("_isHovered"); + private static readonly AccessTools.FieldRef ControllerFocused = + AccessTools.FieldRefAccess("_isControllerFocused"); + private static readonly Action RefreshClickableFocus = + AccessTools.MethodDelegate>( + AccessTools.DeclaredMethod(typeof(NClickableControl), "RefreshFocus")); private NMainMenuTextButton? _lastFocused; private NMainMenuTextButton? _pressedButton; private int _lastFocusedIndex; private bool _wasActive; private bool _restorePending; + private bool _keyboardNavigating; private NControllerManager? _controllerManager; + private Tween? _visualScrollTween; + private float _visualScroll; + private int _heightStepIndex; private static bool IsDirectional => Sts2InputCompat.IsUsingDirectionalNavigation; + private bool FollowsFocus => _keyboardNavigating || IsDirectional; + private bool IsActive => Initialized && IsVisibleInTree() && ActiveScreenContext.Instance.IsCurrent(_mainMenu) && NGame.Instance?.Transition?.InTransition != true; @@ -66,7 +77,6 @@ private static bool IsNavigable(NMainMenuTextButton? button) private void OnWindowFocusExited() { CancelPress(); - ScrollBarDragging(_scrollBar) = false; } internal Control? GetDefaultFocus() @@ -100,11 +110,13 @@ private void OnGuiFocusChanged(Control control) { if (_pressedButton != control) CancelPress(); - if (!IsActive || !IsDirectional || control.GetParent() != this) + if (!IsActive || control.GetParent() != this || control is not NMainMenuTextButton button) return; if (_layoutDirty) RefreshLayout(); - EnsureVisible(control); + if (!FollowsFocus) + return; + AdoptExclusiveFocus(button, mouse: false); } private void OnButtonFocused(NClickableControl control) @@ -115,9 +127,10 @@ private void OnButtonFocused(NClickableControl control) _lastFocusedIndex = Math.Max(0, _buttons.IndexOf(button)); if (_layoutDirty) RefreshLayout(); - if (IsDirectional) - EnsureVisible(button); - ShowReticles(button); + if (button.HasFocus()) + AdoptExclusiveFocus(button, mouse: false); + else + AdoptExclusiveFocus(button, mouse: true); } private void OnButtonUnfocused(NClickableControl control) @@ -128,10 +141,53 @@ private void OnButtonUnfocused(NClickableControl control) HideReticles(button); } + private void AdoptExclusiveFocus(NMainMenuTextButton button, bool mouse) + { + if (mouse) + { + _keyboardNavigating = false; + ClearGodotFocus(); + ShowReticles(button); + return; + } + + _keyboardNavigating = true; + ClearHoverFocus(); + if (!button.HasFocus()) + button.GrabFocus(); + EnsureVisible(button); + ShowReticles(button); + } + + private void ClearHoverFocus() + { + foreach (var button in _buttons) + { + if (!IsInstanceValid(button) || !Hovered(button)) + continue; + Hovered(button) = false; + RefreshClickableFocus(button); + } + } + + private void ClearGodotFocus() + { + foreach (var button in _buttons) + { + if (!IsInstanceValid(button)) + continue; + if (button.HasFocus()) + button.ReleaseFocus(); + if (!ControllerFocused(button)) + continue; + ControllerFocused(button) = false; + RefreshClickableFocus(button); + } + } + private void OnScreenChanged() { CancelPress(); - ScrollBarDragging(_scrollBar) = false; _restorePending = IsDirectional; if (!IsActive) HideDecorations(); @@ -147,13 +203,19 @@ private void OnMouseDetected() { CancelPress(); _restorePending = false; + _keyboardNavigating = false; + ClearGodotFocus(); + var pos = GetGlobalMousePosition(); + if (_buttons.LastOrDefault(button => + IsNavigable(button) && button.GetGlobalRect().HasPoint(pos)) is { } hovered) + ShowReticles(hovered); } private void OnControllerDetected() { CancelPress(); - ScrollBarDragging(_scrollBar) = false; _restorePending = true; + ClearHoverFocus(); RestoreFocusIfMissing(); } @@ -169,7 +231,7 @@ private void RefreshInteraction() } } else if (!_wasActive || _restorePending || - (IsDirectional && GetViewport().GuiGetFocusOwner() == null) || + (FollowsFocus && GetViewport().GuiGetFocusOwner() == null) || (_lastFocused != null && !IsNavigable(_lastFocused))) { RestoreFocusIfMissing(); @@ -187,27 +249,20 @@ private void RestoreFocusIfMissing() if (current != null && (current.GetParent() != this || current is NMainMenuTextButton button && IsNavigable(button))) { - if (current.GetParent() == this) - { - EnsureVisible(current); - if (current is NMainMenuTextButton focusedButton && _reticleButton != focusedButton) - ShowReticles(focusedButton); - } + if (current.GetParent() == this && current is NMainMenuTextButton focusedButton) + AdoptExclusiveFocus(focusedButton, mouse: false); return; } - if (GetDefaultFocus() is not { } target) + if (GetDefaultFocus() is not NMainMenuTextButton target) return; - EnsureVisible(target); - target.GrabFocus(); - if (target is NMainMenuTextButton textButton) - ShowReticles(textButton); + AdoptExclusiveFocus(target, mouse: false); } public override void _GuiInput(InputEvent inputEvent) { - if (!IsActive || ScrollLimit <= 0f) + if (!IsActive || !ScrollEngaged) return; if (inputEvent is InputEventMouseButton { @@ -221,16 +276,56 @@ public override void _GuiInput(InputEvent inputEvent) } } + public override void _UnhandledInput(InputEvent inputEvent) + { + if (!IsActive || + !inputEvent.IsActionPressed(MegaInput.up) && !inputEvent.IsActionPressed(MegaInput.down)) + return; + if (GetViewport().GuiGetFocusOwner() is Control focused && focused.GetParent() == this) + return; + + _keyboardNavigating = true; + RebuildNavigation(); + var navigable = _buttons.Where(IsNavigable).ToList(); + if (navigable.Count == 0) + return; + var from = navigable.FirstOrDefault(button => Hovered(button)) ?? + (IsNavigable(_lastFocused) ? _lastFocused : null) ?? + navigable[0]; + var index = Math.Max(0, navigable.IndexOf(from)); + var target = inputEvent.IsActionPressed(MegaInput.down) + ? navigable[Math.Min(navigable.Count - 1, index + 1)] + : navigable[Math.Max(0, index - 1)]; + AdoptExclusiveFocus(target, mouse: false); + GetViewport().SetInputAsHandled(); + } + internal bool FilterButtonInput(NMainMenuTextButton button, InputEvent inputEvent) { if (_layoutDirty) RefreshLayout(); + if (inputEvent is InputEventMouseButton + { + Pressed: true, ButtonIndex: MouseButton.WheelUp or MouseButton.WheelDown, + } + or InputEventPanGesture) + { + if (!IsActive || !ScrollEngaged) + return false; + CancelPress(); + SetScroll(_scroll - ScrollHelper.GetDragForScrollEvent(inputEvent)); + GetViewport().SetInputAsHandled(); + return false; + } + if (inputEvent.IsActionPressed(MegaInput.up) || inputEvent.IsActionPressed(MegaInput.down) || inputEvent.IsActionPressed(MegaInput.left) || inputEvent.IsActionPressed(MegaInput.right)) { CancelPress(); if (!IsActive) return false; + _keyboardNavigating = true; + ClearHoverFocus(); RebuildNavigation(); return true; } @@ -243,12 +338,12 @@ internal bool FilterButtonInput(NMainMenuTextButton button, InputEvent inputEven var valid = IsActive && !button.IsQueuedForDeletion() && button.IsVisibleInTree() && button.IsEnabled && button.GetParent() == this; - if (valid && isSelect && IsDirectional) + if (valid && isSelect && FollowsFocus) EnsureVisible(button); valid &= isMouseClick - ? GetGlobalRect().HasPoint(GetGlobalMousePosition()) && + ? (!ScrollEngaged || GetGlobalRect().HasPoint(GetGlobalMousePosition())) && button.GetGlobalRect().HasPoint(GetGlobalMousePosition()) - : IsDirectional && IsNavigable(button) && button.HasFocus() && IsRowFullyVisible(button); + : FollowsFocus && IsNavigable(button) && button.HasFocus() && IsRowFullyVisible(button); if (!valid) { CancelPress(); @@ -295,7 +390,52 @@ private void CancelPress(NMainMenuTextButton? button = null) private bool IsRowFullyVisible(Control control) { - return control.Position.Y >= 0f && control.Position.Y + control.Size.Y <= Size.Y + 0.5f; + return !ScrollEngaged || + (control.Position.Y >= 0f && control.Position.Y + control.Size.Y <= Size.Y + 0.5f); + } + + private void SyncHeightSteps(bool instant) + { + var next = 0; + foreach (var item in _measured) + { + if (_scroll + 0.001f < _rowTops[item] + _measurements[item].Y * 0.5f) + break; + next++; + } + + var target = _scroll + 0.001f >= _scrollLimit + ? _scrollLimit + : Mathf.Min(ScrollPixelsForStep(next), _scrollLimit); + if (instant) + { + _visualScrollTween?.Kill(); + _visualScrollTween = null; + _heightStepIndex = next; + _visualScroll = target; + return; + } + + if (Mathf.IsEqualApprox(_visualScroll, target) && next == _heightStepIndex) + return; + _heightStepIndex = next; + _visualScrollTween?.Kill(); + _visualScrollTween = CreateTween(); + _visualScrollTween.TweenMethod(Callable.From(value => + { + _visualScroll = value; + PositionItems(); + }), _visualScroll, target, HeightTweenDuration) + .SetEase(Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + } + + private float ScrollPixelsForStep(int step) + { + if (step <= 0 || _measured.Count == 0) + return 0f; + var last = _measured[Math.Min(step, _measured.Count) - 1]; + return _rowTops[last] + _measurements[last].Y; } } } diff --git a/src/Ui/MainMenu/NMainMenuScroller.cs b/src/Ui/MainMenu/NMainMenuScroller.cs index d0b78a36..e4a6922e 100644 --- a/src/Ui/MainMenu/NMainMenuScroller.cs +++ b/src/Ui/MainMenu/NMainMenuScroller.cs @@ -1,5 +1,4 @@ using Godot; -using MegaCrit.Sts2.Core.Nodes.GodotExtensions; using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using STS2RitsuLib.Data; @@ -10,18 +9,23 @@ internal sealed partial class NMainMenuScroller : Control private const float EdgePadding = 16f; private const float ContentPadding = 6f; private const float HorizontalPadding = 96f; - private const float ScrollBarScale = 0.5f; - private const float ScrollBarInset = 24f; + private const float EdgeZone = 80f; + private const float EdgeMinScale = 0.78f; + private const float EdgeMinAlpha = 0.4f; + private const float SceneBoxTop = 69f; + private const float SceneBoxHeight = 450f; + private const float ScrollOverflowTolerance = 40f; private readonly List _items = []; private readonly List _buttons = []; private readonly Dictionary _measurements = []; private readonly Dictionary _rowTops = []; + private readonly List _measured = []; private NMainMenu _mainMenu = null!; - private NScrollbar _scrollBar = null!; private Rect2 _designOffsets; private Vector2 _designAnchor; private float _separation; private float _scroll; + private float _scrollLimit; private float _contentHeight; private float _columnCenter; internal bool Initialized { get; private set; } @@ -30,7 +34,9 @@ internal sealed partial class NMainMenuScroller : Control private bool _initializeOnEnter; - private float ScrollLimit => Mathf.Max(0f, _contentHeight + ContentPadding * 2f - Size.Y); + private float ScrollLimit => _scrollLimit; + + private bool ScrollEngaged => _scrollLimit > 0f; internal static NMainMenuScroller? Find(NMainMenu menu) { @@ -60,8 +66,8 @@ internal static void Install(NMainMenu menu) Name = box.Name, _mainMenu = menu, _designAnchor = new(box.AnchorLeft, box.AnchorTop), - _designOffsets = new(box.OffsetLeft, box.OffsetTop, - box.OffsetRight - box.OffsetLeft, box.OffsetBottom - box.OffsetTop), + _designOffsets = new(box.OffsetLeft, SceneBoxTop, + box.OffsetRight - box.OffsetLeft, SceneBoxHeight), _separation = box.GetThemeConstant("separation"), Theme = box.Theme, Visible = box.Visible, @@ -117,14 +123,6 @@ internal void Initialize() { if (Initialized) return; - _scrollBar = ResourceLoader.Load("res://scenes/ui/scrollbar.tscn").Instantiate(); - _scrollBar.Name = "ScrollBar"; - _scrollBar.FocusMode = FocusModeEnum.None; - _scrollBar.MouseFilter = MouseFilterEnum.Stop; - _scrollBar.PivotOffset = Vector2.Zero; - _scrollBar.Scale = Vector2.One * ScrollBarScale; - AddChild(_scrollBar, false, InternalMode.Back); - _scrollBar.ValueChanged += OnScrollBarChanged; ChildOrderChanged += RequestLayout; _mainMenu.Resized += RequestLayout; VisibilityChanged += OnVisibilityChanged; @@ -155,9 +153,15 @@ public override void _ExitTree() UntrackItem(item); _items.Clear(); _buttons.Clear(); + _measured.Clear(); + _measurements.Clear(); + _rowTops.Clear(); + _scrollLimit = 0f; + _visualScrollTween?.Kill(); + _visualScrollTween = null; + _visualScroll = 0f; + _heightStepIndex = 0; DisposeDecorations(); - _scrollBar.ValueChanged -= OnScrollBarChanged; - _scrollBar.QueueFree(); Initialized = false; _initializeOnEnter = true; _layoutDirty = true; @@ -189,6 +193,7 @@ private void RefreshLayout() SynchronizeItems(); _measurements.Clear(); _rowTops.Clear(); + _measured.Clear(); _contentHeight = 0f; var columnWidth = _designOffsets.Size.X; foreach (var item in _items.Where(static item => item.Visible)) @@ -204,6 +209,7 @@ private void RefreshLayout() _contentHeight += _separation; _rowTops[item] = _contentHeight; _measurements[item] = minimum; + _measured.Add(item); _contentHeight += minimum.Y; columnWidth = Mathf.Max(columnWidth, minimum.X); } @@ -220,17 +226,15 @@ private void RefreshLayout() Mathf.Max(EdgePadding, menuSize.X - EdgePadding - width)); Position = new(left, top); Size = new(width, Mathf.Max(1f, bottom - top)); + var overflow = _contentHeight + ContentPadding * 2f - Size.Y; + _scrollLimit = overflow > ScrollOverflowTolerance ? overflow : 0f; + ClipContents = ScrollEngaged; _columnCenter = Mathf.Clamp(centerX - left, 0f, width); _scroll = Mathf.Clamp(_scroll, 0f, ScrollLimit); - _scrollBar.Position = new(Size.X - ScrollBarInset - 12f, ContentPadding + ScrollBarInset); - _scrollBar.Size = new(48f, - Mathf.Max(1f, Size.Y - (ContentPadding + ScrollBarInset) * 2f) / ScrollBarScale); - _scrollBar.MaxValue = Mathf.Max(1f, ScrollLimit); - _scrollBar.Visible = ScrollLimit > 0f; - _scrollBar.SetValueNoSignal(_scroll); + SyncHeightSteps(instant: true); PositionItems(); RebuildNavigation(); - if (IsDirectional && GetViewport().GuiGetFocusOwner() is { } focused && _rowTops.ContainsKey(focused)) + if (FollowsFocus && GetViewport().GuiGetFocusOwner() is { } focused && _rowTops.ContainsKey(focused)) EnsureVisible(focused); _layoutDirty = false; } @@ -271,6 +275,7 @@ private void UntrackItem(Control item) { if (!IsInstanceValid(item)) return; + ResetEdgeScale(item); item.MinimumSizeChanged -= RequestLayout; item.VisibilityChanged -= RequestLayout; if (item is not NMainMenuTextButton button) @@ -286,49 +291,67 @@ private void UntrackItem(Control item) private void PositionItems() { - var start = Mathf.Max(ContentPadding, (Size.Y - _contentHeight) / 2f) - _scroll; - foreach (var (item, minimum) in _measurements) + var (topWeight, bottomWeight, start) = GetEdgeScaleContext(); + foreach (var item in _measured) { + if (!IsInstanceValid(item)) + continue; + var minimum = _measurements[item]; var width = item.SizeFlagsHorizontal.HasFlag(SizeFlags.Expand) || item.SizeFlagsHorizontal.HasFlag(SizeFlags.Fill) ? Mathf.Max(minimum.X, _designOffsets.Size.X) : minimum.X; item.Size = new(width, minimum.Y); item.Position = new(_columnCenter - width / 2f, start + _rowTops[item]); + ApplyEdgeScale(item, start + _rowTops[item], minimum.Y, topWeight, bottomWeight); } UpdateDecorations(); } - private void OnScrollBarChanged(double value) - { - if (IsActive) - { - CancelPress(); - SetScroll((float)value); - } - } - private void SetScroll(float value) { _scroll = Mathf.Clamp(value, 0f, ScrollLimit); - _scrollBar.SetValueNoSignal(_scroll); + SyncHeightSteps(instant: false); PositionItems(); } private void EnsureVisible(Control item) { - if (!_rowTops.TryGetValue(item, out var top)) + if (!_measurements.ContainsKey(item)) return; - var height = _measurements[item].Y; - var margin = Mathf.Min(ContentPadding, Mathf.Max(0f, (Size.Y - height) / 2f)); - var start = Mathf.Max(ContentPadding, (Size.Y - _contentHeight) / 2f); - var offset = _scroll; - if (start + top - offset < margin) - offset = start + top - margin; - else if (start + top + height - offset > Size.Y - margin) - offset = start + top + height - Size.Y + margin; - SetScroll(offset); + var ordered = _measured; + var index = ordered.IndexOf(item); + var step = _heightStepIndex; + if (index >= 0 && index <= _heightStepIndex) + step = Math.Max(0, index - 1); + else if (index > _heightStepIndex) + { + step = index; + for (var candidate = _heightStepIndex; candidate <= index; candidate++) + { + var top = ContentPadding + (candidate > 0 ? _separation : 0f); + var fits = false; + for (var i = candidate; i < ordered.Count; i++) + { + var height = _measurements[ordered[i]].Y; + if (i == index) + { + fits = top + height <= Size.Y - ContentPadding + 0.5f; + break; + } + + top += height + _separation; + } + + if (!fits) + continue; + step = candidate; + break; + } + } + + SetScroll(ScrollPixelsForStep(step)); } } }