diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3030c1a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,30 @@
+# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
+* text=auto eol=lf
+
+# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Binaries: never converted, never diffed as text.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.webp binary
+*.svg text
+*.ogg binary
+*.wav binary
+*.mp3 binary
+*.ttf binary
+*.otf binary
+*.woff binary
+*.woff2 binary
+*.dll binary
+*.so binary
+*.dylib binary
+*.pdb binary
+*.exe binary
+*.zip binary
+*.pck binary
+*.moc3 binary
+*.res binary
+*.scn binary
diff --git a/.gitignore b/.gitignore
index bb88804..c301ca8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,37 +1,37 @@
-# Godot 4.x
-.godot/
-.idea/
-sdk/
-bin/
-obj/
-
-#ai
-.omo/
-.opencode/
-
-gen/
-obj/
-bin/
-
-*.tres~
-*.tscn~
-
-# GDScript LSP
-.godot/
-
-# Export
-*.exe
-*.dmg
-*.apk
-*.aab
-*.pck
-*.zip
-*.log
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Editor
-*.import
-*.godot.uid
+# Godot 4.x
+.godot/
+.idea/
+sdk/
+bin/
+obj/
+
+#ai
+.omo/
+.opencode/
+
+gen/
+obj/
+bin/
+
+*.tres~
+*.tscn~
+
+# GDScript LSP
+.godot/
+
+# Export
+*.exe
+*.dmg
+*.apk
+*.aab
+*.pck
+*.zip
+*.log
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Editor
+*.import
+*.godot.uid
diff --git a/src/Base/InputHandlerNode.cs b/src/Base/InputHandlerNode.cs
index 2e1d11c..1c6efd4 100644
--- a/src/Base/InputHandlerNode.cs
+++ b/src/Base/InputHandlerNode.cs
@@ -1,21 +1,21 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base
-{
- ///
- /// Forwarder pattern for Godot input: proxies _Input to a virtual OnInput
- /// so derived classes can handle input events without needing to know about
- /// Godot's _Input override mechanics.
- ///
- public partial class InputHandlerNode : Node
- {
- /// Engine entry point: forwards the input event to .
- public override void _Input(InputEvent @event)
- {
- OnInput(@event);
- }
-
- /// Override in derived classes to receive Godot input events. Called by which proxies from the engine.
- protected virtual void OnInput(InputEvent @event) { }
- }
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base
+{
+ ///
+ /// Forwarder pattern for Godot input: proxies _Input to a virtual OnInput
+ /// so derived classes can handle input events without needing to know about
+ /// Godot's _Input override mechanics.
+ ///
+ public partial class InputHandlerNode : Node
+ {
+ /// Engine entry point: forwards the input event to .
+ public override void _Input(InputEvent @event)
+ {
+ OnInput(@event);
+ }
+
+ /// Override in derived classes to receive Godot input events. Called by which proxies from the engine.
+ protected virtual void OnInput(InputEvent @event) { }
+ }
+}
diff --git a/src/Base/Lists/Border.cs b/src/Base/Lists/Border.cs
index f33573d..61ca9fc 100644
--- a/src/Base/Lists/Border.cs
+++ b/src/Base/Lists/Border.cs
@@ -1,19 +1,19 @@
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Serializable margin/padding struct for list layout calculations.
- /// Marked [System.Serializable] for potential JSON serialization in mod configs.
- ///
- [System.Serializable]
- public struct Border
- {
- /// Left margin or padding, in pixels.
- public float left;
- /// Right margin or padding, in pixels.
- public float right;
- /// Top margin or padding, in pixels.
- public float top;
- /// Bottom margin or padding, in pixels.
- public float bottom;
- }
-}
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Serializable margin/padding struct for list layout calculations.
+ /// Marked [System.Serializable] for potential JSON serialization in mod configs.
+ ///
+ [System.Serializable]
+ public struct Border
+ {
+ /// Left margin or padding, in pixels.
+ public float left;
+ /// Right margin or padding, in pixels.
+ public float right;
+ /// Top margin or padding, in pixels.
+ public float top;
+ /// Bottom margin or padding, in pixels.
+ public float bottom;
+ }
+}
diff --git a/src/Base/Lists/ICellListWidget.cs b/src/Base/Lists/ICellListWidget.cs
index 17bdb02..c0bd2ef 100644
--- a/src/Base/Lists/ICellListWidget.cs
+++ b/src/Base/Lists/ICellListWidget.cs
@@ -1,22 +1,22 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Extends IListWidget with grid/cell dimensions. CellSize drives item sizing;
- /// ViewCellsCount determines how many cells fit in the visible area (used for
- /// scroll/viewport calculations).
- ///
- public interface ICellListWidget : IListWidget
- where TItem : class, IListItem
- {
-
- /// Pixel dimensions of each cell in the grid. Drives item sizing; derived from content width and aspect ratio.
- Vector2 CellSize { get; }
-
- /// Number of cells visible in the viewport (columns × rows). Used for scroll range and visibility calculations.
- Vector2I ViewCellsCount { get; }
-
- }
-
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Extends IListWidget with grid/cell dimensions. CellSize drives item sizing;
+ /// ViewCellsCount determines how many cells fit in the visible area (used for
+ /// scroll/viewport calculations).
+ ///
+ public interface ICellListWidget : IListWidget
+ where TItem : class, IListItem
+ {
+
+ /// Pixel dimensions of each cell in the grid. Drives item sizing; derived from content width and aspect ratio.
+ Vector2 CellSize { get; }
+
+ /// Number of cells visible in the viewport (columns × rows). Used for scroll range and visibility calculations.
+ Vector2I ViewCellsCount { get; }
+
+ }
+
+}
diff --git a/src/Base/Lists/IListItem.cs b/src/Base/Lists/IListItem.cs
index 663855b..c6c116b 100644
--- a/src/Base/Lists/IListItem.cs
+++ b/src/Base/Lists/IListItem.cs
@@ -1,27 +1,27 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Contract for a single item in a list widget. Construct binds a model;
- /// Destruct tears down (typically QueueFree). The Rect getter exposes the
- /// item's Control for layout positioning by the parent list.
- ///
- public interface IListItem
- {
-
- /// The Control that this item occupies. Used by the parent list to position the item in layout.
- Control Rect { get; }
-
- /// The bound data model. Set via .
- TModel Model { get; }
-
- /// Binds the item to a data model. Called by the list widget after instantiation, before layout positioning.
- void Construct(TModel model);
-
- /// Cleans up the item — typically disconnects signals and calls QueueFree. Called when the list is destroyed or refreshed.
- void Destruct();
-
- }
-
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Contract for a single item in a list widget. Construct binds a model;
+ /// Destruct tears down (typically QueueFree). The Rect getter exposes the
+ /// item's Control for layout positioning by the parent list.
+ ///
+ public interface IListItem
+ {
+
+ /// The Control that this item occupies. Used by the parent list to position the item in layout.
+ Control Rect { get; }
+
+ /// The bound data model. Set via .
+ TModel Model { get; }
+
+ /// Binds the item to a data model. Called by the list widget after instantiation, before layout positioning.
+ void Construct(TModel model);
+
+ /// Cleans up the item — typically disconnects signals and calls QueueFree. Called when the list is destroyed or refreshed.
+ void Destruct();
+
+ }
+
+}
diff --git a/src/Base/Lists/IListWidget.cs b/src/Base/Lists/IListWidget.cs
index da186e0..7cd0391 100644
--- a/src/Base/Lists/IListWidget.cs
+++ b/src/Base/Lists/IListWidget.cs
@@ -1,45 +1,45 @@
-using System.Collections.Generic;
-using Godot;
-
+using System.Collections.Generic;
+using Godot;
+
using Cthangover.Core.UI;
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Generic list widget contract: binds a collection of models to Item widgets.
- /// The two-step CreateModels() → CreateContent() pattern separates data sourcing
- /// from instantiation, so subclasses can pull models from anywhere (runtime state,
- /// config, save data) while the framework handles layout. ConstructUI/DestructUI
- /// manage the full lifecycle of child items.
- ///
- public interface IListWidget : IWidget
- where TItem : class, IListItem
- {
-
- /// Padding between items in pixels, stored as Vector2(X, Y). The X component is horizontal gap, Y is vertical gap.
- Vector2 ItemsCellPadding { get; }
-
- /// The container Control that holds all child item nodes. Items are added as children of this node.
- Control Content { get; }
-
- /// The collection of instantiated item widgets. Populated by and cleared by .
- List Items { get; set; }
-
- /// Implementations return the collection of data models to display. Called once per cycle.
- ICollection CreateModels();
-
- /// Instantiates item widgets from the model collection. Separates model sourcing from instantiation so subclasses control both steps.
- List CreateContent(ICollection models);
-
- /// Calculates the total content size in pixels for items, used for scroll container sizing.
- Vector2 GetContentSize(int count);
-
- /// Builds the full item tree: creates models, instantiates items, and positions them via layout.
- void ConstructUI();
-
- /// Tears down all child items by calling each item's and clearing the Items list.
- void DestructUI();
-
- }
-
-}
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Generic list widget contract: binds a collection of models to Item widgets.
+ /// The two-step CreateModels() → CreateContent() pattern separates data sourcing
+ /// from instantiation, so subclasses can pull models from anywhere (runtime state,
+ /// config, save data) while the framework handles layout. ConstructUI/DestructUI
+ /// manage the full lifecycle of child items.
+ ///
+ public interface IListWidget : IWidget
+ where TItem : class, IListItem
+ {
+
+ /// Padding between items in pixels, stored as Vector2(X, Y). The X component is horizontal gap, Y is vertical gap.
+ Vector2 ItemsCellPadding { get; }
+
+ /// The container Control that holds all child item nodes. Items are added as children of this node.
+ Control Content { get; }
+
+ /// The collection of instantiated item widgets. Populated by and cleared by .
+ List Items { get; set; }
+
+ /// Implementations return the collection of data models to display. Called once per cycle.
+ ICollection CreateModels();
+
+ /// Instantiates item widgets from the model collection. Separates model sourcing from instantiation so subclasses control both steps.
+ List CreateContent(ICollection models);
+
+ /// Calculates the total content size in pixels for items, used for scroll container sizing.
+ Vector2 GetContentSize(int count);
+
+ /// Builds the full item tree: creates models, instantiates items, and positions them via layout.
+ void ConstructUI();
+
+ /// Tears down all child items by calling each item's and clearing the Items list.
+ void DestructUI();
+
+ }
+
+}
diff --git a/src/Base/Lists/Impls/ColumnCellListWidget.cs b/src/Base/Lists/Impls/ColumnCellListWidget.cs
index df02c37..a6953a9 100644
--- a/src/Base/Lists/Impls/ColumnCellListWidget.cs
+++ b/src/Base/Lists/Impls/ColumnCellListWidget.cs
@@ -1,93 +1,93 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists.Impls
-{
- ///
- /// Grid-based list layout with configurable column count, aspect-ratio cells,
- /// and border/padding offsets. Cell dimensions are computed dynamically from
- /// the content area width, so they scale with the parent. GetContentSize
- /// calculates the total scrollable height from item count and cell geometry,
- /// enabling vertical scrolling through a grid. PutToLayout positions items
- /// by flat-index-to-grid conversion. The isScaledCanvas flag supports
- /// resolution-independent layouts.
- ///
- public abstract partial class ColumnCellListWidget : ListWidget, ICellListWidget
- where TItem : Control, IListItem
- {
- [Export] private int columnCount;
- [Export] private float aspectHeight = 1f;
- [Export] private float borderLeft;
- [Export] private float borderRight;
- [Export] private float borderTop;
- [Export] private float borderBottom;
- [Export] private bool isScaledCanvas = true;
-
- private Border contentBorder => new() { left = borderLeft, right = borderRight, top = borderTop, bottom = borderBottom };
-
- /// Dynamically computed cell dimensions in pixels. Width = (content width - borders - padding * (columns - 1)) / columns; height = width * aspectHeight.
- public Vector2 CellSize => GetCellSize(Content.Size);
-
- private Vector2 GetCellSize(Vector2 contentSize)
- {
- var size = contentSize;
- var effectiveWidth = size.X - contentBorder.left - contentBorder.right;
- var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
- var cellHeight = cellWidth * aspectHeight;
- return new Vector2(cellWidth, cellHeight);
- }
-
- /// Number of cells visible in the viewport: X = columnCount, Y = floor((content height - borders) / (cell height + padding)).
- public Vector2I ViewCellsCount
- {
- get
- {
- var size = Content.Size;
- return new Vector2I(
- columnCount,
- Mathf.FloorToInt((size.Y - contentBorder.top - contentBorder.bottom) / (CellSize.Y + ItemsCellPadding.Y))
- );
- }
- }
-
- /// Calculates total scrollable content height: top border + bottom border + rows * cellHeight + (rows - 1) * padding.
- public override Vector2 GetContentSize(int count)
- {
- if (count == 0) return Vector2.Zero;
-
- var rect = Content.Size;
- var effectiveWidth = rect.X - contentBorder.left - contentBorder.right;
- var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
- var cellHeight = cellWidth * aspectHeight;
-
- var rows = Mathf.CeilToInt((float)count / columnCount);
- var contentHeight = contentBorder.top + contentBorder.bottom + rows * cellHeight + (rows - 1) * ItemsCellPadding.Y;
- var contentWidth = rect.X;
-
- return new Vector2(contentWidth, contentHeight);
- }
-
- protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
- {
- var rectTransform = item;
- var cellIndex = GetIndex(index);
- var cellSize = GetCellSize(contentSize);
-
- if (rectTransform.GetParent() != container)
- rectTransform.Reparent(container, false);
- rectTransform.Position = new Vector2(
- contentBorder.left + cellIndex.X * (cellSize.X + ItemsCellPadding.X),
- contentBorder.top + cellIndex.Y * (cellSize.Y + ItemsCellPadding.Y)
- );
- rectTransform.Size = cellSize;
- }
-
- private Vector2I GetIndex(int flatIndex)
- {
- var count = ViewCellsCount;
- return new Vector2I(
- flatIndex % count.X,
- flatIndex / count.X
- );
- }
- }
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists.Impls
+{
+ ///
+ /// Grid-based list layout with configurable column count, aspect-ratio cells,
+ /// and border/padding offsets. Cell dimensions are computed dynamically from
+ /// the content area width, so they scale with the parent. GetContentSize
+ /// calculates the total scrollable height from item count and cell geometry,
+ /// enabling vertical scrolling through a grid. PutToLayout positions items
+ /// by flat-index-to-grid conversion. The isScaledCanvas flag supports
+ /// resolution-independent layouts.
+ ///
+ public abstract partial class ColumnCellListWidget : ListWidget, ICellListWidget
+ where TItem : Control, IListItem
+ {
+ [Export] private int columnCount;
+ [Export] private float aspectHeight = 1f;
+ [Export] private float borderLeft;
+ [Export] private float borderRight;
+ [Export] private float borderTop;
+ [Export] private float borderBottom;
+ [Export] private bool isScaledCanvas = true;
+
+ private Border contentBorder => new() { left = borderLeft, right = borderRight, top = borderTop, bottom = borderBottom };
+
+ /// Dynamically computed cell dimensions in pixels. Width = (content width - borders - padding * (columns - 1)) / columns; height = width * aspectHeight.
+ public Vector2 CellSize => GetCellSize(Content.Size);
+
+ private Vector2 GetCellSize(Vector2 contentSize)
+ {
+ var size = contentSize;
+ var effectiveWidth = size.X - contentBorder.left - contentBorder.right;
+ var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
+ var cellHeight = cellWidth * aspectHeight;
+ return new Vector2(cellWidth, cellHeight);
+ }
+
+ /// Number of cells visible in the viewport: X = columnCount, Y = floor((content height - borders) / (cell height + padding)).
+ public Vector2I ViewCellsCount
+ {
+ get
+ {
+ var size = Content.Size;
+ return new Vector2I(
+ columnCount,
+ Mathf.FloorToInt((size.Y - contentBorder.top - contentBorder.bottom) / (CellSize.Y + ItemsCellPadding.Y))
+ );
+ }
+ }
+
+ /// Calculates total scrollable content height: top border + bottom border + rows * cellHeight + (rows - 1) * padding.
+ public override Vector2 GetContentSize(int count)
+ {
+ if (count == 0) return Vector2.Zero;
+
+ var rect = Content.Size;
+ var effectiveWidth = rect.X - contentBorder.left - contentBorder.right;
+ var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
+ var cellHeight = cellWidth * aspectHeight;
+
+ var rows = Mathf.CeilToInt((float)count / columnCount);
+ var contentHeight = contentBorder.top + contentBorder.bottom + rows * cellHeight + (rows - 1) * ItemsCellPadding.Y;
+ var contentWidth = rect.X;
+
+ return new Vector2(contentWidth, contentHeight);
+ }
+
+ protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
+ {
+ var rectTransform = item;
+ var cellIndex = GetIndex(index);
+ var cellSize = GetCellSize(contentSize);
+
+ if (rectTransform.GetParent() != container)
+ rectTransform.Reparent(container, false);
+ rectTransform.Position = new Vector2(
+ contentBorder.left + cellIndex.X * (cellSize.X + ItemsCellPadding.X),
+ contentBorder.top + cellIndex.Y * (cellSize.Y + ItemsCellPadding.Y)
+ );
+ rectTransform.Size = cellSize;
+ }
+
+ private Vector2I GetIndex(int flatIndex)
+ {
+ var count = ViewCellsCount;
+ return new Vector2I(
+ flatIndex % count.X,
+ flatIndex / count.X
+ );
+ }
+ }
+}
diff --git a/src/Base/Lists/Impls/VerticalListWidget.cs b/src/Base/Lists/Impls/VerticalListWidget.cs
index af27275..330415c 100644
--- a/src/Base/Lists/Impls/VerticalListWidget.cs
+++ b/src/Base/Lists/Impls/VerticalListWidget.cs
@@ -1,33 +1,33 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists.Impls
-{
- ///
- /// Simple vertical stack layout. Items are placed top-to-bottom with padding
- /// between them, offset so the list grows upward from the content center.
- /// Content height is the sum of all item heights plus padding — no pooling
- /// or virtualization, so it's intended for short lists.
- ///
- public abstract partial class VerticalListWidget : ListWidget
- where TItem : Control, IListItem
- {
-
- protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
- {
- var posY = index * (PrefabRect.Size.Y + ItemsCellPadding.Y);
- var itemRect = item;
- var pos = new Vector2(itemRect.Position.X, -posY + contentSize.Y * 0.5f);
- itemRect.Position = pos;
- }
-
- /// Content height = count * (prefab height + Y padding). Width is inherited from the Content container.
- public override Vector2 GetContentSize(int count)
- {
- var boxSize = Content.Size;
- boxSize.Y = count * (PrefabRect.Size.Y + ItemsCellPadding.Y);
- return boxSize;
- }
-
- }
-
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists.Impls
+{
+ ///
+ /// Simple vertical stack layout. Items are placed top-to-bottom with padding
+ /// between them, offset so the list grows upward from the content center.
+ /// Content height is the sum of all item heights plus padding — no pooling
+ /// or virtualization, so it's intended for short lists.
+ ///
+ public abstract partial class VerticalListWidget : ListWidget
+ where TItem : Control, IListItem
+ {
+
+ protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
+ {
+ var posY = index * (PrefabRect.Size.Y + ItemsCellPadding.Y);
+ var itemRect = item;
+ var pos = new Vector2(itemRect.Position.X, -posY + contentSize.Y * 0.5f);
+ itemRect.Position = pos;
+ }
+
+ /// Content height = count * (prefab height + Y padding). Width is inherited from the Content container.
+ public override Vector2 GetContentSize(int count)
+ {
+ var boxSize = Content.Size;
+ boxSize.Y = count * (PrefabRect.Size.Y + ItemsCellPadding.Y);
+ return boxSize;
+ }
+
+ }
+
+}
diff --git a/src/Base/Lists/ListItem.cs b/src/Base/Lists/ListItem.cs
index a8942d7..141163d 100644
--- a/src/Base/Lists/ListItem.cs
+++ b/src/Base/Lists/ListItem.cs
@@ -1,35 +1,35 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Base list item: stores the bound model and provides a Rect for layout.
- /// The virtual Construct saves the model reference; Destruct is abstract so
- /// subclasses must explicitly implement cleanup (signal disconnection, QueueFree).
- ///
- public abstract partial class ListItem : Control, IListItem
- {
-
- /// The data model bound to this list item. Set by .
- public TModel Model { get; private set; }
- /// The Control that this item occupies for layout. Returns itself — ListItem is its own layout root.
- public Control Rect => this;
-
- /// Saves the model reference. Override in subclasses to apply model data to UI elements.
- public virtual void Construct(TModel model)
- {
- Model = model;
- }
-
- /// Required cleanup method. Subclasses must implement — typically disconnects signals and calls QueueFree.
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Base list item: stores the bound model and provides a Rect for layout.
+ /// The virtual Construct saves the model reference; Destruct is abstract so
+ /// subclasses must explicitly implement cleanup (signal disconnection, QueueFree).
+ ///
+ public abstract partial class ListItem : Control, IListItem
+ {
+
+ /// The data model bound to this list item. Set by .
+ public TModel Model { get; private set; }
+ /// The Control that this item occupies for layout. Returns itself — ListItem is its own layout root.
+ public Control Rect => this;
+
+ /// Saves the model reference. Override in subclasses to apply model data to UI elements.
+ public virtual void Construct(TModel model)
+ {
+ Model = model;
+ }
+
+ /// Required cleanup method. Subclasses must implement — typically disconnects signals and calls QueueFree.
public abstract void Destruct();
/// Engine callback; forwards to so mod subclasses receive the ready signal without their own engine bindings.
public override void _Ready() => OnReady();
/// Forwarded from . Override for mod item initialization.
- protected virtual void OnReady() { }
-
- }
-
-}
+ protected virtual void OnReady() { }
+
+ }
+
+}
diff --git a/src/Base/Lists/ListWidget.cs b/src/Base/Lists/ListWidget.cs
index b8a865d..e113c0b 100644
--- a/src/Base/Lists/ListWidget.cs
+++ b/src/Base/Lists/ListWidget.cs
@@ -1,134 +1,134 @@
-using System.Collections.Generic;
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Abstract model-driven list: instantiates items from a PackedScene prefab
- /// for each model in CreateModels(), then delegates positioning to the abstract
- /// PutToLayout (defined by layout subclasses like ColumnCellListWidget or
- /// VerticalListWidget). Refresh() tears down and rebuilds the list in place
- /// for reactive updates. The virtual Create/Init hooks let subclasses inject
- /// extra setup between instantiation and model binding without overriding
- /// CreateContent.
- ///
- public abstract partial class ListWidget : Widget, IListWidget
- where T : Control, IListItem
- {
-
- [Export]
- private Control content;
-
- [Export]
- private PackedScene itemScene;
-
- [Export] private Vector2 itemsCellPadding = new(0f, 1f);
-
- /// Editor-configured padding between items; exposed for layout size math.
- public Vector2 ItemsCellPadding => itemsCellPadding;
-
- /// Prefab instance for layout measurement. Instantiated on each access — not cached. Returns null if itemScene is not set.
- protected T Prefab => itemScene?.Instantiate();
- /// The Control rect of the item prefab, used for layout size calculations.
- protected Control PrefabRect => Prefab;
-
- /// The container Control that holds child items. Set via [Export] in the Godot editor.
- public Control Content => content;
-
- /// Calculates the total content size for a given item count. Subclass-specific — different layouts have different formulas.
- public abstract Vector2 GetContentSize(int count);
-
- /// The instantiated item collection. Set by and cleared by .
- public List Items { get; set; }
-
- /// Shows the widget and rebuilds the UI tree via .
- public override void Show()
- {
- base.Show();
- ConstructUI();
- }
-
- /// Tears down child items via then hides via base .
- public override void Hide()
- {
- DestructUI();
- base.Hide();
- }
-
- /// Destructs and reconstructs the item tree in place. Use for reactive updates when the model collection changes.
- public void Refresh()
- {
- if (!Visible)
- return;
-
- DestructUI();
- ConstructUI();
- }
-
- /// Iterates and calls for each, building the item widget collection.
- public List CreateContent(ICollection models)
- {
- var items = new List();
-
- foreach (var model in models)
- {
- var item = Create(model);
- item.Construct(model);
- items.Add(item);
- }
-
- return items;
- }
-
- ///
- /// Full build pipeline: gets models via , instantiates items via ,
- /// sets the content container size via to size, then positions each item via .
- ///
- public void ConstructUI()
- {
- var rect = Content;
- var models = CreateModels();
-
- var items = CreateContent(models);
- var size = GetContentSize(items.Count);
- rect.CallDeferred("set_size", size);
-
- for (int i = 0; i < items.Count; i++)
- PutToLayout(items[i], i, rect, size);
-
- Items = items;
- }
-
- /// Subclasses implement positioning logic. Called for each item with its flat index, the content container, and total content size.
- protected abstract void PutToLayout(T item, int index, Control container, Vector2 contentSize);
-
- /// Subclasses implement model sourcing. Called by before item instantiation.
- public abstract ICollection CreateModels();
-
- /// Hook for subclasses to inject setup between instantiation and model binding. Called by .
- protected virtual void Init(T item, M model) { }
-
- /// Instantiates the PackedScene prefab, adds it to , and calls for subclass setup.
- protected virtual T Create(M model)
- {
- var instance = itemScene.Instantiate();
- Content.AddChild(instance);
- Init(instance, model);
- return instance;
- }
-
- /// Calls on each child item, clears the list, and sets Items to null.
- public void DestructUI()
- {
- if (Items == null || Items.Count == 0)
- return;
-
- foreach (var item in Items)
- item.Destruct();
-
- Items.Clear();
- Items = null;
- }
-
- }
-}
+using System.Collections.Generic;
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Abstract model-driven list: instantiates items from a PackedScene prefab
+ /// for each model in CreateModels(), then delegates positioning to the abstract
+ /// PutToLayout (defined by layout subclasses like ColumnCellListWidget or
+ /// VerticalListWidget). Refresh() tears down and rebuilds the list in place
+ /// for reactive updates. The virtual Create/Init hooks let subclasses inject
+ /// extra setup between instantiation and model binding without overriding
+ /// CreateContent.
+ ///
+ public abstract partial class ListWidget : Widget, IListWidget
+ where T : Control, IListItem
+ {
+
+ [Export]
+ private Control content;
+
+ [Export]
+ private PackedScene itemScene;
+
+ [Export] private Vector2 itemsCellPadding = new(0f, 1f);
+
+ /// Editor-configured padding between items; exposed for layout size math.
+ public Vector2 ItemsCellPadding => itemsCellPadding;
+
+ /// Prefab instance for layout measurement. Instantiated on each access — not cached. Returns null if itemScene is not set.
+ protected T Prefab => itemScene?.Instantiate();
+ /// The Control rect of the item prefab, used for layout size calculations.
+ protected Control PrefabRect => Prefab;
+
+ /// The container Control that holds child items. Set via [Export] in the Godot editor.
+ public Control Content => content;
+
+ /// Calculates the total content size for a given item count. Subclass-specific — different layouts have different formulas.
+ public abstract Vector2 GetContentSize(int count);
+
+ /// The instantiated item collection. Set by and cleared by .
+ public List Items { get; set; }
+
+ /// Shows the widget and rebuilds the UI tree via .
+ public override void Show()
+ {
+ base.Show();
+ ConstructUI();
+ }
+
+ /// Tears down child items via then hides via base .
+ public override void Hide()
+ {
+ DestructUI();
+ base.Hide();
+ }
+
+ /// Destructs and reconstructs the item tree in place. Use for reactive updates when the model collection changes.
+ public void Refresh()
+ {
+ if (!Visible)
+ return;
+
+ DestructUI();
+ ConstructUI();
+ }
+
+ /// Iterates and calls for each, building the item widget collection.
+ public List CreateContent(ICollection models)
+ {
+ var items = new List();
+
+ foreach (var model in models)
+ {
+ var item = Create(model);
+ item.Construct(model);
+ items.Add(item);
+ }
+
+ return items;
+ }
+
+ ///
+ /// Full build pipeline: gets models via , instantiates items via ,
+ /// sets the content container size via to size, then positions each item via .
+ ///
+ public void ConstructUI()
+ {
+ var rect = Content;
+ var models = CreateModels();
+
+ var items = CreateContent(models);
+ var size = GetContentSize(items.Count);
+ rect.CallDeferred("set_size", size);
+
+ for (int i = 0; i < items.Count; i++)
+ PutToLayout(items[i], i, rect, size);
+
+ Items = items;
+ }
+
+ /// Subclasses implement positioning logic. Called for each item with its flat index, the content container, and total content size.
+ protected abstract void PutToLayout(T item, int index, Control container, Vector2 contentSize);
+
+ /// Subclasses implement model sourcing. Called by before item instantiation.
+ public abstract ICollection CreateModels();
+
+ /// Hook for subclasses to inject setup between instantiation and model binding. Called by .
+ protected virtual void Init(T item, M model) { }
+
+ /// Instantiates the PackedScene prefab, adds it to , and calls for subclass setup.
+ protected virtual T Create(M model)
+ {
+ var instance = itemScene.Instantiate();
+ Content.AddChild(instance);
+ Init(instance, model);
+ return instance;
+ }
+
+ /// Calls on each child item, clears the list, and sets Items to null.
+ public void DestructUI()
+ {
+ if (Items == null || Items.Count == 0)
+ return;
+
+ foreach (var item in Items)
+ item.Destruct();
+
+ Items.Clear();
+ Items = null;
+ }
+
+ }
+}
diff --git a/src/Base/Lists/ModWidget.cs b/src/Base/Lists/ModWidget.cs
index 0e9dcb3..5fe4f2b 100644
--- a/src/Base/Lists/ModWidget.cs
+++ b/src/Base/Lists/ModWidget.cs
@@ -1,38 +1,38 @@
-using Godot;
-
+using Godot;
+
using Cthangover.Core.UI;
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Lightweight alternative to Widget for mod-authored controls. Has a simpler
- /// lifecycle — just Construct() on first access and Destruct() on tree exit —
- /// without the visibility interception and Show/Hide pipeline that Widget
- /// carries. Use when the heavy Widget state machine would be overkill or
- /// when the control's visibility is managed by Godot's native system.
- ///
- public abstract partial class ModWidget : Control, IUiLazyConstruct
- {
- private bool _constructed;
-
- /// Idempotent construction gate: calls exactly once, on the first invocation. Thread-safe for repeated external calls.
- public void EnsureConstructed()
- {
- if (_constructed)
- return;
- _constructed = true;
-
- Construct();
- }
-
- /// Runs once when the constructed control leaves the tree.
- public override void _ExitTree()
- {
- if (_constructed)
- Destruct();
- }
-
- protected abstract void Construct();
- protected virtual void Destruct() { }
- }
-}
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Lightweight alternative to Widget for mod-authored controls. Has a simpler
+ /// lifecycle — just Construct() on first access and Destruct() on tree exit —
+ /// without the visibility interception and Show/Hide pipeline that Widget
+ /// carries. Use when the heavy Widget state machine would be overkill or
+ /// when the control's visibility is managed by Godot's native system.
+ ///
+ public abstract partial class ModWidget : Control, IUiLazyConstruct
+ {
+ private bool _constructed;
+
+ /// Idempotent construction gate: calls exactly once, on the first invocation. Thread-safe for repeated external calls.
+ public void EnsureConstructed()
+ {
+ if (_constructed)
+ return;
+ _constructed = true;
+
+ Construct();
+ }
+
+ /// Runs once when the constructed control leaves the tree.
+ public override void _ExitTree()
+ {
+ if (_constructed)
+ Destruct();
+ }
+
+ protected abstract void Construct();
+ protected virtual void Destruct() { }
+ }
+}
diff --git a/src/Base/Lists/TransitionWidget.cs b/src/Base/Lists/TransitionWidget.cs
index 9195493..926bfe6 100644
--- a/src/Base/Lists/TransitionWidget.cs
+++ b/src/Base/Lists/TransitionWidget.cs
@@ -1,128 +1,128 @@
-using Cthangover.Core.Mods;
-using Cthangover.Core.Mods.Resolvers;
-using Cthangover.Core.UI.Theming;
-using Godot;
-
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Shader-based scene transition: animates a "progress" uniform on the
- /// background's ShaderMaterial via a Godot Tween, then reveals content after
- /// a configurable delay. The shader is resolved through the mod system
- /// (ModManager.ResolveShader), allowing mods to replace the transition effect.
- /// Uses an easeInOut curve for the progress ramp. Material is duplicated on
- /// first use so each instance gets its own shader parameters. On tree exit,
- /// the duplicated material is disposed to avoid leaks.
- ///
- public abstract partial class TransitionWidget : Widget
- {
- private static readonly StringName ProgressParam = "progress";
-
- [Export] private TextureRect imgBackground;
- [Export] private Control contentGroup;
- [Export] private float transitionDuration = 0.8f;
- [Export] private float contentDelay = 0.05f;
-
- private ShaderMaterial materialInstance;
- private Tween transitionTween;
-
- ///
- /// Shows the widget base, then creates or reuses a shader material for the background and starts the transition tween.
- /// Material is duplicated from imgBackground.Material on first call so each instance gets independent shader parameters.
- ///
- public override void Show()
- {
- base.Show();
-
- if (materialInstance == null && imgBackground != null)
- {
- materialInstance = imgBackground.Material?.Duplicate() as ShaderMaterial;
- if (materialInstance == null)
- {
- materialInstance = new ShaderMaterial();
- var shader = Shaders.Resolve("scene_transition");
- if (shader != null)
- materialInstance.Shader = shader;
- }
- imgBackground.Material = materialInstance;
- materialInstance.SetShaderParameter(ProgressParam, 0f);
- }
-
- if (transitionTween != null)
- {
- transitionTween.Kill();
- transitionTween = null;
- }
-
- RunShowAnimation();
- }
-
- /// Kills the active transition tween if one is running, then calls base .
- public override void Hide()
- {
- if (transitionTween != null)
- {
- transitionTween.Kill();
- transitionTween = null;
- }
- base.Hide();
- }
-
- private void RunShowAnimation()
- {
- if (contentGroup != null)
- {
- contentGroup.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
- contentGroup.MouseFilter = MouseFilterEnum.Ignore;
- }
-
- transitionTween = CreateTween();
- transitionTween.SetProcessMode(Tween.TweenProcessMode.Physics);
- transitionTween.TweenMethod(Callable.From(OnProgressUpdate), 0f, 1f, transitionDuration);
- transitionTween.Finished += OnTweenFinished;
- }
-
- private void OnProgressUpdate(float t)
- {
- float curvedT = EaseInOut(t);
- if (imgBackground?.Material is ShaderMaterial mat)
- mat.SetShaderParameter(ProgressParam, curvedT);
- }
-
- private void OnTweenFinished()
- {
- if (imgBackground?.Material is ShaderMaterial mat)
- mat.SetShaderParameter(ProgressParam, 1f);
-
- var timer = GetTree().CreateTimer(contentDelay);
- timer.Timeout += () =>
- {
- if (contentGroup != null)
- {
- contentGroup.Modulate = UiTokens.White;
- contentGroup.MouseFilter = MouseFilterEnum.Stop;
- }
- };
-
- transitionTween = null;
- }
-
- private static float EaseInOut(float t)
- {
- return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
- }
-
- /// Disposes the per-instance shader material created for the transition.
- public override void _ExitTree()
- {
- if (materialInstance != null)
- {
- materialInstance.Dispose();
- materialInstance = null;
- }
- base._ExitTree();
- }
- }
-}
-
-
+using Cthangover.Core.Mods;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.UI.Theming;
+using Godot;
+
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Shader-based scene transition: animates a "progress" uniform on the
+ /// background's ShaderMaterial via a Godot Tween, then reveals content after
+ /// a configurable delay. The shader is resolved through the mod system
+ /// (ModManager.ResolveShader), allowing mods to replace the transition effect.
+ /// Uses an easeInOut curve for the progress ramp. Material is duplicated on
+ /// first use so each instance gets its own shader parameters. On tree exit,
+ /// the duplicated material is disposed to avoid leaks.
+ ///
+ public abstract partial class TransitionWidget : Widget
+ {
+ private static readonly StringName ProgressParam = "progress";
+
+ [Export] private TextureRect imgBackground;
+ [Export] private Control contentGroup;
+ [Export] private float transitionDuration = 0.8f;
+ [Export] private float contentDelay = 0.05f;
+
+ private ShaderMaterial materialInstance;
+ private Tween transitionTween;
+
+ ///
+ /// Shows the widget base, then creates or reuses a shader material for the background and starts the transition tween.
+ /// Material is duplicated from imgBackground.Material on first call so each instance gets independent shader parameters.
+ ///
+ public override void Show()
+ {
+ base.Show();
+
+ if (materialInstance == null && imgBackground != null)
+ {
+ materialInstance = imgBackground.Material?.Duplicate() as ShaderMaterial;
+ if (materialInstance == null)
+ {
+ materialInstance = new ShaderMaterial();
+ var shader = Shaders.Resolve("scene_transition");
+ if (shader != null)
+ materialInstance.Shader = shader;
+ }
+ imgBackground.Material = materialInstance;
+ materialInstance.SetShaderParameter(ProgressParam, 0f);
+ }
+
+ if (transitionTween != null)
+ {
+ transitionTween.Kill();
+ transitionTween = null;
+ }
+
+ RunShowAnimation();
+ }
+
+ /// Kills the active transition tween if one is running, then calls base .
+ public override void Hide()
+ {
+ if (transitionTween != null)
+ {
+ transitionTween.Kill();
+ transitionTween = null;
+ }
+ base.Hide();
+ }
+
+ private void RunShowAnimation()
+ {
+ if (contentGroup != null)
+ {
+ contentGroup.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
+ contentGroup.MouseFilter = MouseFilterEnum.Ignore;
+ }
+
+ transitionTween = CreateTween();
+ transitionTween.SetProcessMode(Tween.TweenProcessMode.Physics);
+ transitionTween.TweenMethod(Callable.From(OnProgressUpdate), 0f, 1f, transitionDuration);
+ transitionTween.Finished += OnTweenFinished;
+ }
+
+ private void OnProgressUpdate(float t)
+ {
+ float curvedT = EaseInOut(t);
+ if (imgBackground?.Material is ShaderMaterial mat)
+ mat.SetShaderParameter(ProgressParam, curvedT);
+ }
+
+ private void OnTweenFinished()
+ {
+ if (imgBackground?.Material is ShaderMaterial mat)
+ mat.SetShaderParameter(ProgressParam, 1f);
+
+ var timer = GetTree().CreateTimer(contentDelay);
+ timer.Timeout += () =>
+ {
+ if (contentGroup != null)
+ {
+ contentGroup.Modulate = UiTokens.White;
+ contentGroup.MouseFilter = MouseFilterEnum.Stop;
+ }
+ };
+
+ transitionTween = null;
+ }
+
+ private static float EaseInOut(float t)
+ {
+ return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
+ }
+
+ /// Disposes the per-instance shader material created for the transition.
+ public override void _ExitTree()
+ {
+ if (materialInstance != null)
+ {
+ materialInstance.Dispose();
+ materialInstance = null;
+ }
+ base._ExitTree();
+ }
+ }
+}
+
+
diff --git a/src/Base/Lists/Widget.cs b/src/Base/Lists/Widget.cs
index 66682ad..be38bb1 100644
--- a/src/Base/Lists/Widget.cs
+++ b/src/Base/Lists/Widget.cs
@@ -1,194 +1,194 @@
-using Cthangover.Core.Utils;
-using Godot;
-
+using Cthangover.Core.Utils;
+using Godot;
+
using Cthangover.Core.UI;
-namespace Cthangover.Mods.Interface.Base.Lists
-{
- ///
- /// Base widget with a lazy construct/destruct lifecycle around Show/Hide.
- /// On first Show(), OnceConstruct() fires once via EnsureConstructed — subsequent
- /// Show/Hide calls trigger ShowConstruct/HideDestruct instead, allowing repeated
- /// state reset without full reconstruction. Overrides Godot's built-in Visible
- /// property to intercept visibility changes and route them through the lifecycle.
- /// Maintains an independent isVisible flag because Godot's visibility has quirks
- /// (e.g. parent visibility propagation) that would break the state machine.
- /// The #if TOOLS _ValidateProperty syncs the editor inspector with the override.
- ///
- public abstract partial class Widget : Control, IWidget
- {
- private bool isConstructed;
-
- /// Syncs the internal visibility flag with the scene-file state and hides properly when the widget starts hidden.
- public override void _Ready()
- {
- GameLogger.Log("WIDGET", $"_Ready: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
- isVisible = base.Visible;
- if (!isVisible)
- Hide();
- OnReady();
- }
-
- /// Tears down a constructed widget when it leaves the tree, so hidden-while-freed widgets still release their resources.
- public override void _ExitTree()
- {
- if (isConstructed)
- OnceDestruct();
- OnExitTree();
- }
-
- /// Engine callback; forwards to so mod subclasses receive input without their own engine bindings.
- public override void _Input(InputEvent @event) => OnInput(@event);
-
- /// Forwarded from . Override for mod widget initialization.
- protected virtual void OnReady() { }
-
- /// Forwarded from . Override for mod widget teardown.
- protected virtual void OnExitTree() { }
-
- /// Forwarded from . Override to handle input in mod widgets.
- protected virtual void OnInput(InputEvent @event) { }
-
- /// Toggles visibility: calls if visible, otherwise.
- public void Switch()
- {
- if (Visible)
- Hide();
- else
- Show();
- }
-
- ///
- /// Makes visible and runs lifecycle. On first call, fires .
- /// Subsequent calls trigger without reconstruction. Sets both the internal isVisible flag
- /// and Godot's native visibility, then explicitly shows the Body canvas item.
- ///
- public new virtual void Show()
- {
- GameLogger.Log("WIDGET", $"Show ENTER: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
- var wasConstructed = isConstructed;
- EnsureConstructed();
-
- if (Visible)
- {
- if (!wasConstructed)
- ShowConstruct();
- GameLogger.Log("WIDGET", $"Show SKIP: already visible", LogLevel.Debug);
- return;
- }
-
- ShowConstruct();
-
- isVisible = true;
- base.Visible = true;
-
- var canvasBody = Body as CanvasItem;
- if (canvasBody != null)
- canvasBody.Visible = true;
-
- (Body as Control)?.QueueRedraw();
-
- GameLogger.Log("WIDGET", $"Show EXIT: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
- }
-
- /// Hides the widget, fires if constructed, sets internal and Godot visibility to false, and hides the Body canvas item.
- public new virtual void Hide()
- {
- if (!Visible)
- return;
-
- if (isConstructed)
- HideDestruct();
-
- isVisible = false;
- base.Visible = false;
-
- var canvasBody = Body as CanvasItem;
- if (canvasBody != null)
- canvasBody.Visible = false;
- }
-
- /// Idempotent construction gate: calls exactly once, on the first invocation.
- public void EnsureConstructed()
- {
- if (!isConstructed)
- {
- isConstructed = true;
- OnceConstruct();
- }
- }
-
- /// Called exactly once on the first . Override to perform one-time initialization.
- protected virtual void OnceConstruct()
- {
- }
-
- /// Called once on tree exit if constructed. Override for one-time cleanup of resources allocated in .
- protected virtual void OnceDestruct()
- {
- }
-
- [Export] private bool isVisible = true;
-
- [Export] private Node body;
-
- ///
- /// Shadows Godot's built-in visibility. Setting to true calls , setting to false calls .
- /// Maintains an independent isVisible flag because Godot's native visibility propagates through the parent chain,
- /// which would break the explicit lifecycle state machine.
- ///
- public new bool Visible
- {
- get => isVisible;
- set
- {
- if (isVisible == value)
- return;
-
- if (value)
- Show();
- else
- Hide();
- }
- }
-
- /// The rendered content subtree. May be a child node separate from the Rect layout root.
- public Node Body => body;
-
- /// The Control used for layout. For Widget this is the node itself, enabling it to serve as both layout root and widget.
- public Control Rect => this;
-
- /// Called each time runs after initial construction. Override to refresh state (e.g. reload data, re-enable input).
- protected virtual void ShowConstruct()
- {
- }
-
- /// Called each time runs when constructed. Override to clean up display-only state without destroying permanent resources.
- protected virtual void HideDestruct()
- {
- }
-
-#if TOOLS
- /// Keeps the inspector in sync with the overridden visibility: touching visible or isVisible in the editor routes through the lifecycle.
- public override void _ValidateProperty(Godot.Collections.Dictionary property)
- {
- base._ValidateProperty(property);
- if (property.TryGetValue("visible", out var _))
- {
- isVisible = base.Visible;
- if (isVisible)
- Show();
- else
- Hide();
- }
- else if (property.TryGetValue("isVisible", out var _))
- {
- if (isVisible)
- Show();
- else
- Hide();
- }
- }
-#endif
- }
-}
+namespace Cthangover.Mods.Interface.Base.Lists
+{
+ ///
+ /// Base widget with a lazy construct/destruct lifecycle around Show/Hide.
+ /// On first Show(), OnceConstruct() fires once via EnsureConstructed — subsequent
+ /// Show/Hide calls trigger ShowConstruct/HideDestruct instead, allowing repeated
+ /// state reset without full reconstruction. Overrides Godot's built-in Visible
+ /// property to intercept visibility changes and route them through the lifecycle.
+ /// Maintains an independent isVisible flag because Godot's visibility has quirks
+ /// (e.g. parent visibility propagation) that would break the state machine.
+ /// The #if TOOLS _ValidateProperty syncs the editor inspector with the override.
+ ///
+ public abstract partial class Widget : Control, IWidget
+ {
+ private bool isConstructed;
+
+ /// Syncs the internal visibility flag with the scene-file state and hides properly when the widget starts hidden.
+ public override void _Ready()
+ {
+ GameLogger.Log("WIDGET", $"_Ready: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
+ isVisible = base.Visible;
+ if (!isVisible)
+ Hide();
+ OnReady();
+ }
+
+ /// Tears down a constructed widget when it leaves the tree, so hidden-while-freed widgets still release their resources.
+ public override void _ExitTree()
+ {
+ if (isConstructed)
+ OnceDestruct();
+ OnExitTree();
+ }
+
+ /// Engine callback; forwards to so mod subclasses receive input without their own engine bindings.
+ public override void _Input(InputEvent @event) => OnInput(@event);
+
+ /// Forwarded from . Override for mod widget initialization.
+ protected virtual void OnReady() { }
+
+ /// Forwarded from . Override for mod widget teardown.
+ protected virtual void OnExitTree() { }
+
+ /// Forwarded from . Override to handle input in mod widgets.
+ protected virtual void OnInput(InputEvent @event) { }
+
+ /// Toggles visibility: calls if visible, otherwise.
+ public void Switch()
+ {
+ if (Visible)
+ Hide();
+ else
+ Show();
+ }
+
+ ///
+ /// Makes visible and runs lifecycle. On first call, fires .
+ /// Subsequent calls trigger without reconstruction. Sets both the internal isVisible flag
+ /// and Godot's native visibility, then explicitly shows the Body canvas item.
+ ///
+ public new virtual void Show()
+ {
+ GameLogger.Log("WIDGET", $"Show ENTER: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
+ var wasConstructed = isConstructed;
+ EnsureConstructed();
+
+ if (Visible)
+ {
+ if (!wasConstructed)
+ ShowConstruct();
+ GameLogger.Log("WIDGET", $"Show SKIP: already visible", LogLevel.Debug);
+ return;
+ }
+
+ ShowConstruct();
+
+ isVisible = true;
+ base.Visible = true;
+
+ var canvasBody = Body as CanvasItem;
+ if (canvasBody != null)
+ canvasBody.Visible = true;
+
+ (Body as Control)?.QueueRedraw();
+
+ GameLogger.Log("WIDGET", $"Show EXIT: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
+ }
+
+ /// Hides the widget, fires if constructed, sets internal and Godot visibility to false, and hides the Body canvas item.
+ public new virtual void Hide()
+ {
+ if (!Visible)
+ return;
+
+ if (isConstructed)
+ HideDestruct();
+
+ isVisible = false;
+ base.Visible = false;
+
+ var canvasBody = Body as CanvasItem;
+ if (canvasBody != null)
+ canvasBody.Visible = false;
+ }
+
+ /// Idempotent construction gate: calls exactly once, on the first invocation.
+ public void EnsureConstructed()
+ {
+ if (!isConstructed)
+ {
+ isConstructed = true;
+ OnceConstruct();
+ }
+ }
+
+ /// Called exactly once on the first . Override to perform one-time initialization.
+ protected virtual void OnceConstruct()
+ {
+ }
+
+ /// Called once on tree exit if constructed. Override for one-time cleanup of resources allocated in .
+ protected virtual void OnceDestruct()
+ {
+ }
+
+ [Export] private bool isVisible = true;
+
+ [Export] private Node body;
+
+ ///
+ /// Shadows Godot's built-in visibility. Setting to true calls , setting to false calls .
+ /// Maintains an independent isVisible flag because Godot's native visibility propagates through the parent chain,
+ /// which would break the explicit lifecycle state machine.
+ ///
+ public new bool Visible
+ {
+ get => isVisible;
+ set
+ {
+ if (isVisible == value)
+ return;
+
+ if (value)
+ Show();
+ else
+ Hide();
+ }
+ }
+
+ /// The rendered content subtree. May be a child node separate from the Rect layout root.
+ public Node Body => body;
+
+ /// The Control used for layout. For Widget this is the node itself, enabling it to serve as both layout root and widget.
+ public Control Rect => this;
+
+ /// Called each time runs after initial construction. Override to refresh state (e.g. reload data, re-enable input).
+ protected virtual void ShowConstruct()
+ {
+ }
+
+ /// Called each time runs when constructed. Override to clean up display-only state without destroying permanent resources.
+ protected virtual void HideDestruct()
+ {
+ }
+
+#if TOOLS
+ /// Keeps the inspector in sync with the overridden visibility: touching visible or isVisible in the editor routes through the lifecycle.
+ public override void _ValidateProperty(Godot.Collections.Dictionary property)
+ {
+ base._ValidateProperty(property);
+ if (property.TryGetValue("visible", out var _))
+ {
+ isVisible = base.Visible;
+ if (isVisible)
+ Show();
+ else
+ Hide();
+ }
+ else if (property.TryGetValue("isVisible", out var _))
+ {
+ if (isVisible)
+ Show();
+ else
+ Hide();
+ }
+ }
+#endif
+ }
+}
diff --git a/src/Event/DeviceInput.cs b/src/Event/DeviceInput.cs
index f56ca9c..ec29d46 100644
--- a/src/Event/DeviceInput.cs
+++ b/src/Event/DeviceInput.cs
@@ -1,137 +1,137 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.Event
-{
- ///
- /// Stateless input abstraction that maps mouse to a virtual single-touch source.
- /// Tracks mouse-down state between frames to derive touch phases (Began/Moved/
- /// Ended/Stationary), enabling touch-aware code to work with mouse-only devices.
- /// Provides both UI-space (UITouchPosition) and screen-space (TouchPosition)
- /// coordinate access. Update() must be called each frame to refresh the
- /// wasMouseDownLastFrame flag.
- ///
- public static class DeviceInput
- {
- ///
- /// Index of the active touch (always 0 for the mouse-backed single-touch
- /// model). Exposed for API compatibility with potential multi-touch support.
- ///
- public static int CurrentTouchIndex { get; set; }
-
- ///
- /// Derives a from mouse button state across frames.
- /// Compares the current left-button state against wasMouseDownLastFrame:
- /// Began on fresh press, Moved on hold, Ended on release.
- ///
- public static TouchPhase GetTouchPhase
- {
- get
- {
- if (Input.IsMouseButtonPressed(MouseButton.Left))
- {
- if (Input.IsMouseButtonPressed(MouseButton.Left) && !wasMouseDownLastFrame)
- return TouchPhase.Began;
- if (Input.IsMouseButtonPressed(MouseButton.Left) && wasMouseDownLastFrame)
- return TouchPhase.Moved;
- }
- else
- {
- if (wasMouseDownLastFrame)
- return TouchPhase.Ended;
- }
- return TouchPhase.Stationary;
- }
- }
-
- private static bool wasMouseDownLastFrame;
-
- ///
- /// Must be called each frame to snapshot the mouse button state. The saved
- /// state drives for press/release transitions.
- ///
- public static void Update()
- {
- wasMouseDownLastFrame = Input.IsMouseButtonPressed(MouseButton.Left);
- }
-
- ///
- /// Returns 1 when the left mouse button is pressed, 0 otherwise.
- /// Maps the mouse to a single-touch model.
- ///
- public static int TouchCount
- {
- get
- {
- return Input.IsMouseButtonPressed(MouseButton.Left) ? 1 : 0;
- }
- }
-
- ///
- /// Mouse position in UI space (top-left origin, Control coordinate system).
- /// Used for hit-testing against Control nodes in the UI layer.
- ///
- public static Vector2 UITouchPosition
- {
- get
- {
- var mp = GetViewport()?.GetMousePosition() ?? Vector2.Zero;
- // In Godot, UI coordinates match mouse coordinates (top-left origin in Control nodes)
- return mp;
- }
- }
-
- ///
- /// Mouse position in screen space. Used for raycasting against 2D/3D
- /// world objects.
- ///
- public static Vector2 TouchPosition
- {
- get
- {
- return GetViewport()?.GetMousePosition() ?? Vector2.Zero;
- }
- }
-
- ///
- /// Converts to a 3D vector (z=0) for ray-origin
- /// calculations in the 3D input pipeline.
- ///
- public static Vector3 TouchPosition3D
- {
- get
- {
- var pos = TouchPosition;
- return new Vector3(pos.X, pos.Y, 0);
- }
- }
-
- ///
- /// Last-frame mouse velocity from Godot's Input.GetLastMouseVelocity().
- /// Used by drag handlers to compute movement deltas.
- ///
- public static Vector2 TouchDelta
- {
- get
- {
- return Input.GetLastMouseVelocity();
- }
- }
-
- ///
- /// Returns the current touch position. The parameter
- /// is ignored in the single-touch model — always returns .
- /// Exists for multi-touch API compatibility.
- ///
- /// Touch index (ignored in current implementation).
- public static Vector2 GetTouchPosition(int index)
- {
- return TouchPosition;
- }
-
- private static Viewport GetViewport()
- {
- var tree = Godot.Engine.GetMainLoop() as SceneTree;
- return tree?.Root?.GetViewport();
- }
- }
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.Event
+{
+ ///
+ /// Stateless input abstraction that maps mouse to a virtual single-touch source.
+ /// Tracks mouse-down state between frames to derive touch phases (Began/Moved/
+ /// Ended/Stationary), enabling touch-aware code to work with mouse-only devices.
+ /// Provides both UI-space (UITouchPosition) and screen-space (TouchPosition)
+ /// coordinate access. Update() must be called each frame to refresh the
+ /// wasMouseDownLastFrame flag.
+ ///
+ public static class DeviceInput
+ {
+ ///
+ /// Index of the active touch (always 0 for the mouse-backed single-touch
+ /// model). Exposed for API compatibility with potential multi-touch support.
+ ///
+ public static int CurrentTouchIndex { get; set; }
+
+ ///
+ /// Derives a from mouse button state across frames.
+ /// Compares the current left-button state against wasMouseDownLastFrame:
+ /// Began on fresh press, Moved on hold, Ended on release.
+ ///
+ public static TouchPhase GetTouchPhase
+ {
+ get
+ {
+ if (Input.IsMouseButtonPressed(MouseButton.Left))
+ {
+ if (Input.IsMouseButtonPressed(MouseButton.Left) && !wasMouseDownLastFrame)
+ return TouchPhase.Began;
+ if (Input.IsMouseButtonPressed(MouseButton.Left) && wasMouseDownLastFrame)
+ return TouchPhase.Moved;
+ }
+ else
+ {
+ if (wasMouseDownLastFrame)
+ return TouchPhase.Ended;
+ }
+ return TouchPhase.Stationary;
+ }
+ }
+
+ private static bool wasMouseDownLastFrame;
+
+ ///
+ /// Must be called each frame to snapshot the mouse button state. The saved
+ /// state drives for press/release transitions.
+ ///
+ public static void Update()
+ {
+ wasMouseDownLastFrame = Input.IsMouseButtonPressed(MouseButton.Left);
+ }
+
+ ///
+ /// Returns 1 when the left mouse button is pressed, 0 otherwise.
+ /// Maps the mouse to a single-touch model.
+ ///
+ public static int TouchCount
+ {
+ get
+ {
+ return Input.IsMouseButtonPressed(MouseButton.Left) ? 1 : 0;
+ }
+ }
+
+ ///
+ /// Mouse position in UI space (top-left origin, Control coordinate system).
+ /// Used for hit-testing against Control nodes in the UI layer.
+ ///
+ public static Vector2 UITouchPosition
+ {
+ get
+ {
+ var mp = GetViewport()?.GetMousePosition() ?? Vector2.Zero;
+ // In Godot, UI coordinates match mouse coordinates (top-left origin in Control nodes)
+ return mp;
+ }
+ }
+
+ ///
+ /// Mouse position in screen space. Used for raycasting against 2D/3D
+ /// world objects.
+ ///
+ public static Vector2 TouchPosition
+ {
+ get
+ {
+ return GetViewport()?.GetMousePosition() ?? Vector2.Zero;
+ }
+ }
+
+ ///
+ /// Converts to a 3D vector (z=0) for ray-origin
+ /// calculations in the 3D input pipeline.
+ ///
+ public static Vector3 TouchPosition3D
+ {
+ get
+ {
+ var pos = TouchPosition;
+ return new Vector3(pos.X, pos.Y, 0);
+ }
+ }
+
+ ///
+ /// Last-frame mouse velocity from Godot's Input.GetLastMouseVelocity().
+ /// Used by drag handlers to compute movement deltas.
+ ///
+ public static Vector2 TouchDelta
+ {
+ get
+ {
+ return Input.GetLastMouseVelocity();
+ }
+ }
+
+ ///
+ /// Returns the current touch position. The parameter
+ /// is ignored in the single-touch model — always returns .
+ /// Exists for multi-touch API compatibility.
+ ///
+ /// Touch index (ignored in current implementation).
+ public static Vector2 GetTouchPosition(int index)
+ {
+ return TouchPosition;
+ }
+
+ private static Viewport GetViewport()
+ {
+ var tree = Godot.Engine.GetMainLoop() as SceneTree;
+ return tree?.Root?.GetViewport();
+ }
+ }
+}
diff --git a/src/Messages/MessagesHelper.cs b/src/Messages/MessagesHelper.cs
index cfdb1c9..f2ec71c 100644
--- a/src/Messages/MessagesHelper.cs
+++ b/src/Messages/MessagesHelper.cs
@@ -1,121 +1,121 @@
-using Cthangover.Core.Events;
-using Cthangover.Core.Scenes;
-using Cthangover.Core.UI.Theming;
-using Cthangover.Core.Utils;
-using Godot;
+using Cthangover.Core.Events;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI.Theming;
+using Cthangover.Core.Utils;
+using Godot;
using Cthangover.Core.UI.Messages;
-
-namespace Cthangover.Mods.Interface.Messages
-{
- ///
- /// Static factory for floating UI messages (like "Quest updated" or item
- /// pickup notifications). Manages a cycling position index of 0-5 so
- /// consecutive messages stack vertically without overlapping. Each message
- /// is instantiated from message.tscn and parented to the Background node
- /// (found via scene tree search). Messages auto-fade and self-destruct
- /// via UiMessage's OnUpdate.
- ///
- public static class MessagesHelper
- {
- private const int maxMessageIndex = 5;
- private static int lastMessageIndex = 0;
-
- private static ModScenePrefab prefab;
-
- private static int GetNextIndex(int index)
- {
- if (index++ >= maxMessageIndex)
- index = 0;
- return index;
- }
-
- private static float GetPositionByLastIndex(int index)
- {
- return (index - 1) * 60;
- }
-
- ///
- /// Resets the cycling position index to 0. Call when UI state resets
- /// to prevent messages from accumulating at high Y positions.
- ///
- public static void ClearLastIndex()
- {
- lastMessageIndex = 0;
- }
-
- ///
- /// Shows a message in the default warning colour at the next vertical slot.
- ///
- /// The message text to display.
- /// Fade speed multiplier (higher = faster fade, default 1).
- public static void AddMessage(string text, float speed = 1f)
- {
- AddMessage(text, UiTokens.Warning, speed);
- }
-
-
- ///
- /// Shows a message with a custom color at the next vertical slot.
- /// Messages auto-fade and self-destruct after ~3 seconds of display plus
- /// a fade duration determined by .
- ///
- /// The message text to display.
- /// The text color.
- /// Fade speed multiplier (higher = faster fade, default 1).
- public static void AddMessage(string text, Color color, float speed = 1f)
- {
- var nextIndex = GetNextIndex(lastMessageIndex);
- AddMessage(GetPositionByLastIndex(nextIndex), text, color, speed);
- lastMessageIndex = nextIndex;
- }
-
-
- ///
- /// Shows a message in the default warning colour at a specific vertical
- /// position, bypassing the cycling slot system.
- ///
- /// Y-offset in pixels for this message.
- /// The message text to display.
- /// Fade speed multiplier (default 1).
- public static void AddMessage(float position, string text, float speed = 1f)
- {
- AddMessage(position, text, UiTokens.Warning, speed);
- }
-
- ///
- /// Instantiates a from message.tscn, parents
- /// it to the Background node, and configures it for display and auto-fade.
- ///
- /// Y-offset in pixels for this message.
- /// The message text to display.
- /// The text color.
- /// Fade speed multiplier (default 1).
- public static void AddMessage(float position, string text, Color color, float speed = 1f)
- {
- if (prefab == null)
+
+namespace Cthangover.Mods.Interface.Messages
+{
+ ///
+ /// Static factory for floating UI messages (like "Quest updated" or item
+ /// pickup notifications). Manages a cycling position index of 0-5 so
+ /// consecutive messages stack vertically without overlapping. Each message
+ /// is instantiated from message.tscn and parented to the Background node
+ /// (found via scene tree search). Messages auto-fade and self-destruct
+ /// via UiMessage's OnUpdate.
+ ///
+ public static class MessagesHelper
+ {
+ private const int maxMessageIndex = 5;
+ private static int lastMessageIndex = 0;
+
+ private static ModScenePrefab prefab;
+
+ private static int GetNextIndex(int index)
+ {
+ if (index++ >= maxMessageIndex)
+ index = 0;
+ return index;
+ }
+
+ private static float GetPositionByLastIndex(int index)
+ {
+ return (index - 1) * 60;
+ }
+
+ ///
+ /// Resets the cycling position index to 0. Call when UI state resets
+ /// to prevent messages from accumulating at high Y positions.
+ ///
+ public static void ClearLastIndex()
+ {
+ lastMessageIndex = 0;
+ }
+
+ ///
+ /// Shows a message in the default warning colour at the next vertical slot.
+ ///
+ /// The message text to display.
+ /// Fade speed multiplier (higher = faster fade, default 1).
+ public static void AddMessage(string text, float speed = 1f)
+ {
+ AddMessage(text, UiTokens.Warning, speed);
+ }
+
+
+ ///
+ /// Shows a message with a custom color at the next vertical slot.
+ /// Messages auto-fade and self-destruct after ~3 seconds of display plus
+ /// a fade duration determined by .
+ ///
+ /// The message text to display.
+ /// The text color.
+ /// Fade speed multiplier (higher = faster fade, default 1).
+ public static void AddMessage(string text, Color color, float speed = 1f)
+ {
+ var nextIndex = GetNextIndex(lastMessageIndex);
+ AddMessage(GetPositionByLastIndex(nextIndex), text, color, speed);
+ lastMessageIndex = nextIndex;
+ }
+
+
+ ///
+ /// Shows a message in the default warning colour at a specific vertical
+ /// position, bypassing the cycling slot system.
+ ///
+ /// Y-offset in pixels for this message.
+ /// The message text to display.
+ /// Fade speed multiplier (default 1).
+ public static void AddMessage(float position, string text, float speed = 1f)
+ {
+ AddMessage(position, text, UiTokens.Warning, speed);
+ }
+
+ ///
+ /// Instantiates a from message.tscn, parents
+ /// it to the Background node, and configures it for display and auto-fade.
+ ///
+ /// Y-offset in pixels for this message.
+ /// The message text to display.
+ /// The text color.
+ /// Fade speed multiplier (default 1).
+ public static void AddMessage(float position, string text, Color color, float speed = 1f)
+ {
+ if (prefab == null)
prefab = new ModScenePrefab("scenes/ui/message.tscn");
-
- var instance = prefab.Instantiate();
- var background = SceneContextNode.FindNode("Background");
- if (background == null)
- {
- background = SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background") as Control;
- if (background == null)
- {
- GameLogger.Log("UI", "Background node not found for messages", LogLevel.Error);
- return;
- }
- }
- background.AddChild(instance);
- instance.Setup(position, text, speed, color);
-
- ModEventBus.Publish(new NotificationRequestedEvent
- {
- Text = text,
- Category = "message",
- });
- }
-
- }
-
-}
+
+ var instance = prefab.Instantiate();
+ var background = SceneContextNode.FindNode("Background");
+ if (background == null)
+ {
+ background = SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background") as Control;
+ if (background == null)
+ {
+ GameLogger.Log("UI", "Background node not found for messages", LogLevel.Error);
+ return;
+ }
+ }
+ background.AddChild(instance);
+ instance.Setup(position, text, speed, color);
+
+ ModEventBus.Publish(new NotificationRequestedEvent
+ {
+ Text = text,
+ Category = "message",
+ });
+ }
+
+ }
+
+}
diff --git a/src/Messages/UiMessage.cs b/src/Messages/UiMessage.cs
index 4adbbaa..ade175b 100644
--- a/src/Messages/UiMessage.cs
+++ b/src/Messages/UiMessage.cs
@@ -1,104 +1,104 @@
-using Cthangover.Core.Scenes;
-using Cthangover.Core.UI.Event;
-using Cthangover.Core.UI.Theming;
-using Godot;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI.Event;
+using Cthangover.Core.UI.Theming;
+using Godot;
using Cthangover.Core.UI.Messages;
-
-namespace Cthangover.Mods.Interface.Messages
-{
- ///
- /// Floating text message with auto-fade. Appears at a vertical position,
- /// stays visible for 3 seconds, then fades over 2/Speed seconds. Self-
- /// destructs (QueueFree) when fully transparent. Registers with
- /// SceneEventController for per-frame updates; unregisters on tree exit
- /// to prevent leaks. Speed affects only the fade duration, not the display
- /// duration, so faster speed = shorter fade = message disappears more quickly.
- ///
- public partial class UiMessage : Control, IOnUpdateEvent
- {
-
- [Export] private Label textField;
- /// Tint of the message text; defaults to the warning colour.
- [Export] public Color Color { get; set; } = UiTokens.Warning;
- /// Fade speed multiplier — 1 is normal; higher values shorten the fade-out.
- [Export] public float Speed { get; set; } = 1f;
- private float timestamp;
-
- ///
- /// The text content displayed by this message. Reads/writes the child
- /// textField label directly.
- ///
- public string Text
- {
- get => textField.Text;
- set => textField.Text = value;
- }
-
- ///
- /// Initializes the message: positions it, sets text/speed/color, records
- /// the start timestamp, and registers with the
- /// for per-frame updates. Called by .
- ///
- /// Vertical Y-offset in pixels.
- /// The message text.
- /// Fade speed multiplier.
- /// The text color.
- public void Setup(float pos, string text, float speed, Color color)
- {
- OffsetLeft = 50;
- OffsetRight = -10;
- Size = new Vector2(Size.X, 50);
- Position = new Vector2(0, pos + 200);
-
- Text = text;
- Speed = speed;
- Color = color;
- timestamp = (float)Godot.Time.GetTicksMsec() / 1000f;
-
- if (SceneContextNode.Instance != null)
- {
- var eventController = SceneContextNode.FindNode("EventController");
- eventController?.AddUpdateEventListener(this);
- }
- }
-
- /// Detaches from the event controller so a freed message stops receiving updates.
- public override void _ExitTree()
- {
- if (SceneContextNode.Instance != null)
- {
- var eventController = SceneContextNode.FindNode("EventController");
- eventController?.RemoveUpdateEventListener(this);
- }
- }
-
- ///
- /// Priority 1 — lightweight enough for many concurrent messages.
- ///
- public int Priority => 1;
-
- ///
- /// Per-frame update: displays for 3 seconds, then fades out over
- /// 2 / seconds. Self-destructs via
- /// QueueFree when fully transparent.
- ///
- public void OnUpdate()
- {
- var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
- var elapsed = currentTime - timestamp;
-
- if (elapsed < 3f)
- return;
-
- var fadeDuration = 2f / Speed;
- var fadeElapsed = elapsed - 3f;
- var progress = Mathf.Clamp(fadeElapsed / fadeDuration, 0f, 1f);
- textField.SelfModulate = UiTokens.Alpha(Colors.White, 1f - progress);
-
- if (progress >= 1f)
- QueueFree();
- }
-
- }
-
-}
+
+namespace Cthangover.Mods.Interface.Messages
+{
+ ///
+ /// Floating text message with auto-fade. Appears at a vertical position,
+ /// stays visible for 3 seconds, then fades over 2/Speed seconds. Self-
+ /// destructs (QueueFree) when fully transparent. Registers with
+ /// SceneEventController for per-frame updates; unregisters on tree exit
+ /// to prevent leaks. Speed affects only the fade duration, not the display
+ /// duration, so faster speed = shorter fade = message disappears more quickly.
+ ///
+ public partial class UiMessage : Control, IOnUpdateEvent
+ {
+
+ [Export] private Label textField;
+ /// Tint of the message text; defaults to the warning colour.
+ [Export] public Color Color { get; set; } = UiTokens.Warning;
+ /// Fade speed multiplier — 1 is normal; higher values shorten the fade-out.
+ [Export] public float Speed { get; set; } = 1f;
+ private float timestamp;
+
+ ///
+ /// The text content displayed by this message. Reads/writes the child
+ /// textField label directly.
+ ///
+ public string Text
+ {
+ get => textField.Text;
+ set => textField.Text = value;
+ }
+
+ ///
+ /// Initializes the message: positions it, sets text/speed/color, records
+ /// the start timestamp, and registers with the
+ /// for per-frame updates. Called by .
+ ///
+ /// Vertical Y-offset in pixels.
+ /// The message text.
+ /// Fade speed multiplier.
+ /// The text color.
+ public void Setup(float pos, string text, float speed, Color color)
+ {
+ OffsetLeft = 50;
+ OffsetRight = -10;
+ Size = new Vector2(Size.X, 50);
+ Position = new Vector2(0, pos + 200);
+
+ Text = text;
+ Speed = speed;
+ Color = color;
+ timestamp = (float)Godot.Time.GetTicksMsec() / 1000f;
+
+ if (SceneContextNode.Instance != null)
+ {
+ var eventController = SceneContextNode.FindNode("EventController");
+ eventController?.AddUpdateEventListener(this);
+ }
+ }
+
+ /// Detaches from the event controller so a freed message stops receiving updates.
+ public override void _ExitTree()
+ {
+ if (SceneContextNode.Instance != null)
+ {
+ var eventController = SceneContextNode.FindNode("EventController");
+ eventController?.RemoveUpdateEventListener(this);
+ }
+ }
+
+ ///
+ /// Priority 1 — lightweight enough for many concurrent messages.
+ ///
+ public int Priority => 1;
+
+ ///
+ /// Per-frame update: displays for 3 seconds, then fades out over
+ /// 2 / seconds. Self-destructs via
+ /// QueueFree when fully transparent.
+ ///
+ public void OnUpdate()
+ {
+ var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
+ var elapsed = currentTime - timestamp;
+
+ if (elapsed < 3f)
+ return;
+
+ var fadeDuration = 2f / Speed;
+ var fadeElapsed = elapsed - 3f;
+ var progress = Mathf.Clamp(fadeElapsed / fadeDuration, 0f, 1f);
+ textField.SelfModulate = UiTokens.Alpha(Colors.White, 1f - progress);
+
+ if (progress >= 1f)
+ QueueFree();
+ }
+
+ }
+
+}
diff --git a/src/Tool/ButtonModIconLoader.cs b/src/Tool/ButtonModIconLoader.cs
index aa9efd7..4463e51 100644
--- a/src/Tool/ButtonModIconLoader.cs
+++ b/src/Tool/ButtonModIconLoader.cs
@@ -1,30 +1,30 @@
-using Cthangover.Core.Factories.Impls;
-using Godot;
+using Cthangover.Core.Factories.Impls;
+using Godot;
using Cthangover.Core.UI.Tool;
-
-namespace Cthangover.Mods.Interface.Tool
-{
- ///
- /// Mod-aware TextureButton: resolves IconId through UIIconFactory at _Ready
- /// and applies the result to TextureNormal. Allows tool buttons to reference
- /// icons by logical ID that mods can override.
- ///
- public partial class ButtonModIconLoader : TextureButton
- {
- ///
- /// Logical icon ID resolved through . Set in the editor
- /// or via mod to reference a mod-overridable icon texture.
- ///
- [Export] public string IconId { get; set; }
-
- public override void _Ready()
- {
- if (string.IsNullOrEmpty(IconId))
- return;
-
- var texture = UIIconFactory.Instance.Get(IconId);
- if (texture != null)
- TextureNormal = texture;
- }
- }
-}
+
+namespace Cthangover.Mods.Interface.Tool
+{
+ ///
+ /// Mod-aware TextureButton: resolves IconId through UIIconFactory at _Ready
+ /// and applies the result to TextureNormal. Allows tool buttons to reference
+ /// icons by logical ID that mods can override.
+ ///
+ public partial class ButtonModIconLoader : TextureButton
+ {
+ ///
+ /// Logical icon ID resolved through . Set in the editor
+ /// or via mod to reference a mod-overridable icon texture.
+ ///
+ [Export] public string IconId { get; set; }
+
+ public override void _Ready()
+ {
+ if (string.IsNullOrEmpty(IconId))
+ return;
+
+ var texture = UIIconFactory.Instance.Get(IconId);
+ if (texture != null)
+ TextureNormal = texture;
+ }
+ }
+}
diff --git a/src/Tool/TextureRectModIconLoader.cs b/src/Tool/TextureRectModIconLoader.cs
index 636489c..450ee5b 100644
--- a/src/Tool/TextureRectModIconLoader.cs
+++ b/src/Tool/TextureRectModIconLoader.cs
@@ -1,30 +1,30 @@
-using Cthangover.Core.Factories.Impls;
-using Godot;
+using Cthangover.Core.Factories.Impls;
+using Godot;
using Cthangover.Core.UI.Tool;
-
-namespace Cthangover.Mods.Interface.Tool
-{
- ///
- /// Mod-aware TextureRect: resolves IconId through UIIconFactory at _Ready
- /// and applies the result. Used as a base class for any TextureRect that
- /// needs mod-overridable textures without scene modification.
- ///
- public partial class TextureRectModIconLoader : TextureRect
- {
- ///
- /// Logical icon ID resolved through . Set in the editor
- /// or via mod to reference a mod-overridable icon texture.
- ///
- [Export] public string IconId { get; set; }
-
- public override void _Ready()
- {
- if (string.IsNullOrEmpty(IconId))
- return;
-
- var texture = UIIconFactory.Instance.Get(IconId);
- if (texture != null)
- Texture = texture;
- }
- }
-}
+
+namespace Cthangover.Mods.Interface.Tool
+{
+ ///
+ /// Mod-aware TextureRect: resolves IconId through UIIconFactory at _Ready
+ /// and applies the result. Used as a base class for any TextureRect that
+ /// needs mod-overridable textures without scene modification.
+ ///
+ public partial class TextureRectModIconLoader : TextureRect
+ {
+ ///
+ /// Logical icon ID resolved through . Set in the editor
+ /// or via mod to reference a mod-overridable icon texture.
+ ///
+ [Export] public string IconId { get; set; }
+
+ public override void _Ready()
+ {
+ if (string.IsNullOrEmpty(IconId))
+ return;
+
+ var texture = UIIconFactory.Instance.Get(IconId);
+ if (texture != null)
+ Texture = texture;
+ }
+ }
+}
diff --git a/src/View/AlignType.cs b/src/View/AlignType.cs
index 41bd994..2b80abd 100644
--- a/src/View/AlignType.cs
+++ b/src/View/AlignType.cs
@@ -1,32 +1,32 @@
-namespace Cthangover.Mods.Interface.View
-{
- ///
- /// 9-point alignment grid for viewport content positioning. The naming
- /// uses horizontal-first, vertical-second convention (LeftTop, CenterCenter,
- /// RightBottom) — matching the intuitive order for CSS-like layout.
- ///
- public enum AlignType
- {
- /// Content aligned to top-left corner of the viewport.
- LeftTop,
- /// Content horizontally left, vertically centered in the viewport.
- LeftCenter,
- /// Content horizontally left, vertically at the bottom of the viewport.
- LeftBottom,
-
- /// Content horizontally centered, vertically at the top of the viewport.
- CenterTop,
- /// Content centered both horizontally and vertically in the viewport.
- CenterCenter,
- /// Content horizontally centered, vertically at the bottom of the viewport.
- CenterBottom,
-
- /// Content horizontally right, vertically at the top of the viewport.
- RightTop,
- /// Content horizontally right, vertically centered in the viewport.
- RightCenter,
- /// Content aligned to the bottom-right corner of the viewport.
- RightBottom,
- }
-
-}
+namespace Cthangover.Mods.Interface.View
+{
+ ///
+ /// 9-point alignment grid for viewport content positioning. The naming
+ /// uses horizontal-first, vertical-second convention (LeftTop, CenterCenter,
+ /// RightBottom) — matching the intuitive order for CSS-like layout.
+ ///
+ public enum AlignType
+ {
+ /// Content aligned to top-left corner of the viewport.
+ LeftTop,
+ /// Content horizontally left, vertically centered in the viewport.
+ LeftCenter,
+ /// Content horizontally left, vertically at the bottom of the viewport.
+ LeftBottom,
+
+ /// Content horizontally centered, vertically at the top of the viewport.
+ CenterTop,
+ /// Content centered both horizontally and vertically in the viewport.
+ CenterCenter,
+ /// Content horizontally centered, vertically at the bottom of the viewport.
+ CenterBottom,
+
+ /// Content horizontally right, vertically at the top of the viewport.
+ RightTop,
+ /// Content horizontally right, vertically centered in the viewport.
+ RightCenter,
+ /// Content aligned to the bottom-right corner of the viewport.
+ RightBottom,
+ }
+
+}
diff --git a/src/View/ViewBox.cs b/src/View/ViewBox.cs
index 1cff4ad..16bf8fc 100644
--- a/src/View/ViewBox.cs
+++ b/src/View/ViewBox.cs
@@ -1,284 +1,284 @@
-using Cthangover.Core.Mods;
-using Cthangover.Core.Mods.Resolvers;
-using Cthangover.Core.Scenes;
-using Cthangover.Core.Utils;
-using Cthangover.Core.UI;
-using Cthangover.Core.UI.Theming;
-using Godot;
-
-namespace Cthangover.Mods.Interface.View
-{
- ///
- /// Core viewport container: manages background/foreground TextureRects with
- /// a shared shader material (timed_sprite from mods) for time-of-day lighting.
- /// Supports smooth background transitions via a black ColorRect overlay that
- /// fades in, swaps the texture mid-transition, then fades out — a classic
- /// crossfade approach that doesn't require a second TextureRect. The zoom
- /// system scales the content Control between minZoom and maxZoom based on
- /// a 0–1 input value. Alignment positions content within the viewport using
- /// ViewBoxNavigator's 9-point grid. The #if TOOLS block forces content size
- /// to defaultResolution for editor preview.
- ///
- /// Interactive layers are Control containers placed between Background and
- /// Foreground (and above Foreground for UI) that host interactive scene objects.
- ///
- public partial class ViewBox : Control, IBackgroundView
- {
- [Export] private Vector2I defaultResolution = new(1920, 1024);
- [Export] private Control content;
- [Export] private float minZoom;
- [Export] private float maxZoom;
-
- ///
- /// The content node that holds all scene visuals. Scaling and alignment are applied
- /// directly to this node.
- ///
- public Control Content => content;
-
- /// Logical resolution of the content area (default 1920x1024). Used for normalised coordinate calculations.
- public Vector2I LogicalSize => defaultResolution;
-
- ///
- /// The background rendered behind all interactive layers.
- /// Receives a timed_sprite shader material for time-of-day lighting when
- /// no material is already assigned.
- ///
- public TextureRect Background { get; private set; }
-
- ///
- /// The foreground rendered above interactive layers.
- /// Receives the same timed_sprite shader as the background.
- ///
- public TextureRect Foreground { get; private set; }
-
- /// Container for interactive objects rendered behind the foreground.
- public Control InteractiveLayerBackground { get; private set; }
-
- /// Container for interactive objects rendered above the foreground.
- public Control InteractiveLayerForeground { get; private set; }
-
- /// Container for interactive objects rendered above all scene visuals (UI layer).
- public Control InteractiveLayerUI { get; private set; }
-
- private ColorRect transitionOverlay;
- private Tween transitionTween;
-
- /// Resolves the background and foreground nodes and assigns the time-of-day shader when no material is set.
- public override void _Ready()
- {
- Background ??= GetNodeOrNull("Background");
- Foreground ??= GetNodeOrNull("Foreground");
-
- var timedShader = Shaders.Resolve("timed_sprite");
-
- if (timedShader != null)
- {
- if (Background != null && Background.Material == null)
- {
- Background.Material = new ShaderMaterial { Shader = timedShader };
- GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Background", LogLevel.Debug);
- }
- if (Foreground != null && Foreground.Material == null)
- {
- Foreground.Material = new ShaderMaterial { Shader = timedShader };
- GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Foreground", LogLevel.Debug);
- }
- }
-
- SetZoom(0f);
- SetAlign(AlignType.CenterCenter);
- }
-
- ///
- /// Returns the interactive layer container for the given logical layer name.
- /// Creates the layer lazily as a direct child of ViewBox if it doesn't exist yet.
- ///
- /// "background", "foreground" or "ui".
- /// The matching Control container.
- public Control GetInteractiveLayer(string layerName)
- {
- var result = layerName?.ToLowerInvariant() switch
- {
- "background" => InteractiveLayerBackground ??= CreateLayerDirect("InteractiveLayer_Background", 1),
- "foreground" => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3),
- "ui" => InteractiveLayerUI ??= CreateLayerDirect("InteractiveLayer_UI", 5),
- _ => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3)
- };
-
- return result;
- }
-
- private Control CreateLayerDirect(string name, int zIndex)
- {
- var layer = new Control
- {
- Name = name,
- MouseFilter = MouseFilterEnum.Ignore,
- ZIndex = zIndex,
- Size = new Vector2(defaultResolution.X, defaultResolution.Y)
- };
- AddChild(layer);
- return layer;
- }
-
- ///
- /// Assigns a texture to the background . This is the
- /// primary mechanism for scenes to set the backdrop visual. Logs the new background
- /// ID from for debugging.
- ///
- public void SetBackgroundTexture(Texture2D texture)
- {
- if (Background != null)
- {
- Background.Texture = texture;
- GameLogger.Log("VIEW", $"set background '" + SceneContextNode.LastBackgroundID + "'");
- }
- }
-
- ///
- /// Assigns a texture to the foreground . Typically used
- /// for scene overlays rendered above interactive objects but below UI.
- ///
- public void SetForegroundTexture(Texture2D texture)
- {
- if (Foreground != null)
- {
- Foreground.Texture = texture;
- GameLogger.Log("VIEW", $"set foreground");
- }
- }
-
- ///
- /// Returns the currently assigned background texture, or null if none is set
- /// or the node is missing.
- ///
- public Texture2D GetBackgroundTexture()
- {
- return Background?.Texture;
- }
-
- ///
- /// Smoothly transitions the background to a new texture using a black overlay crossfade:
- /// fades a to full opacity over the first half of ,
- /// swaps the background texture mid-transition, then fades back out over the second half.
- /// If the target texture is already set or the background node is missing, no-op.
- /// cancels any in-progress transition first.
- ///
- /// The replacement background texture.
- /// Total crossfade duration in seconds (default 0.5).
- public void TransitionBackground(Texture2D newTexture)
- {
- TransitionBackground(newTexture, 0.5f);
- }
-
- public void TransitionBackground(Texture2D newTexture, float duration = 0.5f)
- {
- if (Background == null)
- {
- if (newTexture != null)
- SetBackgroundTexture(newTexture);
- return;
- }
-
- if (Background.Texture == newTexture)
- return;
-
- KillTransitionTween();
-
- var overlay = GetOrCreateTransitionOverlay();
- overlay.Visible = true;
- overlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
- overlay.MouseFilter = MouseFilterEnum.Ignore;
-
- var halfDuration = duration / 2f;
-
- transitionTween = CreateTween();
- transitionTween.TweenProperty(overlay, "modulate:a", 1f, halfDuration);
- transitionTween.TweenCallback(Callable.From(() =>
- {
- SetBackgroundTexture(newTexture);
- }));
- transitionTween.TweenProperty(overlay, "modulate:a", 0f, halfDuration);
- transitionTween.TweenCallback(Callable.From(() =>
- {
- overlay.Visible = false;
- transitionTween = null;
- }));
- }
-
- ///
- /// Aborts any in-progress background transition by killing the tween and resetting
- /// the overlay to invisible. Safe to call when no transition is active.
- ///
- public void KillTransitionTween()
- {
- if (transitionTween != null)
- {
- transitionTween.Kill();
- transitionTween = null;
- }
-
- if (transitionOverlay != null)
- {
- transitionOverlay.Visible = false;
- transitionOverlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
- }
- }
-
- private ColorRect GetOrCreateTransitionOverlay()
- {
- if (transitionOverlay == null)
- {
- transitionOverlay = new ColorRect
- {
- Name = "TransitionOverlay",
- Color = UiTokens.Alpha(Colors.Black, 1f),
- MouseFilter = MouseFilterEnum.Ignore,
- Visible = false
- };
- transitionOverlay.AnchorRight = 1f;
- transitionOverlay.AnchorBottom = 1f;
- transitionOverlay.GrowHorizontal = GrowDirection.Both;
- transitionOverlay.GrowVertical = GrowDirection.Both;
- AddChild(transitionOverlay);
- }
- return transitionOverlay;
- }
-
- ///
- /// Applies a zoom level to the content node. The is a 0–1 lerp
- /// factor between minZoom and maxZoom. Scale is uniform (X and Y are equal).
- ///
- /// Normalized zoom factor: 0 = minZoom, 1 = maxZoom.
- public void SetZoom(float value)
- {
- var zoom = minZoom + (maxZoom - minZoom) * value;
- if (content != null)
- content.Scale = Vector2.One * zoom;
- }
-
- ///
- /// Positions the content node within the viewport using the 9-point alignment grid
- /// from .
- ///
- /// Which corner/edge/center to align to.
- public void SetAlign(AlignType align)
- {
- if (content != null)
- content.Position = ViewBoxNavigator.GetPositionByAlign(align, content.Size);
- }
-
-#if TOOLS && DEBUG
- /// Editor-only: forces the content back to the default resolution rect while inspecting properties.
- public override void _ValidateProperty(Godot.Collections.Dictionary property)
- {
- base._ValidateProperty(property);
- if (content != null)
- content.Size = defaultResolution;
- }
-#endif
- }
-
-}
-
-
+using Cthangover.Core.Mods;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.Utils;
+using Cthangover.Core.UI;
+using Cthangover.Core.UI.Theming;
+using Godot;
+
+namespace Cthangover.Mods.Interface.View
+{
+ ///
+ /// Core viewport container: manages background/foreground TextureRects with
+ /// a shared shader material (timed_sprite from mods) for time-of-day lighting.
+ /// Supports smooth background transitions via a black ColorRect overlay that
+ /// fades in, swaps the texture mid-transition, then fades out — a classic
+ /// crossfade approach that doesn't require a second TextureRect. The zoom
+ /// system scales the content Control between minZoom and maxZoom based on
+ /// a 0–1 input value. Alignment positions content within the viewport using
+ /// ViewBoxNavigator's 9-point grid. The #if TOOLS block forces content size
+ /// to defaultResolution for editor preview.
+ ///
+ /// Interactive layers are Control containers placed between Background and
+ /// Foreground (and above Foreground for UI) that host interactive scene objects.
+ ///
+ public partial class ViewBox : Control, IBackgroundView
+ {
+ [Export] private Vector2I defaultResolution = new(1920, 1024);
+ [Export] private Control content;
+ [Export] private float minZoom;
+ [Export] private float maxZoom;
+
+ ///
+ /// The content node that holds all scene visuals. Scaling and alignment are applied
+ /// directly to this node.
+ ///
+ public Control Content => content;
+
+ /// Logical resolution of the content area (default 1920x1024). Used for normalised coordinate calculations.
+ public Vector2I LogicalSize => defaultResolution;
+
+ ///
+ /// The background rendered behind all interactive layers.
+ /// Receives a timed_sprite shader material for time-of-day lighting when
+ /// no material is already assigned.
+ ///
+ public TextureRect Background { get; private set; }
+
+ ///
+ /// The foreground rendered above interactive layers.
+ /// Receives the same timed_sprite shader as the background.
+ ///
+ public TextureRect Foreground { get; private set; }
+
+ /// Container for interactive objects rendered behind the foreground.
+ public Control InteractiveLayerBackground { get; private set; }
+
+ /// Container for interactive objects rendered above the foreground.
+ public Control InteractiveLayerForeground { get; private set; }
+
+ /// Container for interactive objects rendered above all scene visuals (UI layer).
+ public Control InteractiveLayerUI { get; private set; }
+
+ private ColorRect transitionOverlay;
+ private Tween transitionTween;
+
+ /// Resolves the background and foreground nodes and assigns the time-of-day shader when no material is set.
+ public override void _Ready()
+ {
+ Background ??= GetNodeOrNull("Background");
+ Foreground ??= GetNodeOrNull("Foreground");
+
+ var timedShader = Shaders.Resolve("timed_sprite");
+
+ if (timedShader != null)
+ {
+ if (Background != null && Background.Material == null)
+ {
+ Background.Material = new ShaderMaterial { Shader = timedShader };
+ GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Background", LogLevel.Debug);
+ }
+ if (Foreground != null && Foreground.Material == null)
+ {
+ Foreground.Material = new ShaderMaterial { Shader = timedShader };
+ GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Foreground", LogLevel.Debug);
+ }
+ }
+
+ SetZoom(0f);
+ SetAlign(AlignType.CenterCenter);
+ }
+
+ ///
+ /// Returns the interactive layer container for the given logical layer name.
+ /// Creates the layer lazily as a direct child of ViewBox if it doesn't exist yet.
+ ///
+ /// "background", "foreground" or "ui".
+ /// The matching Control container.
+ public Control GetInteractiveLayer(string layerName)
+ {
+ var result = layerName?.ToLowerInvariant() switch
+ {
+ "background" => InteractiveLayerBackground ??= CreateLayerDirect("InteractiveLayer_Background", 1),
+ "foreground" => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3),
+ "ui" => InteractiveLayerUI ??= CreateLayerDirect("InteractiveLayer_UI", 5),
+ _ => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3)
+ };
+
+ return result;
+ }
+
+ private Control CreateLayerDirect(string name, int zIndex)
+ {
+ var layer = new Control
+ {
+ Name = name,
+ MouseFilter = MouseFilterEnum.Ignore,
+ ZIndex = zIndex,
+ Size = new Vector2(defaultResolution.X, defaultResolution.Y)
+ };
+ AddChild(layer);
+ return layer;
+ }
+
+ ///
+ /// Assigns a texture to the background . This is the
+ /// primary mechanism for scenes to set the backdrop visual. Logs the new background
+ /// ID from for debugging.
+ ///
+ public void SetBackgroundTexture(Texture2D texture)
+ {
+ if (Background != null)
+ {
+ Background.Texture = texture;
+ GameLogger.Log("VIEW", $"set background '" + SceneContextNode.LastBackgroundID + "'");
+ }
+ }
+
+ ///
+ /// Assigns a texture to the foreground . Typically used
+ /// for scene overlays rendered above interactive objects but below UI.
+ ///
+ public void SetForegroundTexture(Texture2D texture)
+ {
+ if (Foreground != null)
+ {
+ Foreground.Texture = texture;
+ GameLogger.Log("VIEW", $"set foreground");
+ }
+ }
+
+ ///
+ /// Returns the currently assigned background texture, or null if none is set
+ /// or the node is missing.
+ ///
+ public Texture2D GetBackgroundTexture()
+ {
+ return Background?.Texture;
+ }
+
+ ///
+ /// Smoothly transitions the background to a new texture using a black overlay crossfade:
+ /// fades a to full opacity over the first half of ,
+ /// swaps the background texture mid-transition, then fades back out over the second half.
+ /// If the target texture is already set or the background node is missing, no-op.
+ /// cancels any in-progress transition first.
+ ///
+ /// The replacement background texture.
+ /// Total crossfade duration in seconds (default 0.5).
+ public void TransitionBackground(Texture2D newTexture)
+ {
+ TransitionBackground(newTexture, 0.5f);
+ }
+
+ public void TransitionBackground(Texture2D newTexture, float duration = 0.5f)
+ {
+ if (Background == null)
+ {
+ if (newTexture != null)
+ SetBackgroundTexture(newTexture);
+ return;
+ }
+
+ if (Background.Texture == newTexture)
+ return;
+
+ KillTransitionTween();
+
+ var overlay = GetOrCreateTransitionOverlay();
+ overlay.Visible = true;
+ overlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
+ overlay.MouseFilter = MouseFilterEnum.Ignore;
+
+ var halfDuration = duration / 2f;
+
+ transitionTween = CreateTween();
+ transitionTween.TweenProperty(overlay, "modulate:a", 1f, halfDuration);
+ transitionTween.TweenCallback(Callable.From(() =>
+ {
+ SetBackgroundTexture(newTexture);
+ }));
+ transitionTween.TweenProperty(overlay, "modulate:a", 0f, halfDuration);
+ transitionTween.TweenCallback(Callable.From(() =>
+ {
+ overlay.Visible = false;
+ transitionTween = null;
+ }));
+ }
+
+ ///
+ /// Aborts any in-progress background transition by killing the tween and resetting
+ /// the overlay to invisible. Safe to call when no transition is active.
+ ///
+ public void KillTransitionTween()
+ {
+ if (transitionTween != null)
+ {
+ transitionTween.Kill();
+ transitionTween = null;
+ }
+
+ if (transitionOverlay != null)
+ {
+ transitionOverlay.Visible = false;
+ transitionOverlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
+ }
+ }
+
+ private ColorRect GetOrCreateTransitionOverlay()
+ {
+ if (transitionOverlay == null)
+ {
+ transitionOverlay = new ColorRect
+ {
+ Name = "TransitionOverlay",
+ Color = UiTokens.Alpha(Colors.Black, 1f),
+ MouseFilter = MouseFilterEnum.Ignore,
+ Visible = false
+ };
+ transitionOverlay.AnchorRight = 1f;
+ transitionOverlay.AnchorBottom = 1f;
+ transitionOverlay.GrowHorizontal = GrowDirection.Both;
+ transitionOverlay.GrowVertical = GrowDirection.Both;
+ AddChild(transitionOverlay);
+ }
+ return transitionOverlay;
+ }
+
+ ///
+ /// Applies a zoom level to the content node. The is a 0–1 lerp
+ /// factor between minZoom and maxZoom. Scale is uniform (X and Y are equal).
+ ///
+ /// Normalized zoom factor: 0 = minZoom, 1 = maxZoom.
+ public void SetZoom(float value)
+ {
+ var zoom = minZoom + (maxZoom - minZoom) * value;
+ if (content != null)
+ content.Scale = Vector2.One * zoom;
+ }
+
+ ///
+ /// Positions the content node within the viewport using the 9-point alignment grid
+ /// from .
+ ///
+ /// Which corner/edge/center to align to.
+ public void SetAlign(AlignType align)
+ {
+ if (content != null)
+ content.Position = ViewBoxNavigator.GetPositionByAlign(align, content.Size);
+ }
+
+#if TOOLS && DEBUG
+ /// Editor-only: forces the content back to the default resolution rect while inspecting properties.
+ public override void _ValidateProperty(Godot.Collections.Dictionary property)
+ {
+ base._ValidateProperty(property);
+ if (content != null)
+ content.Size = defaultResolution;
+ }
+#endif
+ }
+
+}
+
+
diff --git a/src/View/ViewBoxNavigator.cs b/src/View/ViewBoxNavigator.cs
index 90ea235..3015a46 100644
--- a/src/View/ViewBoxNavigator.cs
+++ b/src/View/ViewBoxNavigator.cs
@@ -1,73 +1,73 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.View
-{
- ///
- /// Static utility that calculates content positioning offsets for the 9-point
- /// AlignType grid. Uses the viewport's visible rect as the camera/view area
- /// and computes offsets so the content aligns correctly within it. The
- /// CenterCenter case negates X because the content position is treated as
- /// a negative offset from top-left in Godot's coordinate space.
- ///
- public static class ViewBoxNavigator
- {
-
- ///
- /// Calculates the content node position offset for the given 9-point .
- /// Uses the visible viewport rect size as the camera area and computes offsets relative
- /// to the top-left corner. Negative X coordinates are intentional: Godot positions content
- /// relative to the container origin, so aligning center or right requires negative offsets
- /// when content is larger than the viewport.
- ///
- /// The target alignment from the 9-point grid.
- /// The size of the content node being positioned.
- /// A position offset that places the content correctly for the chosen alignment.
- public static Vector2 GetPositionByAlign(AlignType align, Vector2 contentSize)
- {
- var viewport = Godot.Engine.GetMainLoop() as SceneTree;
- var screenSize = viewport?.Root?.GetVisibleRect().Size ?? new Vector2(1920, 1024);
- var cameraSize = screenSize;
- var alignPos = Vector2.Zero;
-
- switch (align)
- {
- case AlignType.LeftTop:
- break;
- case AlignType.LeftCenter:
- alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
- break;
- case AlignType.LeftBottom:
- alignPos.Y = contentSize.Y - cameraSize.Y;
- break;
-
- case AlignType.CenterTop:
- alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
- break;
- case AlignType.CenterCenter:
- alignPos = (contentSize - cameraSize) * 0.5f;
- alignPos.X = -alignPos.X;
- break;
- case AlignType.CenterBottom:
- alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
- alignPos.Y = contentSize.Y - cameraSize.Y;
- break;
-
- case AlignType.RightTop:
- alignPos.X = -(contentSize.X - cameraSize.X);
- break;
- case AlignType.RightCenter:
- alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
- alignPos.X = -(contentSize.X - cameraSize.X);
- break;
- case AlignType.RightBottom:
- alignPos.X = -(contentSize.X - cameraSize.X);
- alignPos.Y = contentSize.Y - cameraSize.Y;
- break;
- }
-
- return alignPos;
- }
-
- }
-
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.View
+{
+ ///
+ /// Static utility that calculates content positioning offsets for the 9-point
+ /// AlignType grid. Uses the viewport's visible rect as the camera/view area
+ /// and computes offsets so the content aligns correctly within it. The
+ /// CenterCenter case negates X because the content position is treated as
+ /// a negative offset from top-left in Godot's coordinate space.
+ ///
+ public static class ViewBoxNavigator
+ {
+
+ ///
+ /// Calculates the content node position offset for the given 9-point .
+ /// Uses the visible viewport rect size as the camera area and computes offsets relative
+ /// to the top-left corner. Negative X coordinates are intentional: Godot positions content
+ /// relative to the container origin, so aligning center or right requires negative offsets
+ /// when content is larger than the viewport.
+ ///
+ /// The target alignment from the 9-point grid.
+ /// The size of the content node being positioned.
+ /// A position offset that places the content correctly for the chosen alignment.
+ public static Vector2 GetPositionByAlign(AlignType align, Vector2 contentSize)
+ {
+ var viewport = Godot.Engine.GetMainLoop() as SceneTree;
+ var screenSize = viewport?.Root?.GetVisibleRect().Size ?? new Vector2(1920, 1024);
+ var cameraSize = screenSize;
+ var alignPos = Vector2.Zero;
+
+ switch (align)
+ {
+ case AlignType.LeftTop:
+ break;
+ case AlignType.LeftCenter:
+ alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
+ break;
+ case AlignType.LeftBottom:
+ alignPos.Y = contentSize.Y - cameraSize.Y;
+ break;
+
+ case AlignType.CenterTop:
+ alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
+ break;
+ case AlignType.CenterCenter:
+ alignPos = (contentSize - cameraSize) * 0.5f;
+ alignPos.X = -alignPos.X;
+ break;
+ case AlignType.CenterBottom:
+ alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
+ alignPos.Y = contentSize.Y - cameraSize.Y;
+ break;
+
+ case AlignType.RightTop:
+ alignPos.X = -(contentSize.X - cameraSize.X);
+ break;
+ case AlignType.RightCenter:
+ alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
+ alignPos.X = -(contentSize.X - cameraSize.X);
+ break;
+ case AlignType.RightBottom:
+ alignPos.X = -(contentSize.X - cameraSize.X);
+ alignPos.Y = contentSize.Y - cameraSize.Y;
+ break;
+ }
+
+ return alignPos;
+ }
+
+ }
+
+}
diff --git a/src/View/ZoomViewBoxController.cs b/src/View/ZoomViewBoxController.cs
index 47aa578..daa754e 100644
--- a/src/View/ZoomViewBoxController.cs
+++ b/src/View/ZoomViewBoxController.cs
@@ -1,31 +1,31 @@
-using Godot;
-
-namespace Cthangover.Mods.Interface.View
-{
- ///
- /// Thin adapter that exposes ViewBox.SetZoom to external callers (e.g. UI
- /// sliders or scroll handlers) through a single method. Exists as a Node
- /// so it can be placed in the scene tree and receive signals.
- ///
- public partial class ZoomViewBoxController : Node
- {
-
- [Export] private ViewBox viewBox;
-
- ///
- /// Delegates to on the linked .
- /// Intended to be connected to a slider or scroll input signal. No-op if
- /// viewBox is not assigned.
- ///
- /// Normalized zoom factor (0–1).
- public void OnChangeZoom(float value)
- {
- if (viewBox == null)
- return;
-
- viewBox.SetZoom(value);
- }
-
- }
-
-}
+using Godot;
+
+namespace Cthangover.Mods.Interface.View
+{
+ ///
+ /// Thin adapter that exposes ViewBox.SetZoom to external callers (e.g. UI
+ /// sliders or scroll handlers) through a single method. Exists as a Node
+ /// so it can be placed in the scene tree and receive signals.
+ ///
+ public partial class ZoomViewBoxController : Node
+ {
+
+ [Export] private ViewBox viewBox;
+
+ ///
+ /// Delegates to on the linked .
+ /// Intended to be connected to a slider or scroll input signal. No-op if
+ /// viewBox is not assigned.
+ ///
+ /// Normalized zoom factor (0–1).
+ public void OnChangeZoom(float value)
+ {
+ if (viewBox == null)
+ return;
+
+ viewBox.SetZoom(value);
+ }
+
+ }
+
+}