using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
namespace Mods.ModManager
{
///
/// Fullscreen scheme editor opened from the main menu: the whole game on one
/// canvas exactly like the approved mockup. The header picks the game and
/// creates new games/mechanics, the canvas shows movable and resizable
/// mechanic contours with freely placed mod chips and dependency lines, and
/// the inspector edits the selected group or mod.
///
/// All heavy lifting stays in the core:
/// merges documents, expands includes
/// into groups and problems, decides what to
/// download and enable, and persists the
/// arrangement. Edits mutate drafts in _overrides — never the loaded
/// documents — and only "Сохранить" writes them back through
/// , so the player can explore
/// layouts without committing anything.
///
public partial class ModCollectionsScreen : ModWidget
{
private const string RoleExtra = "extra";
private Button _close;
private Button _refresh;
private Button _addMechanic;
private Button _newGame;
private Button _apply;
private OptionButton _gameSelect;
private OptionButton _edgesToggle;
private Button _pillProblems;
private Button _pillPlan;
private Label _modsCount;
private Label _dirty;
private Button _json;
private Button _reset;
private Button _save;
private Label _bannerLabel;
private Label _detailTitle;
private Label _detailMeta;
private Label _detailDescription;
private VBoxContainer _detailExtra;
private PanelContainer _bannerPanel;
private CollectionSchemeCanvas _canvas;
private Button _tabInspector;
private Button _tabProblems;
private Button _tabPlan;
private HBoxContainer _crumbs;
private Button _crumbCollections;
private Label _buildName;
private Label _buildInstall;
private Label _buildEnable;
private Label _buildProblems;
private Label _buildHint;
private Button _buildManager;
private Button _buildApply;
private List _collections = new();
private readonly Dictionary _overrides = new(StringComparer.OrdinalIgnoreCase);
private string _currentId;
private ModCollectionGraph _graph;
private ModCollectionPlan _plan;
private ModCollectionLayout _layout = new();
private string _selectedGroupKey;
private string _selectedModId;
private string _tab = "inspector";
/// Mods installed during this session; the registry only sees them after a restart.
private HashSet _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
private bool _installing;
private bool _restartRequired;
private string _statusMessage;
private Button _duplicate;
private Button _revert;
private Button _delete;
private Label _savesInfo;
private Button _addMod;
private LineEdit _canvasSearch;
private Label _zoomLabel;
private VBoxContainer _versionEditor;
private OptionButton _versionPolicy;
private LineEdit _versionValue;
private Label _versionRule;
private Label _versionFeedback;
private ModCollectionModRef _versionReference;
private bool _fitAfterBuild = true;
private bool _fitQueued;
/// Drafts created by "Duplicate", mapped to the game they were copied from (offer to copy its saves).
private readonly Dictionary _duplicatedFrom = new(StringComparer.OrdinalIgnoreCase);
///
/// When true the editor runs as the second level of the collections
/// tab: the close/title/new-game/catalog-refresh header controls are
/// hidden (the hosting owns navigation) and
/// the game selector moves into the breadcrumb row. Editing, saving
/// and applying behaviour are unchanged.
///
public bool Embedded { get; set; }
/// Raised when the breadcrumbs ask to go back to the games gallery.
public event Action GalleryRequested;
/// Raised when a "not found" problem asks to search the mod catalog; the argument is the missing id.
public event Action OpenManagerRequested;
/// Raised after every rebuild so the hosting hub can refresh its header.
public event Action ViewChanged;
/// Localized, '@key'-resolved title of the collection being edited; null when nothing is selected.
public string CurrentCollectionTitle
{
get
{
var collection = EffectiveCollection(_currentId);
if (collection == null)
return null;
var raw = collection.GetLocalizedName(TranslationServer.GetLocale());
return CollectionText.Resolve(raw);
}
}
/// Shares the hub-wide set of mods installed this session so gallery and editor stay consistent.
public void SetSessionInstalled(HashSet sessionInstalled)
=> _sessionInstalled = sessionInstalled ?? new HashSet(StringComparer.OrdinalIgnoreCase);
///
/// Opens the scheme editor on the given collection id, falling back to
/// the first available game when the id is null or unknown. Selection
/// is cleared — the same fresh view a game-switch produces.
///
public void OpenCollection(string collectionId)
{
_currentId = collectionId;
_fitAfterBuild = true;
_selectedGroupKey = null;
_selectedModId = null;
// The gallery re-reads the store on every refresh; re-read it here
// too, so a collection that appeared since this editor was built
// opens instead of falling back to the first game.
ReloadCollections();
}
/// Creates a fresh player game draft and opens it — the gallery's "build your own game" action.
public void OpenNewGame() => OnNewGamePressed();
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
CthangoverTheme.Apply(this);
BindSceneNodes();
SubscribeEvents();
CatalogService.LoadCache();
ReloadCollections();
StartCatalogRefreshIfNeeded();
}
protected override void Destruct()
{
UnsubscribeEvents();
}
private void BindGraphiteControls()
{
var tools = "Margin/Root/Tools/";
_addMod = GetNode(tools + "AddMod");
_addMod.Text = TranslationServer.Translate("mod_manager/collection_add_mod");
_addMod.Pressed += OnAddModPressed;
_canvasSearch = GetNode(tools + "Search");
_canvasSearch.PlaceholderText = TranslationServer.Translate("mod_manager/collection_canvas_search");
_canvasSearch.TextChanged += _canvas.SetSearch;
_zoomLabel = GetNode(tools + "Zoom");
GetNode(tools + "ZoomOut").Pressed += _canvas.ZoomOut;
GetNode(tools + "ZoomIn").Pressed += _canvas.ZoomIn;
var fit = GetNode(tools + "Fit");
fit.Text = TranslationServer.Translate("mod_manager/collection_canvas_fit");
fit.Pressed += _canvas.FitWidth;
var arrange = GetNode(tools + "Arrange");
arrange.Text = TranslationServer.Translate("mod_manager/collection_canvas_arrange");
arrange.Pressed += _canvas.ArrangeGroups;
var focus = GetNode(tools + "Focus");
focus.Text = TranslationServer.Translate("mod_manager/collection_canvas_focus");
focus.Pressed += _canvas.ShowSelection;
_canvas.SetMiniMap(GetNode("Margin/Root/Body/CanvasHost/MiniMap"));
var path = "Margin/Root/Body/Details/DetailScroll/Detail/VersionEditor";
_versionEditor = GetNode(path);
_versionPolicy = GetNode(path + "/Fields/Policy");
_versionValue = GetNode(path + "/Fields/Value");
_versionRule = GetNode(path + "/Rule");
_versionFeedback = GetNode(path + "/Feedback");
GetNode(path + "/Caption").Text = TranslationServer.Translate("mod_manager/collection_version_caption");
GetNode(path + "/Hint").Text = TranslationServer.Translate("mod_manager/collection_version_hint");
var save = GetNode(path + "/SaveRule");
save.Text = TranslationServer.Translate("mod_manager/collection_version_edit");
save.Pressed += OnSaveVersionPressed;
_versionPolicy.ItemSelected += OnVersionPolicySelected;
_versionValue.TextChanged += OnVersionValueChanged;
_versionRule.AddThemeColorOverride("font_color", UiTokens.Accent);
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
var policies = new[]
{
"mod_manager/collection_version_any", "mod_manager/collection_version_minimum",
"mod_manager/collection_version_exact", "mod_manager/collection_version_branch",
"mod_manager/collection_version_maximum", "mod_manager/collection_version_greater",
"mod_manager/collection_version_less",
};
var operators = new[] { "", ">=", "=", "^", "<=", ">", "<" };
for (var i = 0; i < policies.Length; i++)
{
_versionPolicy.AddItem(TranslationServer.Translate(policies[i]));
_versionPolicy.SetItemMetadata(i, operators[i]);
}
}
private void OnGalleryPressed() => GalleryRequested?.Invoke();
private void OnBuildManagerPressed() => SelectTab("plan");
private void OnAddModPressed()
{
var group = _selectedGroupKey == null ? null : FindGroup(_selectedGroupKey);
if (group != null)
OpenModPicker(group);
}
private void OnCanvasViewChanged()
{
if (_zoomLabel != null)
_zoomLabel.Text = Mathf.RoundToInt(_canvas.Zoom * 100f) + "%";
}
private void OnCanvasFirstFrame()
{
GetTree().ProcessFrame -= OnCanvasFirstFrame;
_fitQueued = false;
_fitAfterBuild = false;
_canvas.FitWidth();
while (_canvas.Zoom < 0.75f)
_canvas.ZoomIn();
_canvas.ShowSelection();
}
private void BindVersionRule(ModCollectionGroup group, string modId)
{
foreach (var reference in group.Mods)
{
if (string.Equals(reference.ModId, modId, StringComparison.OrdinalIgnoreCase))
{
_versionReference = reference;
break;
}
}
if (_versionReference == null)
return;
var owner = EffectiveCollection(_versionReference.CollectionId);
foreach (var mechanic in owner?.Mechanics ?? new List())
{
if (!string.Equals(mechanic.Id, _versionReference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
foreach (var raw in mechanic.Mods ?? new List())
{
var requirement = ModDependency.Parse(raw);
if (!requirement.IsValid || !string.Equals(requirement.ModId, modId, StringComparison.OrdinalIgnoreCase))
continue;
for (var i = 0; i < _versionPolicy.ItemCount; i++)
{
if (_versionPolicy.GetItemMetadata(i).AsString() == requirement.Operator)
_versionPolicy.Selected = i;
}
_versionValue.Text = requirement.VersionRaw ?? string.Empty;
if (requirement.VersionRaw == null
&& ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId, out var installed))
_versionValue.Text = installed.Version ?? string.Empty;
_versionEditor.Visible = true;
_versionEditor.GetNode("Caption").Text = TranslationServer.Translate("mod_manager/collection_version_caption")
+ " · " + group.Name;
OnVersionPolicySelected(_versionPolicy.Selected);
return;
}
}
}
private void OnVersionPolicySelected(long index)
{
var op = _versionPolicy.GetItemMetadata((int)index).AsString();
_versionValue.Editable = op.Length > 0;
OnVersionValueChanged(_versionValue.Text);
}
private void OnVersionValueChanged(string value)
{
if (_versionReference == null || _versionPolicy.Selected < 0)
return;
var op = _versionPolicy.GetItemMetadata(_versionPolicy.Selected).AsString();
_versionRule.Text = _versionReference.ModId + op + (op.Length > 0 ? value.Trim() : string.Empty);
_versionFeedback.Text = op == "^"
? TranslationServer.Translate("mod_manager/collection_version_branch_hint") : string.Empty;
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.Muted);
foreach (var node in _graph?.Nodes ?? new List())
{
if (node.ModId != _versionReference.ModId || node.State != ModCollectionNodeState.VersionMismatch)
continue;
var requirements = new List();
foreach (var requirement in node.Requirements)
{
if (!string.IsNullOrEmpty(requirement.Operator))
requirements.Add(requirement.Operator + requirement.VersionRaw);
}
_versionFeedback.Text = string.Format(TranslationServer.Translate("mod_manager/installed_version"), node.InstalledVersion)
+ "\n" + string.Format(TranslationServer.Translate("mod_manager/collection_requires"), string.Join(", ", requirements));
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
break;
}
}
private void OnSaveVersionPressed()
{
if (_versionReference == null || _versionPolicy.Selected < 0)
return;
var group = FindGroup(_selectedGroupKey);
if (group == null)
return;
var reference = _versionReference;
var op = _versionPolicy.GetItemMetadata(_versionPolicy.Selected).AsString();
var owner = EffectiveCollection(reference.CollectionId);
var valid = false;
var changed = false;
foreach (var mechanic in owner?.Mechanics ?? new List())
{
if (!string.Equals(mechanic.Id, reference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
var preview = new ModCollectionMechanic { Mods = new List(mechanic.Mods ?? new List()) };
valid = preview.TrySetModVersion(reference.ModId, op, _versionValue.Text);
for (var i = 0; i < preview.Mods.Count; i++)
{
if (preview.Mods[i] != mechanic.Mods[i])
changed = true;
}
break;
}
if (!valid)
{
_versionFeedback.Text = TranslationServer.Translate("mod_manager/collection_version_invalid");
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
return;
}
if (!changed)
return;
var collection = DraftForEdit(group, reference.CollectionId);
if (collection == null)
return;
foreach (var mechanic in collection.Mechanics ?? new List())
{
if (!string.Equals(mechanic.Id, reference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
if (!mechanic.TrySetModVersion(reference.ModId, op, _versionValue.Text))
{
_versionFeedback.Text = TranslationServer.Translate("mod_manager/collection_version_invalid");
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
return;
}
Rebuild();
return;
}
}
private void AddModRelationships(string modId)
{
AddInfoLine(TranslationServer.Translate("mod_manager/collection_role_hint"));
foreach (var edge in _graph.Edges)
{
if (string.Equals(edge.FromModId, modId, StringComparison.OrdinalIgnoreCase))
{
var consumer = LookupModTitle(edge.ToModId);
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_used_by"), consumer));
}
}
foreach (var collection in _collections)
{
if (string.Equals(collection.Id, _currentId, StringComparison.OrdinalIgnoreCase)
|| collection.Kind != ModCollectionKind.Game)
continue;
var effective = EffectiveCollection(collection.Id);
foreach (var mechanic in effective?.Mechanics ?? new List())
{
foreach (var raw in mechanic.Mods ?? new List())
{
if (!string.Equals(ParseModId(raw), modId, StringComparison.OrdinalIgnoreCase))
continue;
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_other_role"),
CollectionText.Resolve(effective.GetLocalizedName(TranslationServer.GetLocale())),
CollectionText.Resolve(mechanic.GetLocalizedName(TranslationServer.GetLocale()))));
break;
}
}
}
}
private string LookupModTitle(string modId)
{
foreach (var node in _graph.Nodes)
{
if (string.Equals(node.ModId, modId, StringComparison.OrdinalIgnoreCase))
return node.Title;
}
return modId;
}
private void BindSceneNodes()
{
_close = GetNode("Margin/Root/Header/Close");
_refresh = GetNode("Margin/Root/Header/Refresh");
_addMechanic = GetNode("Margin/Root/Header/AddMechanic");
_newGame = GetNode("Margin/Root/Header/NewGame");
_gameSelect = GetNode("Margin/Root/Header/GameSelect");
_edgesToggle = GetNode("Margin/Root/Header/EdgesToggle");
_pillProblems = GetNode("Margin/Root/Header/Problems");
_pillPlan = GetNode("Margin/Root/Header/Plan");
_modsCount = GetNode("Margin/Root/Header/ModsCount");
_dirty = GetNode("Margin/Root/Header/Dirty");
_json = GetNode("Margin/Root/Header/Json");
_reset = GetNode("Margin/Root/Header/Reset");
_save = GetNode("Margin/Root/Header/Save");
_bannerPanel = GetNode("Margin/Root/Banner");
_bannerLabel = GetNode("Margin/Root/Banner/BannerLabel");
_canvas = GetNode("Margin/Root/Body/CanvasHost/CanvasScroll/SchemeCanvas");
var detail = "Margin/Root/Body/Details/DetailScroll/Detail/";
_detailTitle = GetNode(detail + "DetailTitle");
_detailMeta = GetNode(detail + "DetailMeta");
_detailDescription = GetNode(detail + "DetailDescription");
_detailExtra = GetNode(detail + "DetailExtra");
_apply = GetNode(detail + "Apply");
_tabInspector = GetNode(detail + "Tabs/TabInspector");
_tabProblems = GetNode(detail + "Tabs/TabProblems");
_tabPlan = GetNode(detail + "Tabs/TabPlan");
_close.Text = TranslationServer.Translate("mod_manager/back");
_refresh.Text = TranslationServer.Translate("mod_manager/refresh");
_addMechanic.Text = TranslationServer.Translate("mod_manager/collection_add_mechanic");
_newGame.Text = TranslationServer.Translate("mod_manager/collection_new_game");
_pillProblems.Text = TranslationServer.Translate("mod_manager/collection_problems");
_pillPlan.Text = TranslationServer.Translate("mod_manager/collection_plan");
_gameSelect.TooltipText = TranslationServer.Translate("mod_manager/collection_game_tooltip");
_pillProblems.TooltipText = TranslationServer.Translate("mod_manager/collection_problems_tooltip");
_pillPlan.TooltipText = TranslationServer.Translate("mod_manager/collection_plan_tooltip");
_dirty.Text = TranslationServer.Translate("mod_manager/collection_unsaved");
_json.Text = TranslationServer.Translate("mod_manager/collection_json");
_reset.Text = TranslationServer.Translate("mod_manager/collection_reset");
_save.Text = TranslationServer.Translate("mod_manager/collection_save");
_tabInspector.Text = TranslationServer.Translate("mod_manager/collection_tab_inspector");
_tabProblems.Text = TranslationServer.Translate("mod_manager/collection_tab_problems");
_tabPlan.Text = TranslationServer.Translate("mod_manager/collection_tab_plan");
_json.TooltipText = TranslationServer.Translate("mod_manager/collection_json_tooltip");
_reset.TooltipText = TranslationServer.Translate("mod_manager/collection_reset_tooltip");
_apply.Text = TranslationServer.Translate("mod_manager/collection_apply");
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_selected"));
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_all"));
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_none"));
var orderLegend = GetNode("Margin/Root/OrderLegend");
orderLegend.Text = TranslationServer.Translate("mod_manager/collection_order_legend");
orderLegend.AddThemeColorOverride("font_color", UiTokens.Faint);
orderLegend.SetMeta(ModHelpPanel.HintTranslationMetadata, "collection_dependencies");
ModHelpPanel.Attach(_refresh, "refresh");
ModHelpPanel.Attach(_addMechanic, "collection_add_mechanic");
ModHelpPanel.Attach(_newGame, "collection_new");
ModHelpPanel.Attach(_gameSelect, "collection_game");
ModHelpPanel.Attach(_pillProblems, "collection_problems");
ModHelpPanel.Attach(_pillPlan, "collection_plan");
ModHelpPanel.Attach(_json, "collection_json");
ModHelpPanel.Attach(_reset, "collection_reset");
ModHelpPanel.Attach(_apply, "collection_apply");
ModHelpPanel.Attach(_edgesToggle, "collection_links");
ModHelpPanel.Attach(GetNode("Margin/Root/OrderLegend"), "load_order");
var header = _json.GetParent();
_savesInfo = new Label { VerticalAlignment = VerticalAlignment.Center };
_savesInfo.AddThemeColorOverride("font_color", UiTokens.Muted);
_duplicate = new Button { Text = TranslationServer.Translate("mod_manager/collection_duplicate") };
_revert = new Button { Text = TranslationServer.Translate("mod_manager/collection_revert") };
_delete = new Button { Text = TranslationServer.Translate("mod_manager/collection_delete") };
ModHelpPanel.Attach(_delete, "collection_delete");
foreach (var control in new Control[] { _savesInfo, _duplicate, _revert, _delete })
{
header.AddChild(control);
header.MoveChild(control, _json.GetIndex());
}
_duplicate.Pressed += OnDuplicatePressed;
_revert.Pressed += OnRevertPressed;
_delete.Pressed += OnDeletePressed;
CthangoverTheme.ApplyPrimary(_apply);
CthangoverTheme.ApplyPrimary(_save);
_dirty.AddThemeColorOverride("font_color", UiTokens.Warning);
_detailMeta.AddThemeColorOverride("font_color", UiTokens.Muted);
_detailDescription.AddThemeColorOverride("font_color", UiTokens.Body);
_bannerPanel.AddThemeStyleboxOverride("panel", ModManagerTheme.MakeBox(
UiTokens.Alpha(UiTokens.Warning, 0.15f),
UiTokens.Alpha(UiTokens.Warning, 0.45f),
UiTokens.RadiusMd, UiTokens.Space4, UiTokens.Space2));
_bannerLabel.AddThemeColorOverride("font_color", UiTokens.Warning);
_bannerPanel.Visible = false;
BindGraphiteControls();
BuildCrumbs();
BuildBuildBar();
if (Embedded)
ApplyEmbeddedLayout();
}
///
/// Fills the breadcrumb row: "Collections › [game selector] › Scheme"
/// plus the scheme controls that used to crowd the header. The game
/// selector node itself is reparented here so its items and events
/// keep working unchanged.
///
private void BuildCrumbs()
{
_crumbs = GetNode("Margin/Root/Crumbs");
_crumbs.AddThemeConstantOverride("separation", UiTokens.Space2);
_crumbCollections = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"));
_crumbCollections.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_crumbCollections.Pressed += OnGalleryPressed;
_crumbCollections.TooltipText = TranslationServer.Translate("mod_manager/gallery_crumb");
_crumbs.AddChild(_crumbCollections);
_crumbs.AddChild(UiKit.Label("›", UiTokens.FontBodySmall, UiTokens.Faint));
GetNode("Margin/Root/Header/GameSelect").Reparent(_crumbs);
_crumbs.AddChild(UiKit.Label("›", UiTokens.FontBodySmall, UiTokens.Faint));
_crumbs.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_crumb_scheme"), UiTokens.FontBodySmall, UiTokens.Body));
_crumbs.AddChild(UiKit.Spacer());
GetNode("Margin/Root/Header/ModsCount").Reparent(_crumbs);
var header = GetNode("Margin/Root/Header");
_crumbs.Reparent(header);
header.MoveChild(_crumbs, 0);
_crumbs.SizeFlagsHorizontal = SizeFlags.ExpandFill;
GetNode("Margin/Root/Header/Spacer").Visible = false;
var tools = GetNode("Margin/Root/Tools");
_addMechanic.Reparent(tools);
tools.MoveChild(_addMechanic, 0);
_edgesToggle.Reparent(tools);
tools.MoveChild(_edgesToggle, 2);
_pillProblems.Reparent(tools);
tools.MoveChild(_pillProblems, 3);
}
///
/// Builds the permanent bottom build bar from the mockup: build name,
/// install/enable/problems counters, a hint and the apply + open
/// manager actions. Replaces the old plan-tab-only apply button as the
/// always-visible place to run the plan.
///
private void BuildBuildBar()
{
var bar = GetNode("Margin/Root/BuildBar");
bar.AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Surface2, UiTokens.Line));
bar.CustomMinimumSize = new Vector2(0, 54);
var row = GetNode("Margin/Root/BuildBar/BarRow");
row.AddThemeConstantOverride("separation", UiTokens.Space5);
var nameColumn = UiKit.Column(0);
string buildCaption = TranslationServer.Translate("mod_manager/build_caption");
nameColumn.AddChild(UiKit.Label(buildCaption.ToUpperInvariant(), UiTokens.FontMicro, UiTokens.Faint));
_buildName = UiKit.Label(string.Empty, UiTokens.FontBodyLarge, UiTokens.InkBright);
nameColumn.AddChild(_buildName);
row.AddChild(nameColumn);
row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_install"), out _buildInstall));
row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_enable"), out _buildEnable));
row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_problems"), out _buildProblems));
_buildHint = UiKit.Label(string.Empty, UiTokens.FontBodySmall, UiTokens.Warning);
_buildHint.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(_buildHint);
row.AddChild(UiKit.Spacer());
_buildManager = UiKit.Button(TranslationServer.Translate("mod_manager/collection_tab_problems"));
ModHelpPanel.Attach(_buildManager, "collection_plan");
_buildManager.Pressed += OnBuildManagerPressed;
row.AddChild(_buildManager);
_buildApply = UiKit.Button(TranslationServer.Translate("mod_manager/collection_apply"), ButtonKind.Primary);
ModHelpPanel.Attach(_buildApply, "collection_apply");
_buildApply.Pressed += OnApplyPressed;
row.AddChild(_buildApply);
}
/// One "k/v" counter column of the build bar; the value label is returned to the caller.
private Control BuildStat(string caption, out Label value)
{
var column = UiKit.Column(0);
column.AddChild(UiKit.Label(caption.ToUpperInvariant(), UiTokens.FontMicro, UiTokens.Faint));
value = UiKit.Label("0", UiTokens.FontBodyLarge, UiTokens.InkBright);
column.AddChild(value);
return column;
}
/// Slims the header down to save/reset/json/dirty as the mockup requires.
private void ApplyEmbeddedLayout()
{
GetNode("Margin/Root/Header/Title").Visible = false;
_close.Visible = false;
_refresh.Visible = false;
_newGame.Visible = false;
_pillPlan.Visible = false;
}
private void SubscribeEvents()
{
_close.Pressed += OnClosePressed;
_refresh.Pressed += OnRefreshPressed;
_addMechanic.Pressed += OnAddMechanicPressed;
_newGame.Pressed += OnNewGamePressed;
_edgesToggle.ItemSelected += _canvas.SetEdgeMode;
_pillProblems.Pressed += () => SelectTab("problems");
_pillPlan.Pressed += () => SelectTab("plan");
_json.Pressed += OpenJsonDialog;
_reset.Pressed += ResetDrafts;
_save.Pressed += OpenSaveDialog;
_gameSelect.ItemSelected += OnGameSelected;
_tabInspector.Pressed += () => SelectTab("inspector");
_tabProblems.Pressed += () => SelectTab("problems");
_tabPlan.Pressed += () => SelectTab("plan");
_apply.Pressed += OnApplyPressed;
_canvas.GroupSelected += OnGroupSelected;
_canvas.ModSelected += OnModSelected;
_canvas.GroupCommand += OnGroupCommand;
_canvas.LayoutChanged += OnLayoutChanged;
_canvas.ViewChanged += OnCanvasViewChanged;
ModDownloader.OnProgress += OnInstallProgress;
CatalogService.OnRefreshed += OnCatalogRefreshed;
}
private void UnsubscribeEvents()
{
ModDownloader.OnProgress -= OnInstallProgress;
CatalogService.OnRefreshed -= OnCatalogRefreshed;
if (_fitQueued)
GetTree().ProcessFrame -= OnCanvasFirstFrame;
}
/* ═══════════════ Data flow ═══════════════ */
private void ReloadCollections()
{
_collections = ModCollectionStore.LoadAll();
if (string.IsNullOrEmpty(_currentId) || EffectiveCollection(_currentId) == null)
_currentId = FirstGameId();
RebuildGameSelect();
Rebuild();
}
private void Rebuild()
{
_graph = Resolve();
foreach (var node in _graph.Nodes)
{
if (ModRegistry.Instance.AllDiscoveredMods.TryGetValue(node.ModId, out var info))
node.Title = info.DisplayTitle;
}
_layout = _currentId == null ? new ModCollectionLayout() : CollectionLayoutStore.Load(_currentId);
_plan = ModCollectionPlan.Build(_graph, CatalogService.CachedEntries ?? new List());
if (_selectedGroupKey == null && _graph.Groups.Count > 0)
{
var initial = _graph.Groups[0];
foreach (var group in _graph.Groups)
{
if (group.Role == "battle" && group.Selected && group.Mods.Count > 0)
{
initial = group;
break;
}
}
_selectedGroupKey = initial.Key;
_selectedModId = initial.Mods.Count > 0 ? initial.Mods[0].ModId : null;
}
_canvas.Build(_graph, _layout);
_canvas.SetSelection(_selectedGroupKey, _selectedModId);
_canvas.SetEdgeMode(_edgesToggle.Selected);
if (_fitAfterBuild && !_fitQueued)
{
_fitQueued = true;
GetTree().ProcessFrame += OnCanvasFirstFrame;
}
UpdateHeader();
UpdateInspector();
UpdateBanner();
UpdateBuildBar();
ViewChanged?.Invoke();
}
private ModCollectionGraph Resolve()
{
var game = EffectiveCollection(_currentId);
if (game == null)
return new ModCollectionGraph();
return ModCollectionResolver.Resolve(
game,
BuildCollectionIndex(),
ModRegistry.Instance.AllDiscoveredMods,
DisabledModsStore.Load(),
CatalogService.CachedEntries ?? new List(),
_sessionInstalled,
TranslationServer.GetLocale(),
CollectionText.Resolve);
}
private Dictionary BuildCollectionIndex()
{
var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var collection in _collections)
{
if (collection?.Id != null)
index[collection.Id] = collection;
}
foreach (var pair in _overrides)
index[pair.Key] = pair.Value;
return index;
}
private ModCollection EffectiveCollection(string id)
{
if (string.IsNullOrEmpty(id))
return null;
if (_overrides.TryGetValue(id, out var draft))
return draft;
return _collections.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase));
}
private ModCollection OriginalCollection(string id)
=> _collections.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase));
/// Returns the editable draft of a collection, cloning the loaded document on first touch.
private ModCollection Draft(string id)
{
if (string.IsNullOrEmpty(id))
return null;
if (_overrides.TryGetValue(id, out var draft))
return draft;
var source = OriginalCollection(id);
if (source == null)
return null;
draft = CloneCollection(source);
_overrides[id] = draft;
return draft;
}
private ModCollection DraftOfGroup(ModCollectionGroup group)
{
if (group == null)
return null;
return Draft(string.IsNullOrEmpty(group.CollectionId) ? group.ParentCollectionId : group.CollectionId);
}
private static ModCollection CloneCollection(ModCollection source)
{
var json = JsonSerializer.Serialize(source, JsonOptionsProvider.WriteIndentedOptions);
return JsonSerializer.Deserialize(json, JsonOptionsProvider.ReadOptions);
}
private string FirstGameId()
{
var game = _collections
.Where(c => c.Kind == ModCollectionKind.Game)
.OrderBy(c => c.Name ?? c.Id, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
return game?.Id ?? _collections.FirstOrDefault()?.Id;
}
/* ═══════════════ Header ═══════════════ */
private void RebuildGameSelect()
{
_gameSelect.Clear();
var language = TranslationServer.GetLocale();
var games = _collections
.Where(c => c.Kind == ModCollectionKind.Game)
.OrderBy(c => c.GetLocalizedName(language), StringComparer.OrdinalIgnoreCase)
.ToList();
var selected = 0;
for (var i = 0; i < games.Count; i++)
{
_gameSelect.AddItem(games[i].GetLocalizedName(language));
_gameSelect.SetItemMetadata(i, games[i].Id);
if (string.Equals(games[i].Id, _currentId, StringComparison.OrdinalIgnoreCase))
selected = i;
}
if (games.Count > 0)
_gameSelect.Selected = selected;
}
private void UpdateHeader()
{
var dirty = _overrides.Count > 0;
_dirty.Visible = dirty;
_save.Disabled = !dirty;
_reset.Disabled = !dirty;
var current = EffectiveCollection(_currentId);
var isGame = current?.Kind == ModCollectionKind.Game;
_savesInfo.Visible = isGame && !Embedded;
if (isGame)
_savesInfo.Text = string.Format(TranslationServer.Translate("mod_manager/collection_saves"), SaveService.ListSaves(_currentId).Count);
_duplicate.Disabled = current == null;
_revert.Visible = OriginalCollection(_currentId)?.Origin == ModCollectionOrigin.User
&& ModCollectionStore.HasShippedVersion(_currentId);
_delete.Visible = CollectionDeletion.CanDelete(current);
if (_graph != null)
{
var installed = _graph.Nodes.Count(n => n.State == ModCollectionNodeState.Installed);
_modsCount.Text = string.Format(
TranslationServer.Translate("mod_manager/collection_result"), installed, _graph.Nodes.Count);
}
else
{
_modsCount.Text = string.Empty;
}
var errors = CountErrors();
_pillProblems.Text = errors > 0
? $"{TranslationServer.Translate("mod_manager/collection_problems")}: {errors}"
: TranslationServer.Translate("mod_manager/collection_problems");
if (_plan == null)
{
_pillPlan.Text = TranslationServer.Translate("mod_manager/collection_plan");
return;
}
if (errors > 0)
{
_pillPlan.Text = $"{TranslationServer.Translate("mod_manager/collection_problems")}: {errors}";
return;
}
var parts = new List();
if (_plan.Install.Count > 0)
parts.Add($"{TranslationServer.Translate("mod_manager/collection_plan_install")}: {_plan.Install.Count}");
if (_plan.Enable.Count > 0)
parts.Add($"{TranslationServer.Translate("mod_manager/collection_plan_enable")}: {_plan.Enable.Count}");
_pillPlan.Text = parts.Count > 0
? $"{TranslationServer.Translate("mod_manager/collection_plan")}: {string.Join(" · ", parts)}"
: TranslationServer.Translate("mod_manager/collection_plan_ready");
}
private int CountErrors()
=> (_graph?.Problems?.Count ?? 0) + (_plan?.Missing.Count ?? 0) + (_plan?.Mismatches.Count ?? 0);
private void OnGameSelected(long index)
{
var id = _gameSelect.GetItemMetadata((int)index).AsString();
if (string.IsNullOrEmpty(id) || string.Equals(id, _currentId, StringComparison.OrdinalIgnoreCase))
return;
_currentId = id;
_fitAfterBuild = true;
_selectedGroupKey = null;
_selectedModId = null;
Rebuild();
}
private void OnNewGamePressed()
{
_fitAfterBuild = true;
var id = UniqueGameId();
var draft = new ModCollection
{
Id = id,
Kind = ModCollectionKind.Game,
Name = TranslationServer.Translate("mod_manager/collection_new_game_name"),
Author = "player",
Version = "0.1.0",
Description = TranslationServer.Translate("mod_manager/collection_new_game_desc"),
Tags = new List(),
Provides = new List(),
Requires = new List(),
Includes = new List(),
Mechanics = new List(),
};
_overrides[id] = draft;
_currentId = id;
_selectedGroupKey = null;
_selectedModId = null;
RebuildGameSelect();
Rebuild();
OpenSaveDialog();
}
private string UniqueGameId()
{
var index = 1;
while (EffectiveCollection($"my_game_{index}") != null)
index++;
return $"my_game_{index}";
}
/* ═══════════════ Canvas events ═══════════════ */
private void OnGroupSelected(string groupKey)
{
_selectedGroupKey = groupKey;
_selectedModId = null;
_canvas.SetSelection(_selectedGroupKey, _selectedModId);
SelectTab("inspector");
}
private void OnModSelected(string modId)
{
_selectedModId = modId;
_selectedGroupKey = _canvas.GetGroupKeyOfMod(modId);
_canvas.SetSelection(_selectedGroupKey, _selectedModId);
SelectTab("inspector");
}
private void OnGroupCommand(ModCollectionGroup group, string command)
{
switch (command)
{
case "add-mod":
OpenModPicker(group);
break;
case "rename":
OpenRenameDialog(group);
break;
case "toggle":
ToggleGroup(group);
break;
case "replace":
OpenMechanicPicker(group, true);
break;
case "remove":
RemoveGroup(group);
break;
}
}
private void OnLayoutChanged()
{
if (!string.IsNullOrEmpty(_currentId))
CollectionLayoutStore.Save(_currentId, _layout);
}
private void ToggleGroup(ModCollectionGroup group)
{
if (group.IsInclude)
{
var include = FindInclude(group);
if (include == null)
return;
include.Selected = group.Selected ? false : null;
}
else
{
var mechanic = FindMechanic(group);
if (mechanic == null)
return;
mechanic.Selected = group.Selected ? false : null;
}
Rebuild();
}
private void RemoveGroup(ModCollectionGroup group)
{
if (group.IsInclude)
{
var include = FindInclude(group);
if (include == null)
return;
Draft(group.ParentCollectionId)?.Includes.Remove(include);
}
else
{
var mechanic = FindMechanic(group);
if (mechanic == null)
return;
Draft(group.CollectionId)?.Mechanics.Remove(mechanic);
}
_selectedGroupKey = null;
_selectedModId = null;
Rebuild();
}
private ModCollectionInclude FindInclude(ModCollectionGroup group)
{
var parent = Draft(group.ParentCollectionId);
if (parent?.Includes == null)
return null;
return parent.Includes.FirstOrDefault(i =>
string.Equals(IncludeId(i), group.CollectionId, StringComparison.OrdinalIgnoreCase));
}
private ModCollectionMechanic FindMechanic(ModCollectionGroup group)
{
var collection = Draft(group.CollectionId);
if (collection?.Mechanics == null)
return null;
return collection.Mechanics.FirstOrDefault(m =>
string.Equals(m.Id, group.MechanicId, StringComparison.OrdinalIgnoreCase));
}
private static string IncludeId(ModCollectionInclude include)
{
if (include == null || string.IsNullOrWhiteSpace(include.Ref))
return null;
var dependency = ModDependency.Parse(include.Ref);
return dependency.IsValid ? dependency.ModId : include.Ref.Trim();
}
/* ═══════════════ Inspector ═══════════════ */
private void SelectTab(string tab)
{
_tab = tab;
UpdateInspector();
}
private void UpdateInspector()
{
_tabInspector.ButtonPressed = _tab == "inspector";
_tabProblems.ButtonPressed = _tab == "problems";
_tabPlan.ButtonPressed = _tab == "plan";
ClearChildren(_detailExtra);
_versionEditor.Visible = false;
_versionReference = null;
_apply.Visible = !Embedded && _tab == "plan";
if (_tab == "problems")
{
RenderProblems();
return;
}
if (_tab == "plan")
{
RenderPlan();
return;
}
if (_selectedModId != null)
{
RenderModInspector(_selectedModId);
return;
}
var group = _selectedGroupKey == null ? null : FindGroup(_selectedGroupKey);
if (group != null)
{
RenderGroupInspector(group);
return;
}
_detailTitle.Text = TranslationServer.Translate("mod_manager/collections_title");
_detailMeta.Text = string.Empty;
_detailDescription.Text = TranslationServer.Translate("mod_manager/collection_hint");
UpdateApplyButton();
}
private void RenderGroupInspector(ModCollectionGroup group)
{
_detailTitle.Text = group.Name ?? group.CollectionId;
var metaParts = new List
{
group.IsInclude ? TranslationServer.Translate("mod_manager/collection_mechanic") : TranslationServer.Translate("mod_manager/collection_group"),
roleOrNull(group.Role),
group.Optional ? TranslationServer.Translate("mod_manager/collection_optional") : null,
$"{group.Mods.Count} {TranslationServer.Translate("mod_manager/collection_mods")}",
};
metaParts.RemoveAll(string.IsNullOrEmpty);
_detailMeta.Text = string.Join(" · ", metaParts);
_detailDescription.Text = string.IsNullOrEmpty(group.Description) ? "—" : group.Description;
AddInfoLine(TranslationServer.Translate("mod_manager/collection_role_hint"));
var collection = EffectiveCollection(group.CollectionId);
if (collection != null)
AddInfoLine(BuildCollectionMeta(collection));
var actions = UiKit.Row(UiTokens.Space2);
actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_add_mod"),
() => OpenModPicker(group)));
if (group.IsInclude)
{
actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_replace"),
() => OpenMechanicPicker(group, true)));
}
actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_rename"),
() => OpenRenameDialog(group)));
if (group.Optional)
{
actions.AddChild(MakeInspectorButton(
group.Selected ? TranslationServer.Translate("mod_manager/collection_turn_off") : TranslationServer.Translate("mod_manager/collection_turn_on"),
() => ToggleGroup(group)));
}
actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_remove"),
() => RemoveGroup(group), true));
_detailExtra.AddChild(actions);
UpdateApplyButton();
}
private void RenderModInspector(string modId)
{
var node = _graph?.Nodes?.FirstOrDefault(n => string.Equals(n.ModId, modId, StringComparison.OrdinalIgnoreCase));
_detailTitle.Text = node?.Title ?? modId;
_detailMeta.Text = $"{modId} · {node?.InstalledVersion ?? "—"} · {CollectionStateNames.Translate(node?.State ?? ModCollectionNodeState.Missing)}";
_detailDescription.Text = LookupModDescription(modId);
var group = _selectedGroupKey == null ? null : FindGroup(_selectedGroupKey);
if (group == null && node != null)
{
foreach (var requirement in node.Requirements)
{
if (!string.IsNullOrEmpty(requirement.Operator))
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_requires"), requirement.ToString()));
}
}
if (group != null)
{
AddInfoLine($"{TranslationServer.Translate("mod_manager/collection_in_group")}: {group.Name}");
BindVersionRule(group, modId);
AddModRelationships(modId);
var remove = MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_remove_mod"),
() => RemoveModFromGroup(group, modId), true);
_detailExtra.AddChild(remove);
}
UpdateApplyButton();
}
private void RemoveModFromGroup(ModCollectionGroup group, string modId)
{
var refs = group.Mods.Where(m => string.Equals(m.ModId, modId, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var reference in refs)
{
var collection = DraftForEdit(group, reference.CollectionId);
var mechanic = collection?.Mechanics?.FirstOrDefault(m =>
string.Equals(m.Id, reference.MechanicId, StringComparison.OrdinalIgnoreCase));
if (mechanic?.Mods == null)
continue;
mechanic.Mods.RemoveAll(m =>
string.Equals(ParseModId(m), modId, StringComparison.OrdinalIgnoreCase));
}
_selectedModId = null;
Rebuild();
}
private static string ParseModId(string spec)
{
var dependency = ModDependency.Parse(spec);
return dependency.IsValid ? dependency.ModId : spec?.Trim();
}
private void RenderProblems()
{
_detailTitle.Text = TranslationServer.Translate("mod_manager/collection_tab_problems");
_detailMeta.Text = string.Empty;
_detailDescription.Text = string.Empty;
if (_graph == null || CountErrors() == 0)
{
_detailExtra.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_problems_none"), UiTokens.FontBody, UiTokens.SuccessText));
UpdateApplyButton();
return;
}
AddPlanSection("mod_manager/collection_missing", _plan?.Missing, UiTokens.DangerText, findInCatalog: true);
AddPlanSection("mod_manager/collection_mismatch", _plan?.Mismatches, UiTokens.DangerText, findInCatalog: true);
foreach (var problem in _graph.Problems)
{
var row = UiKit.Column(UiTokens.Space1);
row.AddChild(UiKit.Label(FormatProblem(problem), UiTokens.FontBodySmall, UiTokens.DangerText));
if (!string.IsNullOrEmpty(problem.CollectionId) || !string.IsNullOrEmpty(problem.ModId))
{
var target = problem;
row.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_show"),
() => ShowProblemTarget(target)));
}
// Cross link from the mockup: a missing block can be searched
// in the mods catalog (jumps to the mods tab with the id).
if (problem.Kind == ModCollectionProblemKind.MissingCollection && !string.IsNullOrEmpty(problem.Ref))
{
var missingId = problem.Ref;
row.AddChild(MakeInspectorButton(
TranslationServer.Translate("mod_manager/collection_find_in_catalog"),
() => OpenManagerRequested?.Invoke(missingId)));
}
_detailExtra.AddChild(row);
}
UpdateApplyButton();
}
private string FormatProblem(ModCollectionProblem problem)
{
switch (problem.Kind)
{
case ModCollectionProblemKind.MissingCollection:
return string.Format(TranslationServer.Translate("mod_manager/collection_problem_missing"), problem.Ref);
case ModCollectionProblemKind.Cycle:
return string.Format(TranslationServer.Translate("mod_manager/collection_problem_cycle"), problem.CollectionId, problem.Ref);
case ModCollectionProblemKind.RoleConflict:
return string.Format(TranslationServer.Translate("mod_manager/collection_problem_role"), problem.Ref);
case ModCollectionProblemKind.RequirementMissing:
return string.Format(TranslationServer.Translate("mod_manager/collection_problem_require"), problem.CollectionId, RerequireCapability(problem.Title));
default:
return problem.Title;
}
}
private static string RerequireCapability(string title)
{
var parts = (title ?? string.Empty).Split('\'');
return parts.Length >= 4 ? parts[3] : title;
}
private void ShowProblemTarget(ModCollectionProblem problem)
{
if (!string.IsNullOrEmpty(problem.ModId))
{
OnModSelected(problem.ModId);
return;
}
OnGroupSelected(problem.GroupKey);
}
private void RenderPlan()
{
_detailTitle.Text = TranslationServer.Translate("mod_manager/collection_tab_plan");
_detailMeta.Text = string.Empty;
_detailDescription.Text = string.Empty;
if (_plan == null)
return;
if (_plan.IsEmpty && !_plan.HasProblems)
{
_detailExtra.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_plan_ready"), UiTokens.FontBody, UiTokens.SuccessText));
return;
}
AddPlanSection("mod_manager/collection_install_count", _plan.Install.Select(i => i.ModId), UiTokens.Info);
AddPlanSection("mod_manager/collection_enable_count", _plan.Enable, UiTokens.Warning);
AddPlanSection("mod_manager/collection_missing", _plan.Missing, UiTokens.Danger, findInCatalog: true);
AddPlanSection("mod_manager/collection_mismatch", _plan.Mismatches, UiTokens.Warning, findInCatalog: true);
UpdateApplyButton();
}
private void AddPlanSection(string captionKey, IEnumerable ids, Color color, bool findInCatalog = false)
{
var list = ids?.ToList();
if (list == null || list.Count == 0)
return;
_detailExtra.AddChild(UiKit.Label(
string.Format(TranslationServer.Translate(captionKey), list.Count), UiTokens.FontBodySmall, color));
foreach (var id in list)
{
if (!findInCatalog)
{
_detailExtra.AddChild(UiKit.Label($" {id}", UiTokens.FontSmall, UiTokens.Body));
continue;
}
var row = UiKit.Row(UiTokens.Space2);
var label = UiKit.Label($" {id}", UiTokens.FontSmall, UiTokens.Body);
label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(label);
row.AddChild(MakeInspectorButton(
TranslationServer.Translate("mod_manager/collection_find_in_catalog"),
() => OpenManagerRequested?.Invoke(id)));
_detailExtra.AddChild(row);
}
}
private void UpdateApplyButton()
{
// The mockup rule: "Apply collection" is disabled while problems
// (missing mods or resolution errors) are on the board.
var canApply = !_installing
&& _plan != null
&& !_plan.IsEmpty
&& !_plan.HasProblems
&& CountErrors() == 0;
_apply.Disabled = !canApply;
if (_buildApply != null)
_buildApply.Disabled = !canApply;
UpdateBuildBar();
}
///
/// Refreshes the bottom build bar: current game name, install/enable/
/// problems counters and the hint. Safe during BindSceneNodes — every
/// field is guarded.
///
private void UpdateBuildBar()
{
if (_buildName == null)
return;
_buildName.Text = CurrentCollectionTitle ?? "—";
var install = _plan?.Install.Count ?? 0;
var enable = _plan?.Enable.Count ?? 0;
var problems = CountErrors();
var blocked = problems > 0
|| (_plan?.Missing.Count ?? 0) > 0
|| (_plan?.Mismatches.Count ?? 0) > 0;
_buildInstall.Text = install.ToString();
_buildEnable.Text = enable.ToString();
_buildEnable.AddThemeColorOverride("font_color", enable > 0 ? UiTokens.Warning : UiTokens.InkBright);
_buildProblems.Text = problems.ToString();
_buildProblems.AddThemeColorOverride("font_color", problems > 0 ? UiTokens.DangerText : UiTokens.InkBright);
_buildHint.Text = TranslationServer.Translate(blocked
? "mod_manager/build_hint_problems"
: _overrides.Count > 0 ? "mod_manager/collection_unsaved" : "mod_manager/collection_plan_ready");
_buildHint.AddThemeColorOverride("font_color", blocked ? UiTokens.Warning : UiTokens.SuccessText);
}
private void AddInfoLine(string text)
{
if (string.IsNullOrEmpty(text))
return;
_detailExtra.AddChild(UiKit.Label(text, UiTokens.FontSmall, UiTokens.Muted));
}
private string BuildCollectionMeta(ModCollection collection)
{
var parts = new List { TranslationServer.Translate(OriginKey(collection.Origin)) };
if (!string.IsNullOrEmpty(collection.Version))
parts.Add($"v{collection.Version}");
if (!string.IsNullOrEmpty(collection.Author))
parts.Add(collection.Author);
if (collection.Provides?.Count > 0)
parts.Add("+" + string.Join(", ", collection.Provides));
if (collection.Requires?.Count > 0)
parts.Add("!" + string.Join(", ", collection.Requires));
return string.Join(" · ", parts);
}
private static string OriginKey(ModCollectionOrigin origin) => origin switch
{
ModCollectionOrigin.User => "mod_manager/collection_origin_user",
ModCollectionOrigin.Catalog => "mod_manager/collection_origin_catalog",
_ => "mod_manager/collection_origin_builtin",
};
private static string LookupModDescription(string modId)
{
if (ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId, out var info) && !string.IsNullOrEmpty(info.LocalizedDescription))
return info.LocalizedDescription;
var entry = CatalogService.CachedEntries?.FirstOrDefault(e => string.Equals(e.Id, modId, StringComparison.OrdinalIgnoreCase));
if (entry != null && !string.IsNullOrEmpty(entry.Description))
return entry.Description;
return "—";
}
private Button MakeInspectorButton(string text, Action action, bool danger = false)
{
var button = UiKit.Button(text, danger ? ButtonKind.Danger : ButtonKind.Secondary);
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
button.Pressed += action;
return button;
}
private static string roleOrNull(string role)
=> string.IsNullOrEmpty(role) ? null : role;
private static void ClearChildren(Node node)
{
foreach (var child in node.GetChildren())
{
node.RemoveChild(child);
child.QueueFree();
}
}
private ModCollectionGroup FindGroup(string groupKey)
{
if (_graph?.Groups == null || string.IsNullOrEmpty(groupKey))
return null;
return _graph.Groups.FirstOrDefault(g => string.Equals(g.Key, groupKey, StringComparison.OrdinalIgnoreCase));
}
/* ═══════════════ Dialogs ═══════════════ */
private void OpenRenameDialog(ModCollectionGroup group)
{
var include = group.IsInclude ? FindInclude(group) : null;
var mechanic = group.IsInclude ? null : FindMechanic(group);
if (group.IsInclude && include == null)
return;
if (!group.IsInclude && mechanic == null)
return;
var nameEdit = new LineEdit { Text = group.Name ?? string.Empty };
var noteEdit = new TextEdit { Text = group.Description ?? string.Empty, CustomMinimumSize = new Vector2(460, 90) };
var content = UiKit.Column(UiTokens.Space2);
content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_name"), UiTokens.FontTiny, UiTokens.Muted));
content.AddChild(nameEdit);
content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_note"), UiTokens.FontTiny, UiTokens.Muted));
content.AddChild(noteEdit);
var dialog = new AcceptDialog
{
Title = TranslationServer.Translate("mod_manager/collection_rename_title"),
OkButtonText = TranslationServer.Translate("mod_manager/collection_save"),
Exclusive = true,
};
dialog.AddChild(content);
dialog.Confirmed += () =>
{
ApplyRename(group, nameEdit.Text, noteEdit.Text);
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(520, 300));
}
private void ApplyRename(ModCollectionGroup group, string name, string note)
{
if (group.IsInclude)
{
var include = FindInclude(group);
if (include == null)
return;
include.Label = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
include.Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim();
}
else
{
var mechanic = FindMechanic(group);
if (mechanic == null)
return;
mechanic.Name = name?.Trim();
mechanic.Description = note?.Trim();
}
Rebuild();
}
private void OpenMechanicPicker(ModCollectionGroup replaceTarget, bool replace)
{
var content = UiKit.Column(UiTokens.Space2);
var search = new LineEdit { PlaceholderText = TranslationServer.Translate("mod_manager/collection_search_mechanic") };
content.AddChild(search);
var list = new VBoxContainer();
list.SizeFlagsHorizontal = SizeFlags.ExpandFill;
var scroll = new ScrollContainer { CustomMinimumSize = new Vector2(520, 340), HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
scroll.AddChild(list);
content.AddChild(scroll);
var dialog = new AcceptDialog
{
Title = TranslationServer.Translate(replace
? "mod_manager/collection_pick_replace"
: "mod_manager/collection_pick_mechanic"),
OkButtonText = TranslationServer.Translate("mod_manager/back"),
Exclusive = true,
};
dialog.AddChild(content);
void Fill()
{
ClearChildren(list);
var query = search.Text?.Trim().ToLowerInvariant() ?? string.Empty;
if (!replace)
{
list.AddChild(BuildPickerRow(
TranslationServer.Translate("mod_manager/collection_empty_mechanic"),
TranslationServer.Translate("mod_manager/collection_empty_mechanic_desc"),
TranslationServer.Translate("mod_manager/collection_create"),
() =>
{
CreateEmptyMechanic();
dialog.QueueFree();
}));
}
var used = new HashSet(_graph?.Groups.Select(g => g.CollectionId) ?? new List(), StringComparer.OrdinalIgnoreCase);
var language = TranslationServer.GetLocale();
var candidates = BuildCollectionIndex().Values
.Where(c => c.Kind != ModCollectionKind.Game)
.Where(c => !used.Contains(c.Id) || (replace && string.Equals(c.Id, replaceTarget?.CollectionId, StringComparison.OrdinalIgnoreCase)))
.Where(c => string.IsNullOrEmpty(query) || c.GetLocalizedName(language).ToLowerInvariant().Contains(query))
.OrderBy(c => c.GetLocalizedName(language), StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var candidate in candidates)
{
var target = candidate;
list.AddChild(BuildPickerRow(
target.GetLocalizedName(language),
target.GetLocalizedDescription(language),
TranslationServer.Translate(replace
? "mod_manager/collection_replace"
: "mod_manager/collection_choose"),
() =>
{
ApplyMechanicChoice(replaceTarget, replace, target);
dialog.QueueFree();
}));
}
}
search.TextChanged += _ => Fill();
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(560, 460));
Fill();
}
private Control BuildPickerRow(string title, string description, string buttonText, Action action)
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
var column = UiKit.Column(UiTokens.Space1);
column.SizeFlagsHorizontal = SizeFlags.ExpandFill;
column.AddChild(UiKit.Label(title, UiTokens.FontBody, UiTokens.InkBright));
if (!string.IsNullOrEmpty(description))
column.AddChild(UiKit.Label(description, UiTokens.FontSmall, UiTokens.Muted));
row.AddChild(column);
var button = UiKit.Button(buttonText, ButtonKind.Primary);
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
button.Pressed += action;
row.AddChild(button);
return row;
}
private void CreateEmptyMechanic()
{
var draft = Draft(_currentId);
if (draft == null)
return;
draft.Mechanics ??= new List();
draft.Mechanics.Add(new ModCollectionMechanic
{
Id = UniqueMechanicId(draft),
Name = TranslationServer.Translate("mod_manager/collection_new_mechanic_name"),
Role = RoleExtra,
Description = string.Empty,
Mods = new List(),
});
Rebuild();
}
private void ApplyMechanicChoice(ModCollectionGroup replaceTarget, bool replace, ModCollection candidate)
{
var draft = Draft(_currentId);
if (draft == null)
return;
if (replace && replaceTarget != null)
{
var include = FindInclude(replaceTarget);
if (include == null)
return;
include.Ref = candidate.Id;
include.Label = null;
include.Note = null;
include.Selected = null;
Rebuild();
return;
}
draft.Includes ??= new List();
draft.Includes.Add(new ModCollectionInclude
{
Ref = candidate.Id,
Role = GuessRole(candidate),
Optional = false,
});
Rebuild();
}
private static readonly string[] KnownRoles = { "systems", "foundation", "battle", "story", "ui", "content", "locale" };
private static string GuessRole(ModCollection collection)
{
foreach (var role in KnownRoles)
{
if (collection.Tags != null && collection.Tags.Contains(role, StringComparer.OrdinalIgnoreCase))
return role;
}
return RoleExtra;
}
private static string UniqueMechanicId(ModCollection collection)
{
var index = 1;
while (collection.Mechanics.Any(m => string.Equals(m.Id, $"extra_{index}", StringComparison.OrdinalIgnoreCase)))
index++;
return $"extra_{index}";
}
private void OpenModPicker(ModCollectionGroup group)
{
if (group == null)
return;
var child = group.IsInclude ? EffectiveCollection(group.CollectionId) : null;
var targetSelect = new OptionButton();
if (group.IsInclude)
{
foreach (var mechanic in child?.Mechanics ?? new List())
targetSelect.AddItem(mechanic.Name ?? mechanic.Id);
targetSelect.AddItem(TranslationServer.Translate("mod_manager/collection_new_extra"));
}
var content = UiKit.Column(UiTokens.Space2);
if (group.IsInclude)
{
content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_where"), UiTokens.FontTiny, UiTokens.Muted));
content.AddChild(targetSelect);
}
var search = new LineEdit { PlaceholderText = TranslationServer.Translate("mod_manager/collection_search_mod") };
content.AddChild(search);
var list = new VBoxContainer();
var scroll = new ScrollContainer { CustomMinimumSize = new Vector2(520, 320), HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
scroll.AddChild(list);
content.AddChild(scroll);
var dialog = new AcceptDialog
{
Title = TranslationServer.Translate("mod_manager/collection_add_mod"),
OkButtonText = TranslationServer.Translate("mod_manager/back"),
Exclusive = true,
};
dialog.AddChild(content);
void Fill()
{
ClearChildren(list);
var query = search.Text?.Trim().ToLowerInvariant() ?? string.Empty;
var used = new HashSet(_graph?.Nodes.Select(n => n.ModId) ?? new List(), StringComparer.OrdinalIgnoreCase);
foreach (var choice in BuildModChoices(used, query))
{
var picked = choice;
list.AddChild(BuildPickerRow(
picked.Title,
picked.Description,
TranslationServer.Translate("mod_manager/collection_choose"),
() =>
{
ApplyModChoice(group, picked, targetSelect);
dialog.QueueFree();
}));
}
}
search.TextChanged += _ => Fill();
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(560, 440));
Fill();
}
///
/// Mods that can still be added to the current scheme: everything known
/// to the registry plus catalog entries, minus the ids already resolved,
/// filtered by the search text. Ordered by title so the picker is stable.
///
private List BuildModChoices(HashSet used, string query)
{
var choices = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var info in ModRegistry.Instance.AllDiscoveredMods.Values)
{
if (used.Contains(info.Id))
continue;
choices[info.Id] = new ModChoice
{
Id = info.Id,
Title = info.DisplayTitle,
Description = info.LocalizedDescription,
};
}
foreach (var entry in CatalogService.CachedEntries ?? new List())
{
if (used.Contains(entry.Id) || choices.ContainsKey(entry.Id))
continue;
choices[entry.Id] = new ModChoice
{
Id = entry.Id,
Title = entry.GetLocalizedName(TranslationServer.GetLocale()),
Description = entry.GetLocalizedDescription(TranslationServer.GetLocale()),
};
}
return choices.Values
.Where(c => string.IsNullOrEmpty(query) || c.Title.ToLowerInvariant().Contains(query))
.OrderBy(c => c.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void ApplyModChoice(ModCollectionGroup group, ModChoice choice, OptionButton targetSelect)
{
ModCollection collection;
ModCollectionMechanic mechanic;
if (group.IsInclude)
{
collection = DraftForEdit(group, group.CollectionId);
if (collection == null)
return;
collection.Mechanics ??= new List();
var selected = targetSelect.ItemCount == 0 ? -1 : targetSelect.Selected;
if (selected >= 0 && selected < collection.Mechanics.Count)
{
mechanic = collection.Mechanics[selected];
}
else
{
mechanic = new ModCollectionMechanic
{
Id = UniqueMechanicId(collection),
Name = TranslationServer.Translate("mod_manager/collection_extra_name"),
Role = RoleExtra,
Description = TranslationServer.Translate("mod_manager/collection_extra_note"),
Mods = new List(),
};
collection.Mechanics.Add(mechanic);
}
}
else
{
collection = Draft(group.CollectionId);
mechanic = collection?.Mechanics?.FirstOrDefault(m =>
string.Equals(m.Id, group.MechanicId, StringComparison.OrdinalIgnoreCase));
}
if (mechanic == null)
return;
mechanic.Mods ??= new List();
if (!mechanic.Mods.Any(m => string.Equals(ParseModId(m), choice.Id, StringComparison.OrdinalIgnoreCase)))
mechanic.Mods.Add(choice.Id);
Rebuild();
}
private void OpenSaveDialog()
{
var draft = EffectiveCollection(_currentId);
if (draft == null)
return;
var nameEdit = new LineEdit { Text = draft.Name ?? string.Empty };
var content = UiKit.Column(UiTokens.Space2);
content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_name"), UiTokens.FontTiny, UiTokens.Muted));
content.AddChild(nameEdit);
var dialog = new AcceptDialog
{
Title = TranslationServer.Translate("mod_manager/collection_save"),
OkButtonText = TranslationServer.Translate("mod_manager/collection_save"),
Exclusive = true,
};
dialog.AddChild(content);
dialog.Confirmed += () =>
{
draft.Name = nameEdit.Text?.Trim();
SaveDrafts();
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(480, 200));
}
private void SaveDrafts()
{
if (_overrides.Count == 0)
{
_statusMessage = TranslationServer.Translate("mod_manager/collection_no_changes");
UpdateBanner();
return;
}
var user = ModCollectionStore.LoadUser();
foreach (var pair in _overrides)
{
var draft = pair.Value;
draft.Origin = ModCollectionOrigin.User;
user.RemoveAll(c => string.Equals(c.Id, draft.Id, StringComparison.OrdinalIgnoreCase));
user.Add(draft);
}
ModCollectionStore.SaveUser(user);
var duplicates = _overrides.Keys
.Where(id => _duplicatedFrom.ContainsKey(id) && SaveService.ListSaves(_duplicatedFrom[id]).Count > 0)
.Select(id => (Copy: id, Source: _duplicatedFrom[id]))
.ToList();
_overrides.Clear();
_duplicatedFrom.Clear();
// A game's mod set is computed at launch: edits apply on the next start.
_statusMessage = TranslationServer.Translate("mod_manager/collection_save_ok") + " "
+ TranslationServer.Translate("mod_manager/collection_next_launch");
ReloadCollections();
foreach (var (copy, source) in duplicates)
OfferSavesCopy(source, copy);
}
private void ResetDrafts()
{
if (_overrides.Count == 0)
{
_statusMessage = TranslationServer.Translate("mod_manager/collection_no_changes");
UpdateBanner();
return;
}
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/collection_reset"),
DialogText = TranslationServer.Translate("mod_manager/collection_reset_confirm"),
OkButtonText = TranslationServer.Translate("mod_manager/collection_reset"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
dialog.Confirmed += () =>
{
_overrides.Clear();
_selectedGroupKey = null;
_selectedModId = null;
_statusMessage = null;
ReloadCollections();
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
///
/// Shows the current collection as JSON and lets the player edit it:
/// "Apply" parses the text into the draft (the id must stay the same),
/// so documents can be fixed or extended beyond what the canvas offers.
///
private void OpenJsonDialog()
{
var collection = EffectiveCollection(_currentId);
if (collection == null)
return;
var view = new CodeEdit
{
Text = JsonSerializer.Serialize(collection, JsonOptionsProvider.WriteIndentedOptions),
Editable = true,
GuttersDrawLineNumbers = true,
SyntaxHighlighter = SourceSyntax.CreateJsonHighlighter(),
IndentUseSpaces = true,
IndentSize = 2,
CustomMinimumSize = new Vector2(620, 420),
};
view.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/collection_json"),
OkButtonText = TranslationServer.Translate("mod_manager/collection_json_apply"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
dialog.AddChild(view);
dialog.Confirmed += () =>
{
var error = ApplyJson(view.Text);
dialog.QueueFree();
if (error != null)
ShowNotice(TranslationServer.Translate("mod_manager/collection_json"), error);
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(700, 520));
}
/// Replaces the current draft with parsed JSON; returns an error message or null.
private string ApplyJson(string text)
{
ModCollection parsed;
try
{
parsed = JsonSerializer.Deserialize(text, JsonOptionsProvider.ReadOptions);
}
catch (JsonException ex)
{
return string.Format(TranslationServer.Translate("mod_manager/collection_json_invalid"), ex.Message);
}
if (parsed == null || !string.Equals(parsed.Id, _currentId, StringComparison.OrdinalIgnoreCase))
return TranslationServer.Translate("mod_manager/collection_json_id_changed");
parsed.Origin = ModCollectionOrigin.User;
parsed.Package = OriginalCollection(_currentId)?.Package;
_overrides[_currentId] = parsed;
RebuildGameSelect();
Rebuild();
return null;
}
/// Copies the current game under a new id (its saves are offered on save).
private void OnDuplicatePressed()
{
var source = EffectiveCollection(_currentId);
if (source == null)
return;
var copy = CloneCollection(source);
copy.Id = UniqueGameId();
copy.Kind = source.Kind;
copy.Origin = ModCollectionOrigin.User;
copy.Package = source.Package;
copy.Name = string.Format(TranslationServer.Translate("mod_manager/collection_copy_name"),
CollectionText.Resolve(source.GetLocalizedName(TranslationServer.GetLocale())));
copy.Locale = null;
_overrides[copy.Id] = copy;
_duplicatedFrom[copy.Id] = source.Id;
_currentId = copy.Id;
_selectedGroupKey = null;
_selectedModId = null;
RebuildGameSelect();
Rebuild();
OpenSaveDialog();
}
private void OfferSavesCopy(string sourceId, string copyId)
{
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/collection_duplicate"),
DialogText = string.Format(TranslationServer.Translate("mod_manager/collection_copy_saves"), SaveService.ListSaves(sourceId).Count),
OkButtonText = TranslationServer.Translate("mod_manager/collection_copy_saves_ok"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
dialog.Confirmed += () =>
{
SaveService.CopyGameSaves(sourceId, copyId);
dialog.QueueFree();
UpdateHeader();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
/// Drops the player's version of a shipped collection after confirmation.
private void OnRevertPressed()
{
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/collection_revert"),
DialogText = TranslationServer.Translate("mod_manager/collection_revert_confirm"),
OkButtonText = TranslationServer.Translate("mod_manager/collection_revert"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
dialog.Confirmed += () =>
{
ModCollectionStore.RemoveUser(_currentId);
_overrides.Remove(_currentId);
_statusMessage = TranslationServer.Translate("mod_manager/collection_revert_done") + " "
+ TranslationServer.Translate("mod_manager/collection_next_launch");
dialog.QueueFree();
ReloadCollections();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
///
/// Deletes the current player-made game after confirmation (its unsaved
/// draft goes too) and switches to the first remaining game.
///
private void OnDeletePressed()
{
var deletedId = _currentId;
CollectionDeletion.Confirm(this, EffectiveCollection(deletedId), () =>
{
_overrides.Remove(deletedId);
_duplicatedFrom.Remove(deletedId);
_currentId = null;
_selectedGroupKey = null;
_selectedModId = null;
_statusMessage = TranslationServer.Translate("mod_manager/collection_delete_done");
ReloadCollections();
});
}
private void ShowNotice(string title, string text)
{
var notice = new AcceptDialog { Title = title, DialogText = text, Exclusive = true };
notice.Confirmed += notice.QueueFree;
notice.Canceled += notice.QueueFree;
AddChild(notice);
notice.PopupCentered();
}
///
/// Copy-on-write for shared blocks: an edit inside an included
/// collection must not change every game that includes it. The first
/// edit forks the block into "<block>.<game>" (a component
/// owned by this game) and repoints the include; later edits reuse it.
///
private ModCollection DraftForEdit(ModCollectionGroup group, string collectionId)
{
if (!group.IsInclude || string.Equals(collectionId, _currentId, StringComparison.OrdinalIgnoreCase))
return Draft(collectionId);
if (!string.Equals(collectionId, group.CollectionId, StringComparison.OrdinalIgnoreCase))
{
_statusMessage = TranslationServer.Translate("mod_manager/collection_nested_readonly");
UpdateBanner();
return null;
}
var include = FindInclude(group);
if (include == null)
return null;
var forkId = $"{collectionId}.{_currentId}";
if (EffectiveCollection(forkId) == null)
{
var source = EffectiveCollection(collectionId);
if (source == null)
return null;
var fork = CloneCollection(source);
fork.Id = forkId;
fork.Kind = ModCollectionKind.Component;
fork.Origin = ModCollectionOrigin.User;
fork.Package = source.Package;
_overrides[forkId] = fork;
_statusMessage = string.Format(TranslationServer.Translate("mod_manager/collection_forked"),
CollectionText.Resolve(source.GetLocalizedName(TranslationServer.GetLocale())));
UpdateBanner();
}
include.Ref = forkId;
if (string.Equals(_selectedGroupKey, group.Key, StringComparison.OrdinalIgnoreCase))
_selectedGroupKey = $"i:{group.ParentCollectionId}:{forkId}";
return Draft(forkId);
}
/* ═══════════════ Actions ═══════════════ */
private void OnAddMechanicPressed() => OpenMechanicPicker(null, false);
private void OnClosePressed() => QueueFree();
private void OnRefreshPressed() => RefreshCatalog();
private async void RefreshCatalog()
{
if (GodotObject.IsInstanceValid(_refresh))
_refresh.Disabled = true;
UpdateBanner();
try
{
await CatalogService.RefreshAsync(forceRefresh: true);
}
catch (Exception ex)
{
GameLogger.Log("MOD_COLLECTIONS", $"Catalog refresh failed: {ex.Message}", LogLevel.Warning);
}
if (!GodotObject.IsInstanceValid(this))
return;
if (GodotObject.IsInstanceValid(_refresh))
_refresh.Disabled = false;
UpdateBanner();
}
private void OnCatalogRefreshed()
{
if (GodotObject.IsInstanceValid(this))
ReloadCollections();
}
private void OnInstallProgress(InstallProgress progress)
{
if (!_installing || progress == null)
return;
SetBanner(progress.Message);
}
private async void OnApplyPressed()
{
if (_installing || _plan == null || _plan.IsEmpty
|| _plan.HasProblems || CountErrors() > 0)
return;
var plan = _plan;
_installing = true;
_statusMessage = null;
SetBanner(string.Format(TranslationServer.Translate("mod_manager/collection_progress"),
EffectiveCollection(_currentId)?.DisplayTitle ?? _currentId));
UpdateApplyButton();
var failed = !await ModCollectionInstaller.ApplyAsync(plan, _sessionInstalled);
if (!failed)
{
_restartRequired = true;
_statusMessage = null;
}
else
{
_statusMessage = TranslationServer.Translate("mod_manager/collection_apply_failed");
}
_installing = false;
if (!GodotObject.IsInstanceValid(this))
return;
Rebuild();
}
private void StartCatalogRefreshIfNeeded()
{
if (CatalogService.CachedEntries == null && ModConfig.Instance.CatalogUrls.Count > 0)
RefreshCatalog();
else
UpdateBanner();
}
private void UpdateBanner()
{
string text;
if (_restartRequired)
text = TranslationServer.Translate("mod_manager/restart_required");
else if (CatalogService.IsFetching)
text = TranslationServer.Translate("mod_manager/loading");
else
text = _statusMessage;
SetBanner(text);
}
private void SetBanner(string text)
{
if (!GodotObject.IsInstanceValid(_bannerPanel))
return;
_bannerPanel.Visible = !string.IsNullOrEmpty(text);
if (!string.IsNullOrEmpty(text))
_bannerLabel.Text = text;
}
}
}