diff --git a/Settings/ModSettingsUi/ModSettingsScrollContainer.cs b/Settings/ModSettingsUi/ModSettingsScrollContainer.cs new file mode 100644 index 0000000..079337a --- /dev/null +++ b/Settings/ModSettingsUi/ModSettingsScrollContainer.cs @@ -0,0 +1,560 @@ +using Godot; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; + +namespace STS2RitsuLib.Settings; + +/// +/// Custom scroll area (clipper + fade mask + game ui/scrollbar) without Harmony-patched scroll types. +/// +public sealed partial class ModSettingsScrollContainer : Control +{ + /// + /// When true, prints logical vs actual Y to the Godot output (debug chapter navigation). + /// + public static bool LogScrollNav; + + /// + /// Width reserved on the right for the scrollbar gutter. + /// + public const float ScrollbarGutterWidth = 60f; + + /// + /// Height of the bottom fade mask region in pixels. + /// + public const float BottomFade = 70f; + + /// + /// Height of the top fade mask region in pixels. + /// + public const float TopFade = 24f; + + private const float ExtraScrollExtent = 32f; + + private readonly bool _disableScrollingIfContentFits; + + private float _controllerScrollAmount = 400f; + private Control? _content; + private Control _clipper = null!; + private TextureRect _fadeMask = null!; + private Gradient _maskGradient = null!; + private GradientTexture2D _maskGradientTexture = null!; + private NScrollbar _scrollbar = null!; + + private bool _isDragging; + private float _paddingBottom; + private float _paddingTop; + private bool _scrollbarPressed; + private float _startDragPosY; + private float _targetDragPosY; + + private bool _scrollToDeferredRetry; + private readonly Callable _contentRectChangedCallable; + + /// + /// Creates a scroll container with inner top/bottom padding inside the clipper. + /// + public ModSettingsScrollContainer(float topPadding = 0f, float bottomPadding = 0f, bool disableScrollingIfContentFits = false) + { + _contentRectChangedCallable = Callable.From(OnContentItemRectChanged); + _paddingTop = topPadding; + _paddingBottom = bottomPadding; + _disableScrollingIfContentFits = disableScrollingIfContentFits; + + AnchorRight = 1f; + AnchorBottom = 1f; + GrowHorizontal = GrowDirection.Both; + GrowVertical = GrowDirection.Both; + FocusMode = FocusModeEnum.None; + MouseFilter = MouseFilterEnum.Stop; + ClipChildren = CanvasItem.ClipChildrenMode.Only; + + BuildUi(); + } + + /// Game scrollbar instance (0–100). + public NScrollbar Scrollbar => _scrollbar; + + private float ContentHeight => _content == null ? 0f : _content.GetCombinedMinimumSize().Y + ExtraScrollExtent; + + private float ScrollViewportSize => _clipper.Size.Y; + + private float ScrollLimitBottom => + _content == null + ? 0f + : Mathf.Min(0f, _clipper.Size.Y - _paddingTop - _paddingBottom - ContentHeight); + + /// + /// Subscribes resize handling and enables per-frame scroll interpolation. + /// + public override void _Ready() + { + Resized += OnContainerResized; + SetProcess(true); + } + + /// + /// Unhooks resize and content signals before the node leaves the tree. + /// + public override void _ExitTree() + { + Resized -= OnContainerResized; + + DetachContentSignals(); + base._ExitTree(); + } + + /// + /// Replaces scroll content; forces vertical shrink so height is not inflated by ExpandFill parents. + /// + public void AttachContent(Control content) + { + ArgumentNullException.ThrowIfNull(content); + + DetachContentSignals(); + + _content = content; + content.SizeFlagsHorizontal = SizeFlags.ExpandFill; + content.SizeFlagsVertical = SizeFlags.ShrinkBegin; + + _clipper.AddChild(content); + content.Connect(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable); + OnContainerResized(); + UpdateScrollLayout(); + } + + /// + /// Scrolls so aligns to the top padding of the clipper (animated unless skipped). + /// + public void ScrollTo(Control? target, bool skipAnimation = false) + { + if (_content == null || target == null) + return; + + if (!_content.IsAncestorOf(target)) + return; + + _content.UpdateMinimumSize(); + + if (target.Size.Y < 1e-4f && !_scrollToDeferredRetry) + { + _scrollToDeferredRetry = true; + Callable.From(() => + { + _scrollToDeferredRetry = false; + ScrollTo(target, skipAnimation); + }).CallDeferred(); + return; + } + + var raw = -ComputeOffsetInContent(target); + _targetDragPosY = Mathf.Clamp(raw, ScrollLimitBottom, 0f); + + if (LogScrollNav) + GD.Print( + $"[RitsuScroll] ScrollTo name={target.Name} raw={raw:F1} targetY={_paddingTop + _targetDragPosY:F1} " + + $"posY={_content.Position.Y:F1} limit={ScrollLimitBottom:F1} skipAnim={skipAnimation}"); + + if (!skipAnimation) + return; + + _content.Position = _content.Position with { Y = _paddingTop + _targetDragPosY }; + if (ScrollLimitBottom < -1e-4f) + _scrollbar.SetValueWithoutAnimation(_targetDragPosY / ScrollLimitBottom * 100.0); + else + _scrollbar.SetValueWithoutAnimation(0.0); + UpdateScrollLayout(); + } + + /// + /// Scrolls the currently focused control into view if it is under this content. + /// + public void ScrollToFocusedControl(bool skipAnimation = false) + { + var vp = GetViewport(); + if (vp?.GuiGetFocusOwner() is not Control c || _content == null || !_content.IsAncestorOf(c)) + return; + + EnsureControlVisible(c); + if (!skipAnimation) + return; + + if (_content != null) + _content.Position = _content.Position with { Y = _paddingTop + _targetDragPosY }; + if (ScrollLimitBottom < -1e-4f) + _scrollbar.SetValueWithoutAnimation(_targetDragPosY / ScrollLimitBottom * 100.0); + else + _scrollbar.SetValueWithoutAnimation(0.0); + UpdateScrollLayout(); + } + + /// + /// Moves the scroll range minimally so is inside the padded viewport. + /// + public void EnsureControlVisible(Control node) + { + if (_content == null || !IsInstanceValid(node) || !_content.IsAncestorOf(node)) + return; + + _content.UpdateMinimumSize(); + + var off = ComputeOffsetInContent(node); + var top = _content.Position.Y + off; + var bottom = top + node.Size.Y; + var vMin = _paddingTop; + var vMax = _clipper.Size.Y - _paddingBottom; + var dy = 0f; + if (top < vMin) + dy = vMin - top; + else if (bottom > vMax) + dy = vMax - bottom; + + if (Mathf.Abs(dy) > 0.5f) + _targetDragPosY = Mathf.Clamp(_targetDragPosY + dy, ScrollLimitBottom, 0f); + } + + /// + /// Snaps scroll to the top without animation. + /// + public void InstantlyScrollToTop() + { + if (_content == null) + return; + + _targetDragPosY = 0f; + _content.Position = _content.Position with { Y = _paddingTop }; + _scrollbar.SetValueWithoutAnimation(0.0); + UpdateScrollLayout(); + } + + /// + /// Recomputes content sizing and scrollbar visibility after a child layout change inside the attached content. + /// + public void RefreshContentMetrics() + { + if (_content == null) + return; + + _content.UpdateMinimumSize(); + SyncScrollbarAfterContentResize(); + UpdateScrollLayout(); + } + + /// + /// Routes mouse drag and wheel input to the custom scroll logic while visible. + /// + public override void _GuiInput(InputEvent @event) + { + if (IsVisibleInTree()) + { + ProcessMouseEvent(@event); + ProcessScrollEvent(@event); + } + } + + /// + /// Routes controller scrolling input when no GUI control currently owns focus. + /// + public override void _Input(InputEvent @event) + { + if (IsVisibleInTree()) + { + var vp = GetViewport(); + if (vp == null || vp.GuiGetFocusOwner() == null) + ProcessControllerEvent(@event); + } + } + + /// + /// Advances smooth scrolling toward the current target position each frame. + /// + public override void _Process(double delta) + { + if (!IsVisibleInTree() || _content == null) + return; + + if (_disableScrollingIfContentFits && !_scrollbar.Visible) + return; + + UpdateScrollPosition(delta); + } + + private void BuildUi() + { + // Match NNativeScrollableContainer: vertical gradient (bottom→top in UV) and 5 color stops. + _maskGradient = new Gradient + { + Colors = + [ + new Color(1f, 1f, 1f, 0f), + new Color(1f, 1f, 1f, 0.4f), + new Color(1f, 1f, 1f, 1f), + new Color(1f, 1f, 1f, 1f), + new Color(1f, 1f, 1f, 0f), + ], + }; + + _maskGradientTexture = new GradientTexture2D + { + Width = 8, + Height = 256, + FillFrom = new Vector2(0f, 1f), + FillTo = Vector2.Zero, + Gradient = _maskGradient, + }; + + _fadeMask = new TextureRect + { + Name = "Mask", + MouseFilter = MouseFilterEnum.Ignore, + ClipChildren = CanvasItem.ClipChildrenMode.Only, + Texture = _maskGradientTexture, + }; + _fadeMask.SetAnchorsPreset(LayoutPreset.FullRect); + _fadeMask.GrowHorizontal = GrowDirection.Both; + _fadeMask.GrowVertical = GrowDirection.Both; + _fadeMask.ExpandMode = TextureRect.ExpandModeEnum.FitWidthProportional; + _fadeMask.StretchMode = TextureRect.StretchModeEnum.Scale; + + _clipper = new Control + { + Name = "Clipper", + MouseFilter = MouseFilterEnum.Ignore, + ClipContents = true, + }; + _clipper.SetAnchorsPreset(LayoutPreset.FullRect); + _clipper.GrowHorizontal = GrowDirection.Both; + _clipper.GrowVertical = GrowDirection.Both; + ApplyClipperOffsets(); + + _fadeMask.AddChild(_clipper); + + _scrollbar = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/scrollbar")).Instantiate(); + _scrollbar.Name = "Scrollbar"; + _scrollbar.MouseFilter = MouseFilterEnum.Stop; + _scrollbar.SetAnchorsPreset(LayoutPreset.RightWide); + _scrollbar.OffsetLeft = -48f; + _scrollbar.OffsetRight = 0f; + ApplyScrollbarOffsets(); + + _scrollbar.MousePressed += _ => _scrollbarPressed = true; + _scrollbar.MouseReleased += _ => _scrollbarPressed = false; + + AddChild(_fadeMask); + AddChild(_scrollbar); + + _scrollbar.Visible = false; + } + + private void ApplyClipperOffsets() + { + _clipper.OffsetLeft = 0f; + _clipper.OffsetTop = _paddingTop; + _clipper.OffsetRight = -ScrollbarGutterWidth; + _clipper.OffsetBottom = -_paddingBottom; + } + + private void ApplyScrollbarOffsets() + { + _scrollbar.OffsetTop = _paddingTop + 64f; + _scrollbar.OffsetBottom = -_paddingBottom - 64f; + } + + private void OnContainerResized() + { + ApplyClipperOffsets(); + ApplyScrollbarOffsets(); + SyncContentWidth(); + RebuildMaskGradientOffsets(); + UpdateScrollLayout(); + } + + private void OnContentItemRectChanged() + { + SyncScrollbarAfterContentResize(); + UpdateScrollLayout(); + } + + private void DetachContentSignals() + { + if (_content == null) + return; + + if (_content.IsConnected(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable)) + _content.Disconnect(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable); + + if (_content.GetParent() == _clipper) + _clipper.RemoveChild(_content); + + _content = null; + } + + private void SyncContentWidth() + { + if (_content == null || !IsInstanceValid(_content)) + return; + + var w = _clipper.Size.X; + if (w < 1e-4f) + return; + + _content.CustomMinimumSize = new Vector2(w, 0f); + _content.Size = _content.Size with { X = w }; + } + + private void UpdateScrollLayout() + { + if (_content == null) + return; + + const float epsilon = 1f; + + var contentFits = ContentHeight + _paddingTop + _paddingBottom - epsilon <= _clipper.Size.Y; + + var logicalContentY = _paddingTop + _targetDragPosY; + var scrollIsAtTop = -logicalContentY <= _paddingTop + epsilon; + var wasVisible = _scrollbar.Visible; + var showChrome = !contentFits || !scrollIsAtTop; + + _scrollbar.Visible = showChrome; + _scrollbar.MouseFilter = showChrome ? MouseFilterEnum.Stop : MouseFilterEnum.Ignore; + + if (!wasVisible && _scrollbar.Visible) + _targetDragPosY = _content.Position.Y - _paddingTop; + + _fadeMask.ClipChildren = showChrome ? CanvasItem.ClipChildrenMode.Only : CanvasItem.ClipChildrenMode.Disabled; + _fadeMask.SelfModulate = new Color(1f, 1f, 1f, showChrome ? 1f : 0f); + + if (!showChrome) + return; + + var scrollDistanceFromTop = Mathf.Max(0f, _paddingTop - logicalContentY); + var topAlpha = 1f - Mathf.Clamp(scrollDistanceFromTop / TopFade, 0f, 1f); + var colors = _maskGradient.Colors; + colors[4] = new Color(1f, 1f, 1f, topAlpha); + _maskGradient.Colors = colors; + } + + private void RebuildMaskGradientOffsets() + { + var actualHeight = Size.Y; + if (actualHeight <= 0f) + return; + + float FromTop(float px) => 1f - px / actualHeight; + + _maskGradient.Offsets = + [ + 0f, + BottomFade * 0.4f / actualHeight, + BottomFade / actualHeight, + FromTop(_paddingTop + TopFade), + FromTop(_paddingTop), + ]; + } + + private void SyncScrollbarAfterContentResize() + { + if (_content == null || ScrollLimitBottom >= 0f) + return; + + _scrollbar.SetValueNoSignal( + Mathf.Clamp((_content.Position.Y - _paddingTop) / ScrollLimitBottom, 0f, 1f) * 100f); + } + + private static float ComputeOffsetInContent(Control target, Control contentRoot) + { + var off = 0f; + for (Node? n = target; n != null && n != contentRoot; n = n.GetParent()) + { + if (n is Control c) + off += c.Position.Y; + } + + return off; + } + + private float ComputeOffsetInContent(Control target) => + _content == null ? 0f : ComputeOffsetInContent(target, _content); + + private void ProcessControllerEvent(InputEvent inputEvent) + { + if (inputEvent.IsActionPressed(MegaInput.up)) + _targetDragPosY += _controllerScrollAmount; + else if (inputEvent.IsActionPressed(MegaInput.down)) + _targetDragPosY -= _controllerScrollAmount; + } + + private void ProcessMouseEvent(InputEvent inputEvent) + { + if (_content == null) + return; + + switch (inputEvent) + { + case InputEventMouseMotion motion when _isDragging: + _targetDragPosY += motion.Relative.Y; + break; + case InputEventMouseButton { ButtonIndex: MouseButton.Left } btn: + _isDragging = btn.Pressed; + if (btn.Pressed) + { + _startDragPosY = _content.Position.Y - _paddingTop; + _targetDragPosY = _startDragPosY; + } + else + { + _isDragging = false; + } + + break; + } + } + + private void ProcessScrollEvent(InputEvent inputEvent) + { + _targetDragPosY += ScrollHelper.GetDragForScrollEvent(inputEvent); + } + + private void UpdateScrollPosition(double delta) + { + if (_content == null) + return; + + // Scrollbar thumb: read Value first so targetY matches the handle; do not Lerp content (feels laggy vs wheel). + if (_scrollbarPressed && ScrollLimitBottom < 0f) + _targetDragPosY = Mathf.Lerp(0f, ScrollLimitBottom, (float)_scrollbar.Value * 0.01f); + + _targetDragPosY = Mathf.Clamp(_targetDragPosY, ScrollLimitBottom, 0f); + + var targetY = _paddingTop + _targetDragPosY; + + if (_scrollbarPressed) + { + _content.Position = _content.Position with { Y = targetY }; + } + else if (!Mathf.IsEqualApprox(_content.Position.Y, targetY)) + { + var y = Mathf.Lerp(_content.Position.Y, targetY, (float)delta * 15f); + _content.Position = _content.Position with { Y = y }; + if (Mathf.Abs(_content.Position.Y - targetY) < 0.5f) + _content.Position = _content.Position with { Y = targetY }; + + if (ScrollLimitBottom < 0f) + _scrollbar.SetValueWithoutAnimation( + Mathf.Clamp((_content.Position.Y - _paddingTop) / ScrollLimitBottom, 0f, 1f) * 100f); + } + + if (!_isDragging && !_scrollbarPressed) + { + if (_targetDragPosY < Mathf.Min(ScrollLimitBottom, 0f)) + _targetDragPosY = Mathf.Lerp(_targetDragPosY, ScrollLimitBottom, (float)delta * 12f); + else if (_targetDragPosY > Mathf.Max(ScrollLimitBottom, 0f)) + _targetDragPosY = Mathf.Lerp(_targetDragPosY, 0f, (float)delta * 12f); + } + + UpdateScrollLayout(); + } +} diff --git a/Settings/ModSettingsUi/ModSettingsUiControlTheming.cs b/Settings/ModSettingsUi/ModSettingsUiControlTheming.cs index 09ace7d..4511252 100644 --- a/Settings/ModSettingsUi/ModSettingsUiControlTheming.cs +++ b/Settings/ModSettingsUi/ModSettingsUiControlTheming.cs @@ -136,10 +136,10 @@ public static Button CreateSettingsToggleButton(string text, bool pressed) }; ApplySettingsToggleButtonStyle(button, pressed, false); button.Toggled += on => ApplySettingsToggleButtonStyle(button, on, false); - button.MouseEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); - button.MouseExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); - button.FocusEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); - button.FocusExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); + // button.MouseEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); + // button.MouseExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); + // button.FocusEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); + // button.FocusExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); return button; } @@ -162,10 +162,10 @@ public static Button CreateCompactSettingsToggleButton(string text, bool pressed }; ApplySettingsToggleButtonStyle(button, pressed, false); button.Toggled += on => ApplySettingsToggleButtonStyle(button, on, false); - button.MouseEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); - button.MouseExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); - button.FocusEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); - button.FocusExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); + // button.MouseEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); + // button.MouseExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); + // button.FocusEntered += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, true); + // button.FocusExited += () => ApplySettingsToggleButtonStyle(button, button.ButtonPressed, false); return button; } diff --git a/Settings/ModSettingsUi/ModSettingsUiControls.cs b/Settings/ModSettingsUi/ModSettingsUiControls.cs index 863bd83..68d9d53 100644 --- a/Settings/ModSettingsUi/ModSettingsUiControls.cs +++ b/Settings/ModSettingsUi/ModSettingsUiControls.cs @@ -3264,12 +3264,18 @@ private static StyleBoxFlat CreateStyle(bool highlighted) internal sealed partial class ModSettingsListItemCard : PanelContainer { + private const double CollapseFadeDurationSeconds = 0.16; + private readonly int _index; + private readonly Callable _editorSurfaceRectChangedCallable; private readonly bool _isCollapsible; private readonly ModSettingsListItemContext? _itemContext; private readonly ModSettingsListControl _owner; private bool _collapsed; + private Control? _editorClip; + private bool _trackEditorHeight; private PanelContainer? _editorSurface; + private Tween? _editorTween; private ModSettingsCollapsibleHeaderButton? _toggleButton; public ModSettingsListItemCard( @@ -3283,6 +3289,7 @@ public ModSettingsListItemCard( bool startCollapsed, Control? headerAccessory) { + _editorSurfaceRectChangedCallable = Callable.From(SyncEditorExpandedHeight); _owner = owner; _index = index; _itemContext = itemContext; @@ -3371,36 +3378,192 @@ public ModSettingsListItemCard( ModSettingsUiFactory.AttachContextMenuTargets(this, outer, actionsButton); if (editorContent == null) return; + _editorClip = new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ShrinkBegin, + ClipContents = true, + MouseFilter = MouseFilterEnum.Ignore, + }; _editorSurface = new() { SizeFlagsHorizontal = SizeFlags.ExpandFill, MouseFilter = MouseFilterEnum.Ignore, - Visible = !_collapsed, }; + _editorSurface.Modulate = _editorSurface.Modulate with { A = _collapsed ? 0f : 1f }; _editorSurface.AddThemeStyleboxOverride("panel", ModSettingsUiFactory.CreateListEditorSurfaceStyle()); - root.AddChild(_editorSurface); + _editorSurface.Connect(CanvasItem.SignalName.ItemRectChanged, _editorSurfaceRectChangedCallable); + _editorSurface.Resized += SyncEditorExpandedHeight; _editorSurface.AddChild(editorContent); + _editorSurface.AnchorLeft = 0f; + _editorSurface.AnchorTop = 0f; + _editorSurface.AnchorRight = 1f; + _editorSurface.AnchorBottom = 0f; + _editorSurface.OffsetLeft = 0f; + _editorSurface.OffsetTop = 0f; + _editorSurface.OffsetRight = 0f; + _editorSurface.OffsetBottom = 0f; + _editorClip.AddChild(_editorSurface); + root.AddChild(_editorClip); ApplyCollapsedState(); } public ModSettingsListItemCard() { + _editorSurfaceRectChangedCallable = Callable.From(SyncEditorExpandedHeight); _owner = null!; } + public override void _ExitTree() + { + if (_editorSurface != null) + { + _editorSurface.Resized -= SyncEditorExpandedHeight; + if (_editorSurface.IsConnected(CanvasItem.SignalName.ItemRectChanged, _editorSurfaceRectChangedCallable)) + _editorSurface.Disconnect(CanvasItem.SignalName.ItemRectChanged, _editorSurfaceRectChangedCallable); + } + + base._ExitTree(); + } + private void ToggleCollapsed() { if (!_isCollapsible) return; + + var wasCollapsed = _collapsed; _collapsed = !_collapsed; _itemContext?.SetRowState("collapsed", _collapsed); - ApplyCollapsedState(); + ApplyCollapsedState(true); + if (wasCollapsed && !_collapsed) + Callable.From(EnsureExpandedVisible).CallDeferred(); } - private void ApplyCollapsedState() + private void ApplyCollapsedState(bool animate = false) { - _editorSurface?.SetDeferred(CanvasItem.PropertyName.Visible, !_collapsed); _toggleButton?.SetSelected(!_collapsed); + if (_editorSurface == null || _editorClip == null) + return; + + _editorTween?.Kill(); + _editorTween = null; + + if (!animate) + { + _trackEditorHeight = !_collapsed; + _editorSurface.Modulate = _editorSurface.Modulate with { A = _collapsed ? 0f : 1f }; + _editorSurface.Visible = !_collapsed; + _editorClip.Visible = !_collapsed; + SetEditorClipHeight(0f); + if (!_collapsed) + DeferEditorExpandedHeightSync(); + return; + } + + _trackEditorHeight = false; + if (!_collapsed) + { + _editorClip.Visible = true; + _editorSurface.Visible = true; + } + + var target = _editorSurface.Modulate with { A = _collapsed ? 0f : 1f }; + var tween = CreateTween().SetParallel(); + _editorTween = tween; + tween.TweenProperty(_editorSurface, "modulate", target, CollapseFadeDurationSeconds) + .SetEase(_collapsed ? Tween.EaseType.In : Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + tween.TweenMethod( + Callable.From(SetEditorClipHeight), + _editorClip.CustomMinimumSize.Y, + _collapsed ? 0f : MeasureEditorExpandedHeight(), + CollapseFadeDurationSeconds) + .SetEase(_collapsed ? Tween.EaseType.In : Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + tween.Finished += () => + { + if (_editorTween != tween) + return; + + _editorTween = null; + FinalizeEditorSurfaceState(); + if (!_collapsed) + Callable.From(EnsureExpandedVisible).CallDeferred(); + }; + } + + private void FinalizeEditorSurfaceState() + { + if (_editorSurface == null || _editorClip == null) + return; + + _trackEditorHeight = !_collapsed; + _editorSurface.Modulate = _editorSurface.Modulate with { A = _collapsed ? 0f : 1f }; + _editorSurface.Visible = !_collapsed; + _editorClip.Visible = !_collapsed; + SetEditorClipHeight(_collapsed ? 0f : MeasureEditorExpandedHeight()); + if (!_collapsed) + Callable.From(SyncEditorExpandedHeight).CallDeferred(); + } + + private void DeferEditorExpandedHeightSync() + { + Callable.From(() => Callable.From(SyncEditorExpandedHeight).CallDeferred()).CallDeferred(); + } + + private float MeasureEditorExpandedHeight() + { + if (_editorSurface == null) + return 0f; + + _editorSurface.UpdateMinimumSize(); + return _editorSurface.GetCombinedMinimumSize().Y; + } + + private void SyncEditorExpandedHeight() + { + if (!_trackEditorHeight || _editorSurface == null || _editorClip == null) + return; + + SetEditorClipHeight(MeasureEditorExpandedHeight()); + } + + private void SetEditorClipHeight(float height) + { + if (_editorClip == null) + return; + + _editorClip.CustomMinimumSize = _editorClip.CustomMinimumSize with { Y = Mathf.Max(0f, height) }; + _editorClip.UpdateMinimumSize(); + for (Node? current = _editorClip; current != null; current = current.GetParent()) + { + if (current is Control control) + control.UpdateMinimumSize(); + if (current is Container container) + container.QueueSort(); + if (current is ModSettingsCollapsibleSection section) + section.SyncContentExpandedHeight(); + if (current is ModSettingsScrollContainer found) + { + found.RefreshContentMetrics(); + break; + } + } + } + + private void EnsureExpandedVisible() + { + if (!_isCollapsible || _collapsed || !IsVisibleInTree()) + return; + + for (var current = GetParent(); current != null; current = current.GetParent()) + { + if (current is ModSettingsScrollContainer ritsuScroll) + { + ritsuScroll.EnsureControlVisible(this); + return; + } + } } public override bool _CanDropData(Vector2 atPosition, Variant data) @@ -3598,6 +3761,9 @@ private static StyleBoxFlat CreateHeaderStyle(bool selected, bool hovered) internal sealed partial class ModSettingsCollapsibleSection : VBoxContainer { + private const double CollapseFadeDurationSeconds = 0.16; + + private readonly Callable _contentRectChangedCallable; private readonly Control[]? _contentControls; private readonly string? _description; private readonly ModSettingsActionsButton? _headerActions; @@ -3605,12 +3771,16 @@ internal sealed partial class ModSettingsCollapsibleSection : VBoxContainer private readonly bool _startCollapsed; private readonly string? _title; private bool _collapsed; + private Control? _contentClip; + private bool _trackContentHeight; private VBoxContainer? _content; + private Tween? _contentTween; private ModSettingsCollapsibleHeaderButton? _toggle; public ModSettingsCollapsibleSection(string title, string? sectionId, string? description, bool startCollapsed, Control[] contentControls, ModSettingsActionsButton? headerActions = null) { + _contentRectChangedCallable = Callable.From(SyncContentExpandedHeight); _title = title; _sectionId = sectionId; _description = description; @@ -3623,6 +3793,7 @@ public ModSettingsCollapsibleSection(string title, string? sectionId, string? de public ModSettingsCollapsibleSection() { + _contentRectChangedCallable = Callable.From(SyncContentExpandedHeight); } public override void _Ready() @@ -3683,48 +3854,191 @@ public override void _Ready() cardContent.AddChild(headerRow); } + _contentClip = new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ShrinkBegin, + ClipContents = true, + MouseFilter = MouseFilterEnum.Ignore, + }; _content = new() { MouseFilter = MouseFilterEnum.Ignore }; _content.AddThemeConstantOverride("separation", 8); if (_contentControls != null) foreach (var control in _contentControls) _content.AddChild(control); - cardContent.AddChild(_content); + _content.Modulate = _content.Modulate with { A = _startCollapsed ? 0f : 1f }; + _content.Connect(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable); + _content.Resized += SyncContentExpandedHeight; + _content.AnchorLeft = 0f; + _content.AnchorTop = 0f; + _content.AnchorRight = 1f; + _content.AnchorBottom = 0f; + _content.OffsetLeft = 0f; + _content.OffsetTop = 0f; + _content.OffsetRight = 0f; + _content.OffsetBottom = 0f; + _contentClip.AddChild(_content); + cardContent.AddChild(_contentClip); _collapsed = _startCollapsed; ApplyCollapsedState(); } + public override void _ExitTree() + { + if (_content != null) + { + _content.Resized -= SyncContentExpandedHeight; + if (_content.IsConnected(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable)) + _content.Disconnect(CanvasItem.SignalName.ItemRectChanged, _contentRectChangedCallable); + } + + base._ExitTree(); + } + private void ToggleCollapsed() { - _collapsed = !_collapsed; - ApplyCollapsedState(); - if (!_collapsed) - Callable.From(EnsureExpandedSectionVisible).CallDeferred(); + SetCollapsed(!_collapsed, true); } - private void ApplyCollapsedState() + private void ApplyCollapsedState(bool animate = false) { - if (_content != null) - _content.Visible = !_collapsed; _toggle?.SetSelected(!_collapsed); + if (_content == null || _contentClip == null) + return; + + _contentTween?.Kill(); + _contentTween = null; + + if (!animate) + { + _trackContentHeight = !_collapsed; + _content.Modulate = _content.Modulate with { A = _collapsed ? 0f : 1f }; + _content.Visible = !_collapsed; + _contentClip.Visible = !_collapsed; + SetContentClipHeight(0f); + if (!_collapsed) + DeferContentExpandedHeightSync(); + return; + } + + _trackContentHeight = false; + if (!_collapsed) + { + _contentClip.Visible = true; + _content.Visible = true; + } + + var target = _content.Modulate with { A = _collapsed ? 0f : 1f }; + var tween = CreateTween().SetParallel(); + _contentTween = tween; + tween.TweenProperty(_content, "modulate", target, CollapseFadeDurationSeconds) + .SetEase(_collapsed ? Tween.EaseType.In : Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + tween.TweenMethod( + Callable.From(SetContentClipHeight), + _contentClip.CustomMinimumSize.Y, + _collapsed ? 0f : MeasureContentExpandedHeight(), + CollapseFadeDurationSeconds) + .SetEase(_collapsed ? Tween.EaseType.In : Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + tween.Finished += () => + { + if (_contentTween != tween) + return; + + _contentTween = null; + FinalizeContentState(); + if (!_collapsed) + Callable.From(EnsureExpandedSectionVisible).CallDeferred(); + }; } - private void EnsureExpandedSectionVisible() + private void FinalizeContentState() { - if (!IsVisibleInTree()) + if (_content == null || _contentClip == null) return; - var scroll = FindAncestorScrollContainer(this); - scroll?.EnsureControlVisible(this); + _trackContentHeight = !_collapsed; + _content.Modulate = _content.Modulate with { A = _collapsed ? 0f : 1f }; + _content.Visible = !_collapsed; + _contentClip.Visible = !_collapsed; + SetContentClipHeight(_collapsed ? 0f : MeasureContentExpandedHeight()); + if (!_collapsed) + Callable.From(SyncContentExpandedHeight).CallDeferred(); } - private static ScrollContainer? FindAncestorScrollContainer(Node node) + private void DeferContentExpandedHeightSync() { - for (var current = node.GetParent(); current != null; current = current.GetParent()) - if (current is ScrollContainer scroll) - return scroll; + Callable.From(() => Callable.From(SyncContentExpandedHeight).CallDeferred()).CallDeferred(); + } - return null; + private float MeasureContentExpandedHeight() + { + if (_content == null) + return 0f; + + _content.UpdateMinimumSize(); + return _content.GetCombinedMinimumSize().Y; + } + + internal void SyncContentExpandedHeight() + { + if (!_trackContentHeight || _content == null || _contentClip == null) + return; + + SetContentClipHeight(MeasureContentExpandedHeight()); + } + + internal bool SetCollapsed(bool collapsed, bool ensureExpandedVisible) + { + if (_collapsed == collapsed) + return false; + + var wasCollapsed = _collapsed; + _collapsed = collapsed; + ApplyCollapsedState(true); + if (ensureExpandedVisible && wasCollapsed && !_collapsed) + Callable.From(EnsureExpandedSectionVisible).CallDeferred(); + return true; + } + + private void SetContentClipHeight(float height) + { + if (_contentClip == null) + return; + + _contentClip.CustomMinimumSize = _contentClip.CustomMinimumSize with { Y = Mathf.Max(0f, height) }; + _contentClip.UpdateMinimumSize(); + for (Node? current = _contentClip; current != null; current = current.GetParent()) + { + if (current is Control control) + control.UpdateMinimumSize(); + if (current is Container container) + container.QueueSort(); + if (current is ModSettingsCollapsibleSection section && !ReferenceEquals(section, this)) + section.SyncContentExpandedHeight(); + if (current is ModSettingsScrollContainer found) + { + found.RefreshContentMetrics(); + break; + } + } + } + + private void EnsureExpandedSectionVisible() + { + if (_collapsed || !IsVisibleInTree()) + return; + + for (var current = GetParent(); current != null; current = current.GetParent()) + { + if (current is ModSettingsScrollContainer ritsuScroll) + { + ritsuScroll.EnsureControlVisible(this); + return; + } + } } } diff --git a/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs b/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs index 8e44f30..4c639aa 100644 --- a/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs +++ b/Settings/ModSettingsUi/RitsuModSettingsSubmenu.cs @@ -20,7 +20,7 @@ public partial class RitsuModSettingsSubmenu : NSubmenu { private const float SidebarWidth = 324f; private const double AutosaveDelaySeconds = 0.35; - private const int ScrollContentRightGutter = 12; + private const double SidebarFoldDurationSeconds = 0.16; private static readonly StringName PaneSidebarHotkey = MegaInput.viewDeckAndTabLeft; private static readonly StringName PaneContentHotkey = MegaInput.viewExhaustPileAndTabRight; @@ -81,13 +81,14 @@ private readonly Dictionary private Timer? _refreshDebounceTimer; private TextureRect? _rightPaneHotkeyIcon; private double _saveTimer = -1; - private ScrollContainer _scrollContainer = null!; + private ModSettingsScrollContainer _scrollContainer = null!; private string? _selectedModId; private string? _selectedPageId; private string? _selectedSectionId; private bool _selectionDirty = true; + private bool _sidebarExpansionInitialized; private Control _sidebarPanelRoot = null!; - private ScrollContainer _sidebarScrollContainer = null!; + private ModSettingsScrollContainer _sidebarScrollContainer = null!; private bool _sidebarStructureDirty = true; private MegaRichTextLabel _subtitleLabel; private bool _suppressScrollSync; @@ -105,6 +106,12 @@ public RitsuModSettingsSubmenu() GrowVertical = GrowDirection.Both; FocusMode = FocusModeEnum.None; + var theme = new Theme + { + DefaultFont = ModSettingsUiResources.KreonRegular + }; + Theme = theme; + var frame = new MarginContainer { Name = "Frame", @@ -115,9 +122,9 @@ public RitsuModSettingsSubmenu() MouseFilter = MouseFilterEnum.Ignore, }; frame.AddThemeConstantOverride("margin_left", 160); - frame.AddThemeConstantOverride("margin_top", 72); - frame.AddThemeConstantOverride("margin_right", 160); - frame.AddThemeConstantOverride("margin_bottom", 72); + frame.AddThemeConstantOverride("margin_top", 20); + frame.AddThemeConstantOverride("margin_right", 80); + frame.AddThemeConstantOverride("margin_bottom", 40); AddChild(frame); var root = new VBoxContainer @@ -180,7 +187,8 @@ public override void _Ready() ConnectSignals(); _updatePaneHotkeyIconsCallable = Callable.From(UpdatePaneHotkeyHintIcons); TryConnectPaneHotkeyStyleSignals(); - _scrollContainer.GetVScrollBar().ValueChanged += OnContentScrollChanged; + _scrollContainer.Scrollbar.Connect(Godot.Range.SignalName.ValueChanged, + Callable.From(OnContentScrollChanged)); SubscribeLocaleChanges(); EnsureUiUpToDate(true, true); ProcessMode = ProcessModeEnum.Disabled; @@ -506,7 +514,7 @@ public void NavigateToSection(string pageId, string sectionId) EnsureUiUpToDate(false, pageChanged); } - private Control CreatePaneHotkeyHintRow() + private HBoxContainer CreatePaneHotkeyHintRow() { var row = new HBoxContainer { @@ -557,8 +565,7 @@ private void TryConnectPaneHotkeyStyleSignals() _updatePaneHotkeyIconsCallable); } - if (NInputManager.Instance != null) - NInputManager.Instance.Connect(NInputManager.SignalName.InputRebound, _updatePaneHotkeyIconsCallable); + NInputManager.Instance?.Connect(NInputManager.SignalName.InputRebound, _updatePaneHotkeyIconsCallable); _paneHotkeySignalsConnected = true; } @@ -576,8 +583,7 @@ private void TryDisconnectPaneHotkeyStyleSignals() _updatePaneHotkeyIconsCallable); } - if (NInputManager.Instance != null) - NInputManager.Instance.Disconnect(NInputManager.SignalName.InputRebound, + NInputManager.Instance?.Disconnect(NInputManager.SignalName.InputRebound, _updatePaneHotkeyIconsCallable); _paneHotkeySignalsConnected = false; @@ -739,7 +745,7 @@ private void FocusSidebarPaneFromInput() return _sidebarFocusChain.FirstOrDefault(); } - private Control? ResolveInitialSidebarFocus() + private ModSettingsSidebarButton? ResolveInitialSidebarFocus() { var selectedPageKey = GetSelectedPageKey(); var selectedSectionKey = GetSelectedSectionKey(); @@ -777,7 +783,7 @@ private void FocusSidebarPaneFromInput() private Control CreateSidebarPanel() { - var panel = new Panel + var panel = new Control { Name = "RitsuSidebarPanel", CustomMinimumSize = new(SidebarWidth, 0f), @@ -785,7 +791,7 @@ private Control CreateSidebarPanel() MouseFilter = MouseFilterEnum.Ignore, }; _sidebarPanelRoot = panel; - panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.10f, 0.115f, 0.145f, 0.96f))); + // panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.10f, 0.115f, 0.145f, 0.96f))); var frame = new MarginContainer { @@ -831,39 +837,28 @@ private Control CreateSidebarPanel() headerBox.AddChild(ModSettingsUiFactory.CreateInlineDescription( ModSettingsLocalization.Get("sidebar.subtitle", "Browse mods, pages, and sections."))); - var scroll = new ScrollContainer + _sidebarScrollContainer = new ModSettingsScrollContainer(topPadding: 4, bottomPadding: 4) { SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ExpandFill, - HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, - FollowFocus = false, FocusMode = FocusModeEnum.None, }; - _sidebarScrollContainer = scroll; - root.AddChild(scroll); - - var sidebarScrollFrame = new MarginContainer - { - SizeFlagsHorizontal = SizeFlags.ExpandFill, - SizeFlagsVertical = SizeFlags.ExpandFill, - MouseFilter = MouseFilterEnum.Ignore, - }; - sidebarScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); - scroll.AddChild(sidebarScrollFrame); + root.AddChild(_sidebarScrollContainer); _modButtonList = new() { SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ShrinkBegin, MouseFilter = MouseFilterEnum.Ignore, }; _modButtonList.AddThemeConstantOverride("separation", 12); - sidebarScrollFrame.AddChild(_modButtonList); + _sidebarScrollContainer.AttachContent(_modButtonList); return panel; } private Control CreateContentPanel() { - var panel = new Panel + var panel = new Control { Name = "RitsuContentPanel", SizeFlagsHorizontal = SizeFlags.ExpandFill, @@ -871,7 +866,6 @@ private Control CreateContentPanel() MouseFilter = MouseFilterEnum.Ignore, }; _contentPanelRoot = panel; - panel.AddThemeStyleboxOverride("panel", CreatePanelStyle(new(0.08f, 0.095f, 0.125f, 0.98f))); var frame = new MarginContainer { @@ -902,31 +896,22 @@ private Control CreateContentPanel() _pageTabRow.AddThemeConstantOverride("separation", 8); root.AddChild(_pageTabRow); - _scrollContainer = new() + // Stack scroll + build overlay in one rect (overlay draws above scroll; only AttachContent may feed RitsuScrollContainer). + var contentArea = new Control { + Name = "RitsuContentArea", SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ExpandFill, - HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, - FollowFocus = true, - FocusMode = FocusModeEnum.None, - }; - root.AddChild(_scrollContainer); - - var contentStack = new VBoxContainer - { - SizeFlagsHorizontal = SizeFlags.ExpandFill, MouseFilter = MouseFilterEnum.Ignore, }; - contentStack.AddThemeConstantOverride("separation", 0); - _scrollContainer.AddChild(contentStack); + root.AddChild(contentArea); - var contentScrollFrame = new MarginContainer + _scrollContainer = new ModSettingsScrollContainer(topPadding: 0f, bottomPadding: 0f) { - SizeFlagsHorizontal = SizeFlags.ExpandFill, - MouseFilter = MouseFilterEnum.Ignore, + FocusMode = FocusModeEnum.None, }; - contentScrollFrame.AddThemeConstantOverride("margin_right", ScrollContentRightGutter); - contentStack.AddChild(contentScrollFrame); + _scrollContainer.SetAnchorsPreset(LayoutPreset.FullRect); + contentArea.AddChild(_scrollContainer); _contentList = new() { @@ -935,10 +920,11 @@ private Control CreateContentPanel() MouseFilter = MouseFilterEnum.Ignore, }; _contentList.AddThemeConstantOverride("separation", 8); - contentScrollFrame.AddChild(_contentList); + _scrollContainer.AttachContent(_contentList); _contentBuildOverlay = CreateContentBuildOverlay(); - contentStack.AddChild(_contentBuildOverlay); + _contentBuildOverlay.SetAnchorsPreset(LayoutPreset.FullRect); + contentArea.AddChild(_contentBuildOverlay); return panel; } @@ -1009,7 +995,12 @@ private void EnsureSelectionIsValid() _selectionDirty = true; } - ExpandOnlyMod(_selectedModId); + if (!_sidebarExpansionInitialized || _expandedModIds.Any(id => rootPages.All(group => + !string.Equals(group.Key, id, StringComparison.OrdinalIgnoreCase)))) + { + ExpandOnlyMod(_selectedModId); + _sidebarExpansionInitialized = true; + } var modPages = ModSettingsRegistry.GetPages() .Where(page => string.Equals(page.ModId, _selectedModId, StringComparison.OrdinalIgnoreCase)) @@ -1185,13 +1176,15 @@ private void RefreshSelectionState() { var isSelected = string.Equals(pair.Key, _selectedModId, StringComparison.OrdinalIgnoreCase); var isExpanded = _expandedModIds.Contains(pair.Key); + var pages = ModSettingsRegistry.GetPages() + .Where(page => string.Equals(page.ModId, pair.Key, StringComparison.OrdinalIgnoreCase)) + .ToArray(); pair.Value.Card.AddThemeStyleboxOverride("panel", CreateSidebarGroupStyle(isSelected)); + pair.Value.Button.Text = $"{(isExpanded ? "▼" : "▶")} {ResolveSidebarModTitle(pages)}"; pair.Value.MetaLabel.SetTextAutoSize(string.Format( ModSettingsLocalization.Get("sidebar.modMeta", "{0} pages"), - ModSettingsRegistry.GetPages().Count(page => - string.Equals(page.ModId, pair.Key, StringComparison.OrdinalIgnoreCase)))); - pair.Value.MetaLabel.Visible = isExpanded; - pair.Value.NavStack.Visible = isExpanded; + pages.Length)); + SetSidebarModExpanded(pair.Value, isExpanded, pair.Value.FoldInitialized); } _selectionDirty = false; @@ -1478,6 +1471,15 @@ private SidebarModCache CreateSidebarModCache(string modId) string.Empty, () => { + if (_expandedModIds.Contains(modId) && + string.Equals(_selectedModId, modId, StringComparison.OrdinalIgnoreCase)) + { + _expandedModIds.Remove(modId); + _selectionDirty = true; + EnsureUiUpToDate(); + return; + } + _selectedModId = modId; _selectedPageId = ModSettingsRegistry.GetPages() .Where(page => string.Equals(page.ModId, modId, StringComparison.OrdinalIgnoreCase) && @@ -1497,8 +1499,33 @@ private SidebarModCache CreateSidebarModCache(string modId) button.Name = $"Mod_{modId}"; cardContent.AddChild(button); + var foldClip = new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ShrinkBegin, + ClipContents = true, + MouseFilter = MouseFilterEnum.Ignore, + }; + cardContent.AddChild(foldClip); + + var foldContent = new VBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + MouseFilter = MouseFilterEnum.Ignore, + }; + foldContent.AddThemeConstantOverride("separation", 8); + foldContent.AnchorLeft = 0f; + foldContent.AnchorTop = 0f; + foldContent.AnchorRight = 1f; + foldContent.AnchorBottom = 0f; + foldContent.OffsetLeft = 0f; + foldContent.OffsetTop = 0f; + foldContent.OffsetRight = 0f; + foldContent.OffsetBottom = 0f; + foldClip.AddChild(foldContent); + var meta = ModSettingsUiFactory.CreateInlineDescription(string.Empty); - cardContent.AddChild(meta); + foldContent.AddChild(meta); var navStack = new VBoxContainer { @@ -1506,7 +1533,7 @@ private SidebarModCache CreateSidebarModCache(string modId) MouseFilter = MouseFilterEnum.Ignore, }; navStack.AddThemeConstantOverride("separation", 6); - cardContent.AddChild(navStack); + foldContent.AddChild(navStack); return new() { @@ -1515,6 +1542,8 @@ private SidebarModCache CreateSidebarModCache(string modId) Card = card, CardContent = cardContent, Button = button, + FoldClip = foldClip, + FoldContent = foldContent, MetaLabel = meta, NavStack = navStack, }; @@ -1529,8 +1558,6 @@ private void RefreshSidebarModCache(SidebarModCache cache, IReadOnlyList string.IsNullOrWhiteSpace(page.ParentPageId)) .OrderBy(ModSettingsRegistry.GetEffectivePageSortOrder) @@ -1563,6 +1590,111 @@ private void RefreshSidebarModCache(SidebarModCache cache, IReadOnlyList(height => SetSidebarModFoldHeight(cache, height)), + cache.FoldClip.CustomMinimumSize.Y, + expanded ? MeasureSidebarModFoldHeight(cache) : 0f, + SidebarFoldDurationSeconds) + .SetEase(expanded ? Tween.EaseType.Out : Tween.EaseType.In) + .SetTrans(Tween.TransitionType.Cubic); + tween.Finished += () => + { + if (cache.FoldTween != tween) + return; + + cache.FoldTween = null; + cache.FoldContent.Modulate = cache.FoldContent.Modulate with { A = cache.FoldExpanded ? 1f : 0f }; + cache.FoldClip.Visible = cache.FoldExpanded; + cache.FoldContent.Visible = cache.FoldExpanded; + cache.TrackFoldHeight = cache.FoldExpanded; + SetSidebarModFoldHeight(cache, cache.FoldExpanded ? MeasureSidebarModFoldHeight(cache) : 0f); + }; + } + + private void DeferSidebarModFoldHeightSync(SidebarModCache cache) + { + cache.FoldHeightSyncPending = true; + Callable.From(() => Callable.From(() => SyncSidebarModFoldHeight(cache)).CallDeferred()).CallDeferred(); + } + + private void SyncSidebarModFoldHeight(SidebarModCache cache) + { + if (!cache.TrackFoldHeight || !IsInstanceValid(cache.FoldClip) || !IsInstanceValid(cache.FoldContent)) + return; + + cache.FoldHeightSyncPending = false; + SetSidebarModFoldHeight(cache, MeasureSidebarModFoldHeight(cache)); + } + + private static float MeasureSidebarModFoldHeight(SidebarModCache cache) + { + cache.FoldContent.UpdateMinimumSize(); + return cache.FoldContent.GetCombinedMinimumSize().Y; + } + + private void SetSidebarModFoldHeight(SidebarModCache cache, float height) + { + cache.FoldClip.CustomMinimumSize = cache.FoldClip.CustomMinimumSize with { Y = Mathf.Max(0f, height) }; + cache.FoldClip.UpdateMinimumSize(); + for (Node? current = cache.FoldClip; current != null; current = current.GetParent()) + { + if (current is Control control) + control.UpdateMinimumSize(); + if (current is Container container) + container.QueueSort(); + if (ReferenceEquals(current, _modButtonList)) + { + _sidebarScrollContainer.RefreshContentMetrics(); + break; + } + } } private async Task BuildPageAsync(ModSettingsPage page, PageContentCache cache) @@ -1595,7 +1727,7 @@ private async Task BuildPageAsync(ModSettingsPage page, PageContentCache cache) _selectionDirty = true; EnsureUiUpToDate(); } - : static () => { }; + : static () => { }; try { @@ -1677,9 +1809,7 @@ private void ReplaceHostChildren(Control host, Control stagedContent) host.AddChild(child); } - host.ResetSize(); - _contentList.ResetSize(); - _scrollContainer.QueueSort(); + // Do not ResetSize hosts — it collapses expand-fill controls to minimum size after async build. Callable.From(RefreshContentLayout).CallDeferred(); stagedContent.QueueFree(); } @@ -1779,39 +1909,29 @@ private void RefreshContentLayout() if (!IsInstanceValid(_contentList) || !IsInstanceValid(_scrollContainer)) return; - _contentList.ResetSize(); _contentList.QueueSort(); - if (_contentList.GetParent() is Control contentFrame) + var depth = 0; + for (var n = _contentList.GetParent(); n != null && depth < 16; n = n.GetParent(), depth++) { - contentFrame.ResetSize(); - if (contentFrame is Container contentFrameContainer) - contentFrameContainer.QueueSort(); - if (contentFrame.GetParent() is Control contentStack) - { - contentStack.ResetSize(); - if (contentStack is Container contentStackContainer) - contentStackContainer.QueueSort(); - } + if (n is Container c) + c.QueueSort(); } - - _scrollContainer.ResetSize(); - _scrollContainer.QueueSort(); - _scrollContainer.ScrollVertical = Mathf.Max(0, _scrollContainer.ScrollVertical); } private void ScrollToSelectedAnchor() { _suppressScrollSync = true; - if (!string.IsNullOrWhiteSpace(_selectedSectionId)) - if (_contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control target) - { - _scrollContainer.ScrollVertical = Mathf.RoundToInt(target.GlobalPosition.Y - - _scrollContainer.GlobalPosition.Y + _scrollContainer.ScrollVertical - 12f); - Callable.From(() => _suppressScrollSync = false).CallDeferred(); - return; - } + if (!string.IsNullOrWhiteSpace(_selectedSectionId) + && _contentList.FindChild($"Section_{_selectedSectionId}", true, false) is Control target) + { + if (target is ModSettingsCollapsibleSection section && section.SetCollapsed(false, false)) + Callable.From(() => _scrollContainer.ScrollTo(target, skipAnimation: false)).CallDeferred(); + else + _scrollContainer.ScrollTo(target, skipAnimation: false); + } + else + _scrollContainer.InstantlyScrollToTop(); - _scrollContainer.ScrollVertical = 0; Callable.From(() => _suppressScrollSync = false).CallDeferred(); } @@ -1890,17 +2010,17 @@ private void ApplySplitPaneFocusNavigation() IsInstanceValid(owner) && IsAncestorOf(owner): return; case true: - { - _contentOnlyRebuildNeedsContentFocus = false; - var contentTarget = ResolveContentFocusTargetForSection(); - if (contentTarget != null && contentTarget.IsVisibleInTree()) { - GrabControlDeferred(contentTarget); - return; - } + _contentOnlyRebuildNeedsContentFocus = false; + var contentTarget = ResolveContentFocusTargetForSection(); + if (contentTarget != null && contentTarget.IsVisibleInTree()) + { + GrabControlDeferred(contentTarget); + return; + } - break; - } + break; + } } if (IsFocusUnderPopupOrTransientWindow(owner)) @@ -2173,8 +2293,15 @@ private sealed class SidebarModCache public required PanelContainer Card { get; init; } public required VBoxContainer CardContent { get; init; } public required ModSettingsSidebarButton Button { get; init; } + public required Control FoldClip { get; init; } + public required VBoxContainer FoldContent { get; init; } public required MegaRichTextLabel MetaLabel { get; init; } public required VBoxContainer NavStack { get; init; } + public bool FoldExpanded { get; set; } + public bool FoldHeightSyncPending { get; set; } + public bool FoldInitialized { get; set; } + public bool TrackFoldHeight { get; set; } + public Tween? FoldTween { get; set; } public Dictionary PageNodes { get; } = new(StringComparer.OrdinalIgnoreCase); }