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);
}