From 7cbef9e7d0652f8401d73502729f93dbeba1ddaa Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Fri, 20 Mar 2026 10:11:12 +0100 Subject: [PATCH 01/10] Add Control.AddThemeFontSizeOverrideAll --- Config/ModConfig.cs | 6 +----- Extensions/ControlExtensions.cs | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index db3ae12..fe595ec 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -342,11 +342,7 @@ protected static MegaRichTextLabel CreateRawLabelControl(string labelText, int f label.AddThemeFontOverride("normal_font", kreonNormal); label.AddThemeFontOverride("bold_font", kreonBold); - label.AddThemeFontSizeOverride("normal_font_size", fontSize); - label.AddThemeFontSizeOverride("bold_font_size", fontSize); - label.AddThemeFontSizeOverride("bold_italics_font_size", fontSize); - label.AddThemeFontSizeOverride("italics_font_size", fontSize); - label.AddThemeFontSizeOverride("mono_font_size", fontSize); + label.AddThemeFontSizeOverrideAll(fontSize); return label; } diff --git a/Extensions/ControlExtensions.cs b/Extensions/ControlExtensions.cs index 28e757c..e662cfb 100644 --- a/Extensions/ControlExtensions.cs +++ b/Extensions/ControlExtensions.cs @@ -18,4 +18,24 @@ public static void DrawDebug(this Control artist, Control child) { artist.DrawRect(new Rect2(child.Position, child.Size), new Color(1, 1, 1, 0.5f)); } -} + + /// + /// Calls AddThemeFontSizeOverride() for all font types: font_size, {normal,bold,italics,bold_italics,mono}_font_size. + /// + public static void AddThemeFontSizeOverrideAll(this Control control, int fontSize) + { + string[] fontTypes = [ + "font_size", + "normal_font_size", + "bold_font_size", + "italics_font_size", + "bold_italics_font_size", + "mono_font_size" + ]; + + foreach (var fontType in fontTypes) + { + control.AddThemeFontSizeOverride(fontType, fontSize); + } + } +} \ No newline at end of file From e0fd02191c0448e94b5bf799e3f89652356f5af6 Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Fri, 20 Mar 2026 10:30:04 +0100 Subject: [PATCH 02/10] Mod config: set error font size; clean up ShowMod --- Config/ModConfig.cs | 2 +- Config/UI/NModConfigPopup.cs | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index fe595ec..712a259 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -193,7 +193,7 @@ public void Load() } catch (JsonException jsonEx) { - ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid. " + + ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid.\n" + $"Error: {jsonEx.Message}"); ModConfigLogger.Warn("Config saving has been DISABLED for this session to protect any manual edits. " + "Please fix the JSON formatting.", true); diff --git a/Config/UI/NModConfigPopup.cs b/Config/UI/NModConfigPopup.cs index bc650d6..28c2b8e 100644 --- a/Config/UI/NModConfigPopup.cs +++ b/Config/UI/NModConfigPopup.cs @@ -1,4 +1,5 @@ -using BaseLib.Utils; +using BaseLib.Extensions; +using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Assets; @@ -140,14 +141,7 @@ private void ShowMod(ModConfig config, NConfigButton opener) _currentConfig = config; config.ConfigChanged += OnConfigChanged; Show(); - - var pendingMessages = ModConfig.ModConfigLogger.PendingUserMessages; - if (pendingMessages.Count > 0) - { - var popup = NErrorPopup.Create("Mod configuration error", string.Join('\n', pendingMessages), false); - NModalContainer.Instance?.Add(popup!); - pendingMessages.Clear(); - } + ShowAndClearPendingErrors(); } catch (Exception e) { @@ -200,4 +194,22 @@ private void SaveCurrentConfig() _currentConfig?.Save(); _saveTimer = -1; } + + protected static void ShowAndClearPendingErrors() + { + var pendingMessages = ModConfig.ModConfigLogger.PendingUserMessages; + if (pendingMessages.Count > 0) + { + var errorPopup = NErrorPopup.Create("Mod configuration error", + string.Join('\n', pendingMessages), false); + if (errorPopup == null || NModalContainer.Instance == null) return; + NModalContainer.Instance.Add(errorPopup); + + var vertPopup = errorPopup.GetNodeOrNull("VerticalPopup"); + if (vertPopup == null) return; + vertPopup.BodyLabel.AddThemeFontSizeOverrideAll(22); + + pendingMessages.Clear(); + } + } } \ No newline at end of file From 3112e2b043da736b4245aa231548afebe29ec91e Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Fri, 20 Mar 2026 16:17:49 +0100 Subject: [PATCH 03/10] Mod config: show as a proper submenu (WIP) * Insert a native-looking submenu when the mod config icon is clicked * Old ModConfigs are fully compatible * Full controller support (after the next few commits) The commits just after this one finish this feature up. --- BaseLib/localization/eng/settings_ui.json | 2 + Config/ModConfig.cs | 28 ++- Config/UI/NConfigButton.cs | 37 ++- Config/UI/NModConfigPopup.cs | 215 ----------------- Config/UI/NModConfigSubmenu.cs | 281 ++++++++++++++++++++++ 5 files changed, 332 insertions(+), 231 deletions(-) delete mode 100644 Config/UI/NModConfigPopup.cs create mode 100644 Config/UI/NModConfigSubmenu.cs diff --git a/BaseLib/localization/eng/settings_ui.json b/BaseLib/localization/eng/settings_ui.json index c639e7f..612199d 100644 --- a/BaseLib/localization/eng/settings_ui.json +++ b/BaseLib/localization/eng/settings_ui.json @@ -1,4 +1,6 @@ { + "BASELIB.mod_title": "Example mod configuration", + "BASELIB-FIRST_SECTION.title": "These are just example options", "BASELIB-SECOND_SECTION.title": "Nothing here does anything at all", diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index 712a259..f2929b8 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -11,6 +11,7 @@ using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; namespace BaseLib.Config; @@ -26,7 +27,7 @@ public abstract partial class ModConfig public event EventHandler? ConfigChanged; private readonly string _path; - protected string ModPrefix { get; private set; } + public string ModPrefix { get; private set; } private readonly string _modConfigName; private bool _savingDisabled; @@ -193,8 +194,12 @@ public void Load() } catch (JsonException jsonEx) { + var locationText = jsonEx.LineNumber.HasValue + ? $"Line {jsonEx.LineNumber + 1}, position {jsonEx.BytePositionInLine + 1}" + : "unknown line"; ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid.\n" + - $"Error: {jsonEx.Message}"); + $"File path: {_path}\n" + + $"Error location: {locationText}"); ModConfigLogger.Warn("Config saving has been DISABLED for this session to protect any manual edits. " + "Please fix the JSON formatting.", true); _savingDisabled = true; @@ -323,7 +328,7 @@ protected NDropdownPositioner CreateRawDropdownControl(PropertyInfo property) // Creates a raw label control, with no layout (see SimpleModConfig.Create*Option and CreateSectionHeader for // layout-ready controls) - protected static MegaRichTextLabel CreateRawLabelControl(string labelText, int fontSize) + public static MegaRichTextLabel CreateRawLabelControl(string labelText, int fontSize) { var kreonNormal = PreloadManager.Cache.GetAsset("res://themes/kreon_regular_shared.tres"); var kreonBold = PreloadManager.Cache.GetAsset("res://themes/kreon_bold_shared.tres"); @@ -358,6 +363,23 @@ protected static ColorRect CreateDividerControl() }; } + public static void ShowAndClearPendingErrors() + { + var pendingMessages = ModConfigLogger.PendingUserMessages; + if (pendingMessages.Count <= 0) return; + + var errorPopup = NErrorPopup.Create("Mod configuration error", + string.Join('\n', pendingMessages), false); + if (errorPopup == null || NModalContainer.Instance == null) return; + NModalContainer.Instance.Add(errorPopup); + + var vertPopup = errorPopup.GetNodeOrNull("VerticalPopup"); + if (vertPopup == null) return; + vertPopup.BodyLabel.AddThemeFontSizeOverrideAll(22); + + pendingMessages.Clear(); + } + [GeneratedRegex("[^a-zA-Z0-9_.]")] private static partial Regex SpecialCharRegex(); } \ No newline at end of file diff --git a/Config/UI/NConfigButton.cs b/Config/UI/NConfigButton.cs index c4c7cdb..de18e21 100644 --- a/Config/UI/NConfigButton.cs +++ b/Config/UI/NConfigButton.cs @@ -1,7 +1,10 @@ -using BaseLib.Patches.UI; +using System.Reflection; +using BaseLib.Patches.UI; using BaseLib.Utils; using Godot; +using HarmonyLib; using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.ModdingScreen; using MegaCrit.Sts2.Core.Nodes.TopBar; @@ -59,6 +62,7 @@ public override void _Process(double delta) } } + private static readonly FieldInfo ModdingScreenStack = AccessTools.Field(typeof(NModdingScreen), "_stack"); protected override void OnRelease() { base.OnRelease(); @@ -74,17 +78,7 @@ protected override void OnRelease() var modConfig = ModConfigRegistry.Get(mod.manifest?.id); if (modConfig != null) { - // Find NModdingScreen - var parent = GetParent(); - while (parent != null && parent is not NModdingScreen) - { - parent = parent.GetParent(); - } - - if (parent is not NModdingScreen screen) return; - - NModConfigPopup.ShowModConfig(screen, modConfig, this); - IsConfigOpen = true; + OpenModConfigSubmenu(modConfig); } } @@ -105,10 +99,27 @@ public override void OnUnfocus() base.OnUnfocus(); NHoverTipSet.Remove(this); }*/ - protected override bool IsOpen() { return IsConfigOpen; } + + private void OpenModConfigSubmenu(ModConfig modConfig) + { + var stackField = AccessTools.Field(typeof(NSubmenu), "_stack"); + + if (FindParent("ModdingScreen") is not NModdingScreen moddingScreen || + stackField.GetValue(moddingScreen) is not NMainMenuSubmenuStack stackInstance) + { + ModConfig.ModConfigLogger.Error("Unable to locate the game's modding screen!\n" + + "Please report a bug at:\nhttps://github.com/Alchyr/BaseLib-StS2"); + return; + } + + var modConfigSubmenu = stackInstance.PushSubmenuType(); + modConfigSubmenu.LoadModConfig(modConfig, this); + + IsConfigOpen = true; + } } \ No newline at end of file diff --git a/Config/UI/NModConfigPopup.cs b/Config/UI/NModConfigPopup.cs deleted file mode 100644 index 28c2b8e..0000000 --- a/Config/UI/NModConfigPopup.cs +++ /dev/null @@ -1,215 +0,0 @@ -using BaseLib.Extensions; -using BaseLib.Utils; -using Godot; -using HarmonyLib; -using MegaCrit.Sts2.Core.Assets; -using MegaCrit.Sts2.Core.Helpers; -using MegaCrit.Sts2.Core.Nodes.CommonUi; -using MegaCrit.Sts2.Core.Nodes.GodotExtensions; -using MegaCrit.Sts2.Core.Nodes.Screens.ModdingScreen; - -namespace BaseLib.Config.UI; - -public partial class NModConfigPopup : NClickableControl -{ - public static readonly SpireField ConfigPopup = new(Create); - - [HarmonyPatch(typeof(NModdingScreen), nameof(NModdingScreen._Ready))] - static class NModConfigPatch - { - [HarmonyPostfix] - static void PrepPopup(NModdingScreen __instance) - { - ConfigPopup.Get(__instance); - } - } - - public static void ShowModConfig(NModdingScreen screen, ModConfig config, NConfigButton opener) - { - var popup = ConfigPopup.Get(screen); - if (popup == null) - { - opener.IsConfigOpen = false; - return; - } - - popup?.ShowMod(config, opener); - } - - private ModConfig? _currentConfig; - private NScrollableContainer _optionScrollContainer; - private VBoxContainer _optionContainer; - private NConfigButton? _opener; - private double _saveTimer; //When any config option is changed, starts a timer. If enough time passes with no change, saves. - private const double AutosaveDelay = 5; - - public static NModConfigPopup Create(NModdingScreen screen) - { - NModConfigPopup popup = new(screen); - /*popup.Size = screen.Size; - popup.MouseFilter = MouseFilterEnum.Ignore; - popup.Hide(); - - screen.AddChild(popup); - popup.Owner = screen; - - popup._optionContainer.Size = new(, 1); - popup.Position = */ - return popup; - } - - private NModConfigPopup(Control futureParent) - { - _saveTimer = -1; - - Size = futureParent.Size; - MouseFilter = MouseFilterEnum.Ignore; - - _optionScrollContainer = new(); - _optionScrollContainer.MouseFilter = MouseFilterEnum.Stop; - _optionScrollContainer.Size = new Vector2( - x: Math.Max(480, Size.X * 0.5f), - y: Math.Min(950, Size.Y * 0.95f) - ); - Color back = new Color(0.1f, 0.1f, 0.1f); //Allow mods to change the color of their panel? - Color border = new Color(239/255f, 198/255f, 93/255f); //Allow mods to change the color of their panel? - _optionScrollContainer.Draw += () => - { - _optionScrollContainer - .DrawRect(new Rect2(0, 0, _optionScrollContainer.Size), back); - _optionScrollContainer - .DrawRect(new Rect2(0, 0, _optionScrollContainer.Size), border, false, 2); - }; - - AddChild(_optionScrollContainer); - _optionScrollContainer.Owner = this; - _optionScrollContainer.Position = Size * 0.5f - _optionScrollContainer.Size * 0.5f; - - NScrollbar scrollbar = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/scrollbar")).Instantiate(); - scrollbar.Name = "Scrollbar"; - _optionScrollContainer.AddChild(scrollbar); - scrollbar.Owner = _optionScrollContainer; - - scrollbar.SetAnchorsPreset(LayoutPreset.RightWide); - scrollbar.OffsetLeft = 0; - scrollbar.OffsetRight = 48; - scrollbar.OffsetTop = 32; - scrollbar.OffsetBottom = -32; - - Control mask = new(); - mask.Name = "Mask"; - mask.Size = _optionScrollContainer.Size; - mask.MouseFilter = MouseFilterEnum.Ignore; - mask.ClipContents = true; - - _optionScrollContainer.AddChild(mask); - mask.Owner = _optionScrollContainer; - - _optionContainer = new VBoxContainer(); - _optionContainer.Name = "Content"; - _optionContainer.CustomMinimumSize = new Vector2(mask.Size.X, 0); - mask.MouseFilter = MouseFilterEnum.Ignore; - - _optionContainer.MinimumSizeChanged += () => - { - _optionContainer.Size = new Vector2(mask.Size.X, _optionContainer.GetMinimumSize().Y); - }; - - mask.AddChild(_optionContainer); - _optionContainer.Owner = mask; - - Hide(); - futureParent.AddChildSafely(this); - } - - public override void _Ready() - { - ConnectSignals(); - } - - private void ShowMod(ModConfig config, NConfigButton opener) - { - _opener = opener; - NHotkeyManager.Instance?.AddBlockingScreen(this); - MouseFilter = MouseFilterEnum.Stop; - - try - { - config.SetupConfigUI(_optionContainer); - _optionScrollContainer.DisableScrollingIfContentFits(); - _optionScrollContainer.InstantlyScrollToTop(); - _currentConfig = config; - config.ConfigChanged += OnConfigChanged; - Show(); - ShowAndClearPendingErrors(); - } - catch (Exception e) - { - MainFile.Logger.Error(e.ToString()); - ClosePopup(); - } - } - - private void ClosePopup() - { - if (_opener != null) _opener.IsConfigOpen = false; - NHotkeyManager.Instance?.RemoveBlockingScreen(this); - MouseFilter = MouseFilterEnum.Ignore; - if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; - Hide(); - _optionContainer.FreeChildren(); - foreach (var child in _optionContainer.GetParent().GetChildren()) - if (child != _optionContainer) - child.QueueFreeSafely(); - } - - private void OnConfigChanged(object? sender, EventArgs e) - { - _saveTimer = AutosaveDelay; - } - - public override void _Process(double delta) - { - base._Process(delta); - if (_saveTimer > 0) - { - _saveTimer -= delta; - if (_saveTimer <= 0) - { - SaveCurrentConfig(); - } - } - } - - protected override void OnRelease() - { - base.OnRelease(); - - SaveCurrentConfig(); - ClosePopup(); - } - - private void SaveCurrentConfig() - { - _currentConfig?.Save(); - _saveTimer = -1; - } - - protected static void ShowAndClearPendingErrors() - { - var pendingMessages = ModConfig.ModConfigLogger.PendingUserMessages; - if (pendingMessages.Count > 0) - { - var errorPopup = NErrorPopup.Create("Mod configuration error", - string.Join('\n', pendingMessages), false); - if (errorPopup == null || NModalContainer.Instance == null) return; - NModalContainer.Instance.Add(errorPopup); - - var vertPopup = errorPopup.GetNodeOrNull("VerticalPopup"); - if (vertPopup == null) return; - vertPopup.BodyLabel.AddThemeFontSizeOverrideAll(22); - - pendingMessages.Clear(); - } - } -} \ No newline at end of file diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs new file mode 100644 index 0000000..2d5a767 --- /dev/null +++ b/Config/UI/NModConfigSubmenu.cs @@ -0,0 +1,281 @@ +using BaseLib.Extensions; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; + +namespace BaseLib.Config.UI; + +[HarmonyPatch(typeof(NMainMenuSubmenuStack), nameof(NMainMenuSubmenuStack.GetSubmenuType), typeof(Type))] +public static class InjectModConfigSubmenuPatch +{ + private static NModConfigSubmenu? _modConfigSubmenu; + + [HarmonyPrefix] + public static bool Prefix(NMainMenuSubmenuStack __instance, Type type, ref NSubmenu __result) + { + if (type != typeof(NModConfigSubmenu)) return true; + + if (_modConfigSubmenu == null) + { + _modConfigSubmenu = new NModConfigSubmenu(); + _modConfigSubmenu.Visible = false; + __instance.AddChildSafely(_modConfigSubmenu); + } + __result = _modConfigSubmenu; + return false; + } +} + +public partial class NModConfigSubmenu : NSubmenu +{ + private VBoxContainer _optionContainer; + private NScrollableContainer _scrollContainer; + private Control _contentPanel; + private MegaRichTextLabel _modTitle; + private NConfigButton? _opener; + private Tween? _fadeTween; + + private const float ContentWidth = 1012f; // Same as the base game + private const float ClipperTopOffset = 187f; + private const float ModTitleHeight = 90f; + private const float MinPadding = 30f; + + protected override Control InitialFocusedControl => FindFirstFocusable(_optionContainer) ?? _optionContainer; + + public NModConfigSubmenu() + { + // Basic node structure: + // NModConfigSubmenu > _scrollContainer > mask > clipper > _contentPanel > _optionContainer + // ... where _optionContainer is passed to the mod's ModConfig + AnchorRight = 1f; + AnchorBottom = 1f; + GrowHorizontal = GrowDirection.Both; + GrowVertical = GrowDirection.Both; + + _scrollContainer = new NScrollableContainer + { + Name = "ScrollContainer", + ClipChildren = ClipChildrenMode.Only, + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + }; + + // Verbose, but basically the same as the fading gradient in the original settings scene + var mask = new TextureRect + { + Name = "Mask", + ClipChildren = ClipChildrenMode.Only, + AnchorRight = 1f, + AnchorBottom = 1f, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + Texture = new GradientTexture2D + { + FillFrom = new Vector2(0f, 1f), + FillTo = Vector2.Zero, + Gradient = new Gradient + { + // Note: these are ordered bottom-to-top! + Offsets = [0f, 0.08f, 0.805f, 0.827f], + Colors = + [ + new Color(1f, 1f, 1f, 0f), + new Color(1f, 1f, 1f), + new Color(1f, 1f, 1f), + new Color(1f, 1f, 1f, 0f) + ], + }, + }, + }; + + var clipper = new Control + { + Name = "Clipper", + ClipContents = true, + AnchorRight = 1f, + AnchorBottom = 1f, + OffsetTop = ClipperTopOffset, + GrowHorizontal = GrowDirection.Both, + GrowVertical = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + mask.AddChild(clipper); + + _contentPanel = new Control + { + Name = "ModConfigContent", + AnchorLeft = 0.5f, + AnchorRight = 0.5f, + OffsetLeft = -ContentWidth / 2, + OffsetTop = 24f, + OffsetRight = ContentWidth / 2, + GrowHorizontal = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + clipper.AddChild(_contentPanel); + + // The container that we send to the ModConfig to populate + _optionContainer = new VBoxContainer + { + Name = "VBoxContainer", + CustomMinimumSize = new Vector2(ContentWidth, 0f), + AnchorRight = 1f, + GrowHorizontal = GrowDirection.Both, + MouseFilter = MouseFilterEnum.Ignore, + }; + + _optionContainer.MinimumSizeChanged += RefreshSize; + + _contentPanel.AddChild(_optionContainer); + + var scrollbar = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/scrollbar")) + .Instantiate(); + scrollbar.Name = "Scrollbar"; + const float gap = 54f; + const float width = 48f; + scrollbar.AnchorLeft = 0.5f; + scrollbar.AnchorRight = 0.5f; + scrollbar.AnchorTop = 0f; + scrollbar.AnchorBottom = 1f; + scrollbar.OffsetLeft = ContentWidth / 2 + gap; + scrollbar.OffsetRight = ContentWidth / 2 + gap + width; + + scrollbar.OffsetTop = 219f; + scrollbar.OffsetBottom = -64f; + + _scrollContainer.AddChild(scrollbar); + scrollbar.Owner = _scrollContainer; + _scrollContainer.AddChild(mask); + + // Autosize is on, but we need a value here + _modTitle = ModConfig.CreateRawLabelControl("[center]Unknown mod name[/center]", 36); + _modTitle.Name = "ModTitle"; + _modTitle.AutoSizeEnabled = true; + _modTitle.MaxFontSize = 64; + _modTitle.CustomMinimumSize = new Vector2(ContentWidth, ModTitleHeight); + + _modTitle.SetAnchorsPreset(LayoutPreset.TopWide); + _modTitle.OffsetBottom = ClipperTopOffset - 10; + _modTitle.OffsetTop = _modTitle.OffsetBottom - ModTitleHeight; + } + + public override void _Ready() + { + AddChild(_scrollContainer); + AddChild(_modTitle); + _scrollContainer.SetContent(_contentPanel); + _scrollContainer.DisableScrollingIfContentFits(); + + var backButton = PreloadManager.Cache.GetScene(SceneHelper.GetScenePath("ui/back_button")).Instantiate(); + backButton.Name = "BackButton"; + AddChild(backButton); + + ConnectSignals(); + GetViewport().Connect(Viewport.SignalName.SizeChanged, Callable.From(RefreshSize)); + } + + public void LoadModConfig(ModConfig config, NConfigButton opener) + { + _opener = opener; + _optionContainer.FreeChildren(); + _optionContainer.AddThemeConstantOverride("separation", 8); + + try + { + config.SetupConfigUI(_optionContainer); + + SetModTitle(config); + _scrollContainer.DisableScrollingIfContentFits(); + RefreshSize(); + ModConfig.ShowAndClearPendingErrors(); + + // Note: TryGrabFocus returns if a controller isn't being used + Callable.From( + () => FindFirstFocusable(_optionContainer)?.TryGrabFocus() + ).CallDeferred(); + } + catch (Exception e) + { + MainFile.Logger.Error(e.ToString()); + _stack.Pop(); + } + } + + private void SetModTitle(ModConfig config) + { + var fallbackTitle = config.GetType().GetRootNamespace(); + if (string.IsNullOrWhiteSpace(fallbackTitle)) fallbackTitle = "Unknown mod name"; + + var locKey = $"{config.ModPrefix[..^1]}.mod_title"; + var locStr = LocString.GetIfExists("settings_ui", locKey); + if (locStr == null) + { + ModConfig.ModConfigLogger.Warn( + $"No {locKey} found in localization table, using mod namespace as title"); + } + + var titleText = locStr?.GetFormattedText() ?? fallbackTitle; + _modTitle.SetTextAutoSize($"[center]{titleText}[/center]"); + } + + private void RefreshSize() + { + var clipperSize = _contentPanel.GetParent().Size; + var requiredHeight = _optionContainer.GetMinimumSize().Y; + var paddedHeight = requiredHeight + MinPadding; + + // Emulate the game's menus: add padding below if scrolling is (almost) needed + if (paddedHeight >= clipperSize.Y) + { + paddedHeight += clipperSize.Y * 0.3f; + } + + _contentPanel.CustomMinimumSize = new Vector2(ContentWidth, paddedHeight); + _optionContainer.Size = new Vector2(ContentWidth, requiredHeight); + _scrollContainer.DisableScrollingIfContentFits(); + } + + protected override void OnSubmenuShown() + { + base.OnSubmenuShown(); + _scrollContainer.InstantlyScrollToTop(); + + _fadeTween?.Kill(); + _fadeTween = CreateTween().SetParallel(); + _fadeTween.TweenProperty(_contentPanel, "modulate", Colors.White, 0.5f) + .From(new Color(0, 0, 0, 0)) + .SetEase(Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Cubic); + } + + protected override void OnSubmenuHidden() + { + if (_opener != null) _opener.IsConfigOpen = false; + + base.OnSubmenuHidden(); + } + + private static Control? FindFirstFocusable(Node parent) + { + foreach (var child in parent.GetChildren()) + { + if (child is Control { FocusMode: FocusModeEnum.All or FocusModeEnum.Click } control) + return control; + + var nestedFocus = FindFirstFocusable(child); + if (nestedFocus != null) + return nestedFocus; + } + + return null; + } +} \ No newline at end of file From 1d2232d7eeaa5f5deb9d45abf2f281993447f12a Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Fri, 20 Mar 2026 21:09:23 +0100 Subject: [PATCH 04/10] Mod config: focus fixes; revert toggle layout --- Config/ModConfig.cs | 1 + Config/SimpleModConfig.cs | 13 ++++++------- Config/UI/NConfigOptionRow.cs | 1 + Config/UI/NConfigTickbox.cs | 15 +++------------ 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index f2929b8..4610064 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -339,6 +339,7 @@ public static MegaRichTextLabel CreateRawLabelControl(string labelText, int font Theme = PreloadManager.Cache.GetAsset(SettingsTheme), AutoSizeEnabled = false, MouseFilter = Control.MouseFilterEnum.Ignore, + FocusMode = Control.FocusModeEnum.None, BbcodeEnabled = true, ScrollActive = false, VerticalAlignment = VerticalAlignment.Center, diff --git a/Config/SimpleModConfig.cs b/Config/SimpleModConfig.cs index 551c840..cbae80a 100644 --- a/Config/SimpleModConfig.cs +++ b/Config/SimpleModConfig.cs @@ -52,6 +52,7 @@ protected MarginContainer CreateSectionHeader(string labelName, bool alignToTop container.AddThemeConstantOverride("margin_left", 24); container.AddThemeConstantOverride("margin_right", 24); container.MouseFilter = Control.MouseFilterEnum.Ignore; + container.FocusMode = Control.FocusModeEnum.None; var label = CreateRawLabelControl($"[center][b]{GetLabelText(labelName)}[/b][/center]", 40); label.Name = "SectionLabel_" + labelName.Replace(" ", ""); @@ -147,14 +148,12 @@ protected void GenerateOptionsForAllProperties(Control targetContainer) if (previousSetting != null) { - var path = currentSetting.GetPathTo(previousSetting); - if (currentSetting.FocusNeighborLeft?.IsEmpty != false) currentSetting.FocusNeighborLeft = path; - if (currentSetting.FocusNeighborTop?.IsEmpty != false) currentSetting.FocusNeighborTop = path; - - path = previousSetting.GetPathTo(currentSetting); - if (previousSetting.FocusNeighborRight?.IsEmpty != false) previousSetting.FocusNeighborRight = path; - if (previousSetting.FocusNeighborBottom?.IsEmpty != false) previousSetting.FocusNeighborBottom = path; + currentSetting.FocusNeighborTop = currentSetting.GetPathTo(previousSetting); + previousSetting.FocusNeighborBottom = previousSetting.GetPathTo(currentSetting); } + + currentSetting.FocusNeighborLeft = currentSetting.GetPath(); + currentSetting.FocusNeighborRight = currentSetting.GetPath(); } catch (NotSupportedException ex) { diff --git a/Config/UI/NConfigOptionRow.cs b/Config/UI/NConfigOptionRow.cs index 4849236..5f57d73 100644 --- a/Config/UI/NConfigOptionRow.cs +++ b/Config/UI/NConfigOptionRow.cs @@ -31,6 +31,7 @@ public NConfigOptionRow(string modPrefix, PropertyInfo property, Control label, AddThemeConstantOverride("margin_left", 24); AddThemeConstantOverride("margin_right", 24); MouseFilter = MouseFilterEnum.Pass; + FocusMode = FocusModeEnum.None; CustomMinimumSize = new Vector2(0, 64); label.CustomMinimumSize = new Vector2(0, 64); diff --git a/Config/UI/NConfigTickbox.cs b/Config/UI/NConfigTickbox.cs index 034db40..718ebca 100644 --- a/Config/UI/NConfigTickbox.cs +++ b/Config/UI/NConfigTickbox.cs @@ -11,26 +11,17 @@ public partial class NConfigTickbox : NSettingsTickbox public NConfigTickbox() { - SetCustomMinimumSize(new Vector2(64, 64)); + SetCustomMinimumSize(new Vector2(320, 64)); SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; + FocusMode = FocusModeEnum.All; + MouseFilter = MouseFilterEnum.Pass; } public override void _Ready() { if (_property == null) throw new Exception("NConfigTickbox added to tree without an assigned property"); ConnectSignals(); - - var tickboxVisuals = GetNode("%TickboxVisuals"); - tickboxVisuals.SetAnchorsAndOffsetsPreset(LayoutPreset.CenterRight, LayoutPresetMode.KeepSize); - - if (GetParent() is MarginContainer parentContainer) - { - // Hacky, but aligns it properly with dropdowns and sliders. Likely needed due to transparent pixels - // in the graphic. - parentContainer.AddThemeConstantOverride("margin_right", parentContainer.GetThemeConstant("margin_right") - 10); - } - SetFromProperty(); } From 1f8f3e7c1094ef39fb66be2c3afa5c0c82333d3a Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Sat, 21 Mar 2026 15:05:14 +0100 Subject: [PATCH 05/10] Mod config: final touches prior to testing Implements the actual logic (save + autosave), and cleans up a bit. --- Config/UI/NConfigOptionRow.cs | 4 +-- Config/UI/NModConfigSubmenu.cs | 49 ++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Config/UI/NConfigOptionRow.cs b/Config/UI/NConfigOptionRow.cs index 5f57d73..f16846d 100644 --- a/Config/UI/NConfigOptionRow.cs +++ b/Config/UI/NConfigOptionRow.cs @@ -28,8 +28,8 @@ public NConfigOptionRow(string modPrefix, PropertyInfo property, Control label, Name = property.Name; SettingControl = settingControl; - AddThemeConstantOverride("margin_left", 24); - AddThemeConstantOverride("margin_right", 24); + AddThemeConstantOverride("margin_left", 12); + AddThemeConstantOverride("margin_right", 12); MouseFilter = MouseFilterEnum.Pass; FocusMode = FocusModeEnum.None; CustomMinimumSize = new Vector2(0, 64); diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs index 2d5a767..89e890d 100644 --- a/Config/UI/NModConfigSubmenu.cs +++ b/Config/UI/NModConfigSubmenu.cs @@ -39,7 +39,11 @@ public partial class NModConfigSubmenu : NSubmenu private Control _contentPanel; private MegaRichTextLabel _modTitle; private NConfigButton? _opener; - private Tween? _fadeTween; + private Tween? _fadeInTween; + + private ModConfig? _currentConfig; + private double _saveTimer = -1; + private const double AutosaveDelay = 5; private const float ContentWidth = 1012f; // Same as the base game private const float ClipperTopOffset = 187f; @@ -149,11 +153,10 @@ public NModConfigSubmenu() scrollbar.OffsetLeft = ContentWidth / 2 + gap; scrollbar.OffsetRight = ContentWidth / 2 + gap + width; - scrollbar.OffsetTop = 219f; + scrollbar.OffsetTop = ClipperTopOffset + 32f; scrollbar.OffsetBottom = -64f; _scrollContainer.AddChild(scrollbar); - scrollbar.Owner = _scrollContainer; _scrollContainer.AddChild(mask); // Autosize is on, but we need a value here @@ -185,6 +188,8 @@ public override void _Ready() public void LoadModConfig(ModConfig config, NConfigButton opener) { + if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; + _currentConfig = config; _opener = opener; _optionContainer.FreeChildren(); _optionContainer.AddThemeConstantOverride("separation", 8); @@ -192,10 +197,13 @@ public void LoadModConfig(ModConfig config, NConfigButton opener) try { config.SetupConfigUI(_optionContainer); - SetModTitle(config); + config.ConfigChanged += OnConfigChanged; + _scrollContainer.DisableScrollingIfContentFits(); + _scrollContainer.InstantlyScrollToTop(); RefreshSize(); + ModConfig.ShowAndClearPendingErrors(); // Note: TryGrabFocus returns if a controller isn't being used @@ -247,11 +255,12 @@ private void RefreshSize() protected override void OnSubmenuShown() { base.OnSubmenuShown(); - _scrollContainer.InstantlyScrollToTop(); - _fadeTween?.Kill(); - _fadeTween = CreateTween().SetParallel(); - _fadeTween.TweenProperty(_contentPanel, "modulate", Colors.White, 0.5f) + _saveTimer = -1; + + _fadeInTween?.Kill(); + _fadeInTween = CreateTween().SetParallel(); + _fadeInTween.TweenProperty(_contentPanel, "modulate", Colors.White, 0.5f) .From(new Color(0, 0, 0, 0)) .SetEase(Tween.EaseType.Out) .SetTrans(Tween.TransitionType.Cubic); @@ -259,11 +268,18 @@ protected override void OnSubmenuShown() protected override void OnSubmenuHidden() { + if (_currentConfig != null) _currentConfig.ConfigChanged -= OnConfigChanged; if (_opener != null) _opener.IsConfigOpen = false; + SaveCurrentConfig(); base.OnSubmenuHidden(); } + private void OnConfigChanged(object? sender, EventArgs e) + { + _saveTimer = AutosaveDelay; + } + private static Control? FindFirstFocusable(Node parent) { foreach (var child in parent.GetChildren()) @@ -278,4 +294,21 @@ protected override void OnSubmenuHidden() return null; } + + public override void _Process(double delta) + { + base._Process(delta); + if (_saveTimer <= 0) return; + _saveTimer -= delta; + if (_saveTimer <= 0) + { + SaveCurrentConfig(); + } + } + + private void SaveCurrentConfig() + { + _currentConfig?.Save(); + _saveTimer = -1; + } } \ No newline at end of file From a0595ac0520c55cbc79d503e8e76056bf16393dd Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Sat, 21 Mar 2026 16:31:15 +0100 Subject: [PATCH 06/10] Mod config: final touches on error reporting Only show error popups for errors the user likely really cares about. --- Config/ModConfig.cs | 65 +++++++++++++++++++++++++--------- Config/UI/NModConfigSubmenu.cs | 11 +++++- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index 4610064..df892d3 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -12,10 +12,23 @@ using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Localization; using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; namespace BaseLib.Config; +// NMainMenu is recreated every time you return from a run, etc., so this isn't a run-once as it seems. +// We also check for errors when exiting the mod config submenu, as that won't trigger this code. +[HarmonyPatch(typeof(NMainMenu), nameof(NMainMenu._Ready))] +public static class NMainMenu_Ready_Patch +{ + public static void Postfix() + { + if (ModConfig.ModConfigLogger.PendingUserMessages.Count == 0) return; + Callable.From(ModConfig.ShowAndClearPendingErrors).CallDeferred(); + } +} + public abstract partial class ModConfig { private const string SettingsTheme = "res://themes/settings_screen_line_header.tres"; @@ -39,12 +52,17 @@ public static class ModConfigLogger { public static List PendingUserMessages { get; } = []; + /// + /// Show a message in the console, and optionally in the GUI. Only use showInGui=true if truly necessary; + /// players won't enjoy having warnings/errors shoved in their faces unless it's something that truly impacts them. + /// public static void Warn(string message, bool showInGui = false) { MainFile.Logger.Warn(message); if (showInGui && !PendingUserMessages.Contains(message)) PendingUserMessages.Add(message); } + /// public static void Error(string message, bool showInGui = true) { MainFile.Logger.Error(message); @@ -91,7 +109,7 @@ private void CheckConfigProperties() if (!property.CanRead || !property.CanWrite) continue; if (property.GetMethod?.IsStatic != true) { - ModConfigLogger.Warn($"Ignoring {_modConfigName} property {property.Name}: only static properties are supported", true); + ModConfigLogger.Warn($"Ignoring {_modConfigName} property {property.Name}: only static properties are supported"); continue; } @@ -118,22 +136,36 @@ public void Save() { if (_savingDisabled) { - ModConfigLogger.Error($"Skipping save for {_modConfigName} because the config file is currently in a corrupted, read-only state."); + // No GUI error here, because that would've been shown already when _savingDisabled was set. + ModConfigLogger.Warn($"Skipping save for {_modConfigName} because the config file is currently in a corrupted, read-only state."); return; } Dictionary values = []; - foreach (var property in ConfigProperties) + + try { - var value = property.GetValue(null); + foreach (var property in ConfigProperties) + { + var value = property.GetValue(null); - var converter = TypeDescriptor.GetConverter(property.PropertyType); - var stringValue = converter.ConvertToInvariantString(value); + var converter = TypeDescriptor.GetConverter(property.PropertyType); + var stringValue = converter.ConvertToInvariantString(value); - if (stringValue != null) - values.Add(property.Name, stringValue); - else - ModConfigLogger.Warn($"Failed to convert {_modConfigName} property {property.Name} to string for saving; it will be omitted.", true); + if (stringValue != null) + values.Add(property.Name, stringValue); + else + { + ModConfigLogger.Warn( + $"Failed to convert {_modConfigName} property {property.Name} to string for saving; " + + "it will be omitted."); + } + } + } + catch (Exception) + { + // During testing, I have never seen an exception here, but let's avoid a game crash/menu hang, etc. + ModConfigLogger.Error($"Failed to save config {_modConfigName}: unknown error during conversion.", false); } try @@ -187,21 +219,20 @@ public void Load() if (!TryApplyPropertyValue(property, value)) hasSoftErrors = true; } - MainFile.Logger.Info(!hasSoftErrors - ? $"Loaded config {_modConfigName} successfully" - : $"Loaded config {_modConfigName} with some missing or invalid fields."); + if (hasSoftErrors) + ModConfigLogger.Warn($"Loaded config {_modConfigName} with some missing or invalid fields."); } } catch (JsonException jsonEx) { + // Unlikely to happen except for people who have modified the file manually, so let's be verbose and show in GUI. var locationText = jsonEx.LineNumber.HasValue ? $"Line {jsonEx.LineNumber + 1}, position {jsonEx.BytePositionInLine + 1}" : "unknown line"; ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid.\n" + $"File path: {_path}\n" + $"Error location: {locationText}"); - ModConfigLogger.Warn("Config saving has been DISABLED for this session to protect any manual edits. " + - "Please fix the JSON formatting.", true); + ModConfigLogger.Warn("Config saving has been DISABLED for this mod to protect any manual edits.", true); _savingDisabled = true; return; } @@ -229,7 +260,7 @@ private static bool TryApplyPropertyValue(PropertyInfo property, string value) if (configVal == null) { ModConfigLogger.Warn($"Failed to load saved config value \"{value}\" for property {property.Name}:" + - "Converter returned null.", true); + "Converter returned null."); return false; } @@ -244,7 +275,7 @@ private static bool TryApplyPropertyValue(PropertyInfo property, string value) catch (Exception ex) { ModConfigLogger.Warn($"Failed to load saved config value \"{value}\" for property {property.Name}. " + - $"Error: {ex.Message}", true); + $"Error: {ex.Message}"); return false; } } diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs index 89e890d..578750b 100644 --- a/Config/UI/NModConfigSubmenu.cs +++ b/Config/UI/NModConfigSubmenu.cs @@ -213,6 +213,8 @@ public void LoadModConfig(ModConfig config, NConfigButton opener) } catch (Exception e) { + ModConfig.ModConfigLogger.Error("An error occurred while loading the mod config screen." + + "Please report a bug at:\nhttps://github.com/Alchyr/BaseLib-StS2"); MainFile.Logger.Error(e.ToString()); _stack.Pop(); } @@ -228,7 +230,7 @@ private void SetModTitle(ModConfig config) if (locStr == null) { ModConfig.ModConfigLogger.Warn( - $"No {locKey} found in localization table, using mod namespace as title"); + $"No {locKey} found in localization table, using mod namespace {fallbackTitle} as title"); } var titleText = locStr?.GetFormattedText() ?? fallbackTitle; @@ -272,6 +274,13 @@ protected override void OnSubmenuHidden() if (_opener != null) _opener.IsConfigOpen = false; SaveCurrentConfig(); + if (ModConfig.ModConfigLogger.PendingUserMessages.Count > 0) + { + // The main menu will only show this when recreated; if a player goes from settings to play a game, + // that is AFTER finishing the game. We need to show the error now, so let's check here, too. + Callable.From(ModConfig.ShowAndClearPendingErrors).CallDeferred(); + } + base.OnSubmenuHidden(); } From 3d381ae69e1d9b0424bd24f09a36055476e30a2b Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Fri, 20 Mar 2026 22:30:07 +0100 Subject: [PATCH 07/10] Mod config: fix Slider controller support Includes nice things like acceleration when holding, etc. --- Config/UI/NConfigSlider.cs | 136 +++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/Config/UI/NConfigSlider.cs b/Config/UI/NConfigSlider.cs index a133b04..23d3ba8 100644 --- a/Config/UI/NConfigSlider.cs +++ b/Config/UI/NConfigSlider.cs @@ -1,47 +1,75 @@ using System.Reflection; using Godot; using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.CommonUi; using MegaCrit.Sts2.Core.Nodes.GodotExtensions; namespace BaseLib.Config.UI; -// We don't inherit from NSettingsSlider because it's too rigid +// We don't inherit from NSettingsSlider because it's too rigid (forces % in the format, forces step of 5, fixed width) public partial class NConfigSlider : Control { private ModConfig? _config; private PropertyInfo? _property; private string _displayFormat = "{0}"; + private NSlider _slider = null!; + private MegaLabel _sliderLabel = null!; + private NSelectionReticle _selectionReticle = null!; + // _realMin is a workaround to support negative numbers, without forcing // the underlying NSlider to understand that such things really exist - private NSlider _slider = null!; private double _realMin; + private const int LabelFontSize = 28; + + // Controller hold-to-accelerate stuff + private enum HoldDirection { None, Left, Right } + private HoldDirection _holdDir = HoldDirection.None; + private float _holdTimer; + private float _stepTimer; + private float _currentRepeatRate = 0.1f; + private const float InitialDelay = 0.3f; + private const float StartingRepeatRate = 0.1f; + private float _minRepeatDelay = 0.002f; public NConfigSlider() { - SetCustomMinimumSize(new Vector2(320, 64)); + var targetSize = new Vector2(324, 64); + CustomMinimumSize = targetSize; + Size = targetSize; SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; + FocusMode = FocusModeEnum.All; } public override void _Ready() { _slider = GetNode("Slider"); + _sliderLabel = GetNode("SliderValue"); + _selectionReticle = GetNode((NodePath) "SelectionReticle"); - var label = GetNodeOrNull("SliderValue"); - if (label != null) - { - label.AutoSizeEnabled = false; - label.AddThemeFontSizeOverride("font_size", 28); + _slider.FocusMode = FocusModeEnum.None; + var numSteps = (float)((_slider.MaxValue - _slider.MinValue) / _slider.Step); + var dynamicFloor = 1.5f / numSteps; + _minRepeatDelay = Mathf.Max(0.002f, dynamicFloor); - // Right-align the label and let it overflow, so users can use formats more than a few characters wide - label.GrowHorizontal = GrowDirection.Begin; - label.HorizontalAlignment = HorizontalAlignment.Right; - label.ClipContents = false; - } + _sliderLabel.AutoSizeEnabled = false; + _sliderLabel.AddThemeFontSizeOverride("font_size", LabelFontSize); + + // Right-align the label and let it overflow, so users can use formats more than a few characters wide + _sliderLabel.GrowHorizontal = GrowDirection.Begin; + _sliderLabel.HorizontalAlignment = HorizontalAlignment.Right; + _sliderLabel.ClipContents = false; + + _selectionReticle.AnchorRight = 1f; + _selectionReticle.OffsetRight = 10f; SetFromProperty(); _slider.Connect(Godot.Range.SignalName.ValueChanged, Callable.From(OnValueChanged)); + Connect(Godot.Control.SignalName.FocusEntered, Callable.From(OnFocus)); + Connect(Godot.Control.SignalName.FocusExited, Callable.From(OnUnfocus)); } public void Initialize(ModConfig modConfig, PropertyInfo property) @@ -85,7 +113,85 @@ private void OnValueChanged(double proxyValue) private void UpdateLabel(double value) { - var label = GetNodeOrNull("SliderValue"); - label?.SetText(string.Format(_displayFormat, value)); + _sliderLabel.Text = string.Format(_displayFormat, value); + + // Update the reticle; the label size doesn't match the text size, so calculate the correct offset manually + var textWidth = _sliderLabel.GetMinimumSize().X; + var labelRightEdge = _sliderLabel.Position.X + _sliderLabel.Size.X; + var labelLeftEdge = labelRightEdge - textWidth; + _selectionReticle.OffsetLeft = labelLeftEdge - 10f; + } + + private void OnFocus() + { + if (NControllerManager.Instance?.IsUsingController != true) return; + _selectionReticle.OnSelect(); + } + + private void OnUnfocus() + { + _selectionReticle.OnDeselect(); + } + + // The remaining code below is all controller specific, to improve UX by not being forced to tap left/right + // once for every step you want to move the slider. (Technically works with keyboard, too.) + + public override void _GuiInput(InputEvent @event) + { + base._GuiInput(@event); + + if (@event.IsActionPressed(MegaInput.left)) + { + _slider.Value -= _slider.Step; + StartHolding(HoldDirection.Left); + AcceptEvent(); + } + else if (@event.IsActionPressed(MegaInput.right)) + { + _slider.Value += _slider.Step; + StartHolding(HoldDirection.Right); + AcceptEvent(); + } + + else if (@event.IsActionReleased(MegaInput.left) && _holdDir == HoldDirection.Left || + @event.IsActionReleased(MegaInput.right) && _holdDir == HoldDirection.Right) + { + _holdDir = HoldDirection.None; + } + } + + private void StartHolding(HoldDirection dir) + { + _holdDir = dir; + _holdTimer = 0f; + _stepTimer = 0f; + _currentRepeatRate = StartingRepeatRate; + } + + public override void _Process(double delta) + { + base._Process(delta); + + if (_holdDir == HoldDirection.None) return; + if (!HasFocus()) + { + _holdDir = HoldDirection.None; + return; + } + + _holdTimer += (float)delta; + if (_holdTimer < InitialDelay) return; + + _stepTimer += (float)delta; + if (_stepTimer < _currentRepeatRate) return; + + // Time to make a step; accelerate until we reach the limit + _stepTimer = 0f; + _currentRepeatRate = Mathf.Clamp(_currentRepeatRate - 0.01f, _minRepeatDelay, 0.15f); + + if (_holdDir == HoldDirection.Left) + _slider.Value -= _slider.Step; + else + _slider.Value += _slider.Step; } } \ No newline at end of file From d5267b7e84e14bcabd95dd6a785665175a80fe0e Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Sat, 21 Mar 2026 09:47:05 +0100 Subject: [PATCH 08/10] Mod config: dropdown fixes * Focus last selected item on open (game always selects index 0) * Force override focus neighbors to fix issue of left going to a random control; some code is overriding this after we set it up --- Config/UI/NConfigDropdown.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Config/UI/NConfigDropdown.cs b/Config/UI/NConfigDropdown.cs index df3e2cb..c51a249 100644 --- a/Config/UI/NConfigDropdown.cs +++ b/Config/UI/NConfigDropdown.cs @@ -13,6 +13,7 @@ public partial class NConfigDropdown : NSettingsDropdown private List? _items; private int _currentDisplayIndex = -1; private float _lastGlobalY; + private NodePath _selfNodePath = new("."); private static readonly FieldInfo DropdownContainerField = AccessTools.Field(typeof(NDropdown), "_dropdownContainer"); @@ -28,6 +29,13 @@ public override void _Process(double delta) { base._Process(delta); + // Hacky, but this is overwritten and causes issues. Setting it in _Ready and on row creation isn't enough. + if (FocusNeighborLeft != _selfNodePath || FocusNeighborRight != _selfNodePath) + { + FocusNeighborLeft = _selfNodePath; + FocusNeighborRight = _selfNodePath; + } + if (DropdownContainerField.GetValue(this) is Control { Visible: true } container) { // Ensure the list of items follows the dropdown itself when the parent container is scrolled. @@ -73,6 +81,11 @@ public override void _Ready() container.VisibilityChanged += () => { container.TopLevel = container.Visible; container.GlobalPosition = GlobalPosition + new Vector2(0, Size.Y); + + // Focus the last selected entry (base class always selects the first) + if (_currentDisplayIndex < 0 || _currentDisplayIndex >= _items.Count) return; + var entry = _dropdownItems.GetChildOrNull(_currentDisplayIndex); + entry?.TryGrabFocus(); }; } } From 986b6549e42efff56b6859bac286f470117e2657 Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Sat, 21 Mar 2026 10:25:22 +0100 Subject: [PATCH 09/10] Mod config: transfer nodes in constructor --- Config/ModConfig.cs | 13 ++++++------- Config/UI/NConfigDropdown.cs | 3 +++ Config/UI/NConfigSlider.cs | 16 ++++++++++------ Config/UI/NConfigTickbox.cs | 4 ++++ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index df892d3..117c270 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -4,7 +4,6 @@ using System.Text.RegularExpressions; using BaseLib.Config.UI; using BaseLib.Extensions; -using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.addons.mega_text; @@ -286,27 +285,27 @@ protected string GetLabelText(string labelName) return loc != null ? loc.GetFormattedText() : labelName; } - // Creates a raw toggle control, with no layout (see SimpleModConfig.CreateToggleOption unless you want custom layout) + // Creates a raw toggle control, with no layout (use SimpleModConfig.CreateToggleOption unless you want custom layout) protected NConfigTickbox CreateRawTickboxControl(PropertyInfo property) { - var tickbox = new NConfigTickbox().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_tickbox")); + var tickbox = new NConfigTickbox(); tickbox.Initialize(this, property); return tickbox; } - // Creates a raw slider control, with no layout (see SimpleModConfig.CreateSliderOption unless you want custom layout) + // Creates a raw slider control, with no layout (use SimpleModConfig.CreateSliderOption unless you want custom layout) protected NConfigSlider CreateRawSliderControl(PropertyInfo property) { - var slider = new NConfigSlider().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_slider")); + var slider = new NConfigSlider(); slider.Initialize(this, property); return slider; } - // Creates a raw dropdown control, with no layout (see SimpleModConfig.CreateDropdownOption unless you want custom layout) + // Creates a raw dropdown control, with no layout (use SimpleModConfig.CreateDropdownOption unless you want custom layout) private static readonly FieldInfo DropdownNode = AccessTools.DeclaredField(typeof(NDropdownPositioner), "_dropdownNode"); protected NDropdownPositioner CreateRawDropdownControl(PropertyInfo property) { - var dropdown = new NConfigDropdown().TransferAllNodes(SceneHelper.GetScenePath("screens/settings_dropdown")); + var dropdown = new NConfigDropdown(); var items = CreateDropdownItems(property, out var currentIndex); dropdown.SetItems(items, currentIndex); diff --git a/Config/UI/NConfigDropdown.cs b/Config/UI/NConfigDropdown.cs index c51a249..17c9f6b 100644 --- a/Config/UI/NConfigDropdown.cs +++ b/Config/UI/NConfigDropdown.cs @@ -1,4 +1,5 @@ using System.Reflection; +using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Helpers; @@ -23,6 +24,8 @@ public NConfigDropdown() SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; FocusMode = FocusModeEnum.All; + + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_dropdown")); } public override void _Process(double delta) diff --git a/Config/UI/NConfigSlider.cs b/Config/UI/NConfigSlider.cs index 23d3ba8..d4b30e3 100644 --- a/Config/UI/NConfigSlider.cs +++ b/Config/UI/NConfigSlider.cs @@ -1,7 +1,9 @@ using System.Reflection; +using BaseLib.Utils; using Godot; using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.ControllerInput; +using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Nodes.Combat; using MegaCrit.Sts2.Core.Nodes.CommonUi; using MegaCrit.Sts2.Core.Nodes.GodotExtensions; @@ -15,9 +17,9 @@ public partial class NConfigSlider : Control private PropertyInfo? _property; private string _displayFormat = "{0}"; - private NSlider _slider = null!; - private MegaLabel _sliderLabel = null!; - private NSelectionReticle _selectionReticle = null!; + private NSlider _slider; + private MegaLabel _sliderLabel; + private NSelectionReticle _selectionReticle; // _realMin is a workaround to support negative numbers, without forcing // the underlying NSlider to understand that such things really exist @@ -42,14 +44,16 @@ public NConfigSlider() SizeFlagsHorizontal = SizeFlags.ShrinkEnd; SizeFlagsVertical = SizeFlags.Fill; FocusMode = FocusModeEnum.All; - } - public override void _Ready() - { + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_slider")); + _slider = GetNode("Slider"); _sliderLabel = GetNode("SliderValue"); _selectionReticle = GetNode((NodePath) "SelectionReticle"); + } + public override void _Ready() + { _slider.FocusMode = FocusModeEnum.None; var numSteps = (float)((_slider.MaxValue - _slider.MinValue) / _slider.Step); var dynamicFloor = 1.5f / numSteps; diff --git a/Config/UI/NConfigTickbox.cs b/Config/UI/NConfigTickbox.cs index 718ebca..cc2ecb4 100644 --- a/Config/UI/NConfigTickbox.cs +++ b/Config/UI/NConfigTickbox.cs @@ -1,5 +1,7 @@ using System.Reflection; +using BaseLib.Utils; using Godot; +using MegaCrit.Sts2.Core.Helpers; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; namespace BaseLib.Config.UI; @@ -16,6 +18,8 @@ public NConfigTickbox() SizeFlagsVertical = SizeFlags.Fill; FocusMode = FocusModeEnum.All; MouseFilter = MouseFilterEnum.Pass; + + this.TransferAllNodes(SceneHelper.GetScenePath("screens/settings_tickbox")); } public override void _Ready() From 801c72719998c355826f7313cb9477ca70e0ecae Mon Sep 17 00:00:00 2001 From: Aeluwas Date: Sat, 21 Mar 2026 22:08:25 +0100 Subject: [PATCH 10/10] Mod config: fix disposed object crash --- Config/UI/NModConfigSubmenu.cs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Config/UI/NModConfigSubmenu.cs b/Config/UI/NModConfigSubmenu.cs index 578750b..3bbe4e7 100644 --- a/Config/UI/NModConfigSubmenu.cs +++ b/Config/UI/NModConfigSubmenu.cs @@ -1,4 +1,5 @@ using BaseLib.Extensions; +using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.addons.mega_text; @@ -14,20 +15,21 @@ namespace BaseLib.Config.UI; [HarmonyPatch(typeof(NMainMenuSubmenuStack), nameof(NMainMenuSubmenuStack.GetSubmenuType), typeof(Type))] public static class InjectModConfigSubmenuPatch { - private static NModConfigSubmenu? _modConfigSubmenu; + private static readonly SpireField SubmenuField = new(CreateSubmenu); + + private static NModConfigSubmenu CreateSubmenu(NMainMenuSubmenuStack stack) + { + var menu = new NModConfigSubmenu(); + menu.Visible = false; + stack.AddChildSafely(menu); + return menu; + } - [HarmonyPrefix] public static bool Prefix(NMainMenuSubmenuStack __instance, Type type, ref NSubmenu __result) { if (type != typeof(NModConfigSubmenu)) return true; - if (_modConfigSubmenu == null) - { - _modConfigSubmenu = new NModConfigSubmenu(); - _modConfigSubmenu.Visible = false; - __instance.AddChildSafely(_modConfigSubmenu); - } - __result = _modConfigSubmenu; + __result = SubmenuField.Get(__instance)!; return false; } }