using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Cthangover.Core.Mods; using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; using Godot; using Cthangover.Mods.Interface.Theming; namespace Mods.ModManager { /// /// Level 1 of the collections tab: the gallery of ready-to-play games from /// the hub mockup. One card per /// collection, built exclusively from /// over the live /// merge — there are no hardcoded /// collection ids, so any mod, catalog or player document appears here /// automatically. Every card shows the source badge from /// , status badges derived from the /// summary (draft, missing, problems, enable, ready), the Installed/Total /// progress bar and its action buttons; the plan is applied through /// exactly like the scheme editor does. /// /// The whole layout is code-built (no .tscn) following the UiKit helper /// style; a dashed "build your own game" card and the empty state with a /// hint finish the screen. /// public partial class CollectionsGallery : ScrollContainer { /// Requested to open the scheme editor for the collection id. public event Action EditSchemeRequested; /// Requested to create a fresh player game in the editor. public event Action NewGameRequested; /// Progress/status line for the hosting hub banner; null hides it. public event Action StatusMessage; private VBoxContainer _host; private HFlowContainer _grid; private VBoxContainer _emptyBox; private Label _emptyHint; private readonly Dictionary _index = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _plans = new(StringComparer.OrdinalIgnoreCase); private readonly List _summaries = new(); private HashSet _sessionInstalled = new(StringComparer.OrdinalIgnoreCase); private string _query = string.Empty; private string _installingId; public CollectionsGallery() { SizeFlagsHorizontal = SizeFlags.ExpandFill; SizeFlagsVertical = SizeFlags.ExpandFill; HorizontalScrollMode = ScrollMode.Disabled; VerticalScrollMode = ScrollMode.Auto; var margin = new MarginContainer(); margin.AddThemeConstantOverride("margin_left", UiTokens.Space8); margin.AddThemeConstantOverride("margin_top", UiTokens.Space6); margin.AddThemeConstantOverride("margin_right", UiTokens.Space8); margin.AddThemeConstantOverride("margin_bottom", UiTokens.Space7); margin.SizeFlagsHorizontal = SizeFlags.ExpandFill; AddChild(margin); _host = UiKit.Column(UiTokens.Space5); _host.SizeFlagsHorizontal = SizeFlags.ExpandFill; margin.AddChild(_host); BuildCrumbs(); BuildHead(); BuildGrid(); BuildEmptyState(); ModDownloader.OnProgress += OnInstallProgress; CatalogService.OnRefreshed += OnCatalogRefreshed; } /// Unsubscribes the static events; the gallery is freed together with the hub. public override void _ExitTree() { ModDownloader.OnProgress -= OnInstallProgress; CatalogService.OnRefreshed -= OnCatalogRefreshed; } /// Shares the hub-wide set of mods installed during this session. public void SetSessionInstalled(HashSet sessionInstalled) => _sessionInstalled = sessionInstalled ?? new HashSet(StringComparer.OrdinalIgnoreCase); /// Applies the hub header search query; empty text shows everything. public void ApplyFilter(string query) { _query = query ?? string.Empty; RenderGrid(); } /// /// Reloads the merged collection store, rebuilds the summaries through /// the core gallery and the per-game apply plans, then re-renders. /// Called on open, after a catalog refresh and after applying a plan. /// public void Refresh() { _index.Clear(); foreach (var collection in ModCollectionStore.LoadAll()) { if (!string.IsNullOrEmpty(collection?.Id)) _index[collection.Id] = collection; } var catalog = CatalogService.CachedEntries ?? new List(); var summaries = ModCollectionGallery.Build( _index, ModRegistry.Instance.AllDiscoveredMods, DisabledModsStore.Load(), catalog, _sessionInstalled, CollectionText.Resolve, TranslationServer.GetLocale()); _summaries.Clear(); _plans.Clear(); foreach (var summary in summaries) { _summaries.Add(summary); _plans[summary.Collection.Id] = BuildPlan(summary, catalog); } RenderGrid(); } /* ═══════════════ Static skeleton ═══════════════ */ private void BuildCrumbs() { var row = UiKit.Row(UiTokens.Space2); row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/hub_tab_collections"), UiTokens.FontBodySmall, UiTokens.Faint)); row.AddChild(UiKit.Label("›", UiTokens.FontBodySmall, UiTokens.Faint)); row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_crumb"), UiTokens.FontBodySmall, UiTokens.Body)); row.AddChild(UiKit.Label("·", UiTokens.FontBodySmall, UiTokens.Faint)); row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_hint"), UiTokens.FontBodySmall, UiTokens.Faint)); _host.AddChild(row); } private void BuildHead() { var row = UiKit.Row(UiTokens.Space3); row.AddChild(UiKit.SectionTitle(TranslationServer.Translate("mod_manager/gallery_title"), UiTokens.FontLead)); row.AddChild(UiKit.Spacer()); var newGame = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_new_game"), ButtonKind.Primary); newGame.Pressed += () => NewGameRequested?.Invoke(); row.AddChild(newGame); _host.AddChild(row); } private void BuildGrid() { _grid = new HFlowContainer(); _grid.AddThemeConstantOverride("h_separation", UiTokens.Space5); _grid.AddThemeConstantOverride("v_separation", UiTokens.Space5); _grid.SizeFlagsHorizontal = SizeFlags.ExpandFill; _grid.SizeFlagsVertical = SizeFlags.ExpandFill; _host.AddChild(_grid); } private void BuildEmptyState() { _emptyBox = UiKit.Column(UiTokens.Space2); _emptyBox.SizeFlagsHorizontal = SizeFlags.ExpandFill; _emptyBox.SizeFlagsVertical = SizeFlags.ExpandFill; _emptyBox.Alignment = BoxContainer.AlignmentMode.Center; _emptyBox.Visible = false; var title = UiKit.EmptyState(TranslationServer.Translate("mod_manager/collections_empty"), UiTokens.FontLead); _emptyHint = UiKit.Label(TranslationServer.Translate("mod_manager/gallery_empty_hint"), UiTokens.FontBodySmall, UiTokens.Faint); _emptyHint.HorizontalAlignment = HorizontalAlignment.Center; _emptyHint.AutowrapMode = TextServer.AutowrapMode.WordSmart; _emptyBox.AddChild(title); _emptyBox.AddChild(_emptyHint); _host.AddChild(_emptyBox); } /* ═══════════════ Cards ═══════════════ */ private void RenderGrid() { ClearChildren(_grid); var items = Filtered(); _emptyBox.Visible = items.Count == 0; _grid.Visible = items.Count > 0; foreach (var summary in items) _grid.AddChild(BuildCard(summary)); } private List Filtered() { var query = _query.Trim(); if (query.Length == 0) return _summaries; return _summaries .Where(s => (s.Name ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase) || (s.Description ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase) || (s.Collection?.Id ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase) || MatchesTags(s.Collection?.Tags, query)) .ToList(); } private static bool MatchesTags(List tags, string query) { if (tags == null) return false; foreach (var tag in tags) { if (!string.IsNullOrEmpty(tag) && tag.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; } return false; } private Control BuildCard(ModCollectionGameSummary summary) { var card = new PanelContainer { CustomMinimumSize = new Vector2(360, 0) }; card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface2, UiTokens.Line)); var column = UiKit.Column(UiTokens.Space3); column.SizeFlagsHorizontal = SizeFlags.ExpandFill; card.AddChild(column); var top = UiKit.Row(UiTokens.Space3); top.AddChild(BuildCover(summary.Name ?? summary.Collection.Id)); var headColumn = UiKit.Column(2); headColumn.SizeFlagsHorizontal = SizeFlags.ExpandFill; headColumn.AddChild(UiKit.Label(summary.Name ?? summary.Collection.Id, UiTokens.FontBodyLarge, UiTokens.InkBright)); headColumn.AddChild(UiKit.Label(BuildSubtitle(summary), UiTokens.FontTiny, UiTokens.Faint)); top.AddChild(headColumn); column.AddChild(top); var description = UiKit.Label( string.IsNullOrEmpty(summary.Description) ? "—" : summary.Description, UiTokens.FontBodySmall, UiTokens.Muted); description.AutowrapMode = TextServer.AutowrapMode.WordSmart; column.AddChild(description); var badges = UiKit.Row(UiTokens.Space1); foreach (var (text, color) in BuildStatusBadges(summary)) badges.AddChild(UiKit.Badge(text, color)); column.AddChild(badges); if (summary.TotalMods > 0) { var progress = new ProgressBar { MaxValue = 100, Value = 100f * summary.InstalledCount / summary.TotalMods, ShowPercentage = false, CustomMinimumSize = new Vector2(0, 6), }; progress.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Surface1, UiTokens.LineSoft)); progress.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Accent, null)); column.AddChild(progress); } var foot = UiKit.Row(UiTokens.Space2); foot.AddChild(BuildPrimaryButton(summary)); foot.AddChild(BuildEditButton(summary)); if (CollectionDeletion.CanDelete(summary.Collection)) foot.AddChild(BuildDeleteButton(summary)); foot.AddChild(UiKit.Spacer()); foot.AddChild(UiKit.Label( string.Format(TranslationServer.Translate("mod_manager/collection_result"), summary.InstalledCount, summary.TotalMods), UiTokens.FontSmall, UiTokens.Faint)); column.AddChild(foot); return card; } /// Cover box with the first grapheme of the game name (mockup's emoji slot). private static Control BuildCover(string title) { var panel = new PanelContainer { CustomMinimumSize = new Vector2(64, 64) }; panel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.Line)); var letter = "·"; var text = title?.Trim(); if (!string.IsNullOrEmpty(text)) { // First grapheme cluster so surrogate-pair emoji covers survive. var info = new StringInfo(text); if (info.LengthInTextElements > 0) letter = info.SubstringByTextElements(0, 1).ToUpperInvariant(); } var label = UiKit.Label(letter, UiTokens.FontH3, UiTokens.AccentBright); label.HorizontalAlignment = HorizontalAlignment.Center; label.VerticalAlignment = VerticalAlignment.Center; panel.AddChild(label); return panel; } private static string BuildSubtitle(ModCollectionGameSummary summary) { var collection = summary.Collection; var parts = new List(); if (!string.IsNullOrEmpty(collection.Author)) parts.Add(collection.Author); if (!string.IsNullOrEmpty(collection.Version)) parts.Add("v" + collection.Version); parts.Add(TranslationServer.Translate(OriginKey(collection.Origin))); parts.Add($"{summary.TotalMods} {TranslationServer.Translate("mod_manager/collection_mods")}"); return string.Join(" · ", parts); } /// Source badge colours per (blue built-in/catalog, violet player). 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", }; /// /// Status badges exactly per the hub rules: draft for empty games; /// red/yellow "missing N" plus "problems: K" for unplayable ones; /// amber "enable N" when only disabled mods hold the game back; green /// "ready to play" otherwise. A disabled mod alone never breaks /// , so the badges follow /// the summary, not raw node states. /// private static List<(string Text, Color Color)> BuildStatusBadges(ModCollectionGameSummary summary) { var badges = new List<(string, Color)>(); Func translate = key => TranslationServer.Translate(key); if (summary.TotalMods == 0) { badges.Add((translate("mod_manager/gallery_draft"), UiTokens.Warning)); return badges; } if (!summary.IsReady) { var gaps = summary.MissingIds.Count + summary.MismatchIds.Count; if (gaps > 0) { var color = summary.MissingIds.Count > 0 ? UiTokens.DangerText : UiTokens.Warning; badges.Add((string.Format(translate("mod_manager/gallery_missing_n"), gaps), color)); } if (summary.ProblemsCount > 0) badges.Add((string.Format(translate("mod_manager/gallery_problems_n"), summary.ProblemsCount), UiTokens.DangerText)); return badges; } if (summary.PlanInstallCount == 0 && summary.PlanEnableCount > 0) badges.Add((string.Format(translate("mod_manager/gallery_enable_badge_n"), summary.PlanEnableCount), UiTokens.Warning)); else badges.Add((translate("mod_manager/gallery_ready"), UiTokens.SuccessText)); return badges; } /// The card's primary action: install the plan, enable the disabled mods, continue a draft or rest on a ready game. private Button BuildPrimaryButton(ModCollectionGameSummary summary) { Func translate = key => TranslationServer.Translate(key); Button button; Action action; if (summary.PlanInstallCount > 0) { button = UiKit.Button(string.Format(translate("mod_manager/gallery_install_btn_n"), summary.PlanInstallCount), ButtonKind.Primary); action = () => ApplyPlan(summary.Collection.Id); button.Disabled = summary.MissingIds.Count > 0 || _installingId != null; } else if (summary.PlanEnableCount > 0) { button = UiKit.Button(string.Format(translate("mod_manager/gallery_enable_btn_n"), summary.PlanEnableCount), ButtonKind.Primary); action = () => ApplyPlan(summary.Collection.Id); button.Disabled = _installingId != null; } else if (summary.TotalMods == 0) { button = UiKit.Button(translate("mod_manager/gallery_continue")); action = () => EditSchemeRequested?.Invoke(summary.Collection.Id); button.Disabled = _installingId != null; } else { button = UiKit.Button(translate("mod_manager/gallery_ready_done")); action = null; button.Disabled = true; } if (action != null) button.Pressed += action; return button; } private Button BuildEditButton(ModCollectionGameSummary summary) { var button = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_edit_scheme")); button.Disabled = _installingId != null; button.Pressed += () => EditSchemeRequested?.Invoke(summary.Collection.Id); return button; } private Button BuildDeleteButton(ModCollectionGameSummary summary) { var button = UiKit.Button(TranslationServer.Translate("mod_manager/collection_delete"), ButtonKind.Danger); button.Disabled = _installingId != null; button.Pressed += () => CollectionDeletion.Confirm(this, summary.Collection, () => { StatusMessage?.Invoke(TranslationServer.Translate("mod_manager/collection_delete_done")); Refresh(); }); return button; } /* ═══════════════ Apply ═══════════════ */ private ModCollectionPlan BuildPlan(ModCollectionGameSummary summary, List catalog) { var graph = ModCollectionResolver.Resolve( summary.Collection, _index, ModRegistry.Instance.AllDiscoveredMods, DisabledModsStore.Load(), catalog, _sessionInstalled, TranslationServer.GetLocale(), CollectionText.Resolve); return ModCollectionPlan.Build(graph, catalog); } /// /// Applies the plan of one card through the shared runner — the same /// install-then-enable routine the scheme editor performs — and /// refreshes the gallery so badges and buttons reflect the new state. /// private async void ApplyPlan(string collectionId) { if (_installingId != null || !_plans.TryGetValue(collectionId, out var plan) || plan.IsEmpty || plan.Missing.Count > 0) { return; } var summary = _summaries.FirstOrDefault(s => string.Equals(s.Collection?.Id, collectionId, StringComparison.OrdinalIgnoreCase)); var title = summary?.Name ?? collectionId; _installingId = collectionId; StatusMessage?.Invoke(string.Format(TranslationServer.Translate("mod_manager/collection_progress"), title)); RenderGrid(); bool success; try { success = await ModCollectionInstaller.ApplyAsync(plan, _sessionInstalled); } catch (Exception ex) { GameLogger.Log("MOD_HUB", $"Gallery apply '{collectionId}' failed: {ex.Message}", LogLevel.Error); success = false; } _installingId = null; if (!GodotObject.IsInstanceValid(this)) return; StatusMessage?.Invoke(TranslationServer.Translate(success ? "mod_manager/restart_required" : "mod_manager/collection_apply_failed")); Refresh(); } private void OnInstallProgress(InstallProgress progress) { if (_installingId == null || progress == null) return; StatusMessage?.Invoke(progress.Message); } private void OnCatalogRefreshed() { if (GodotObject.IsInstanceValid(this)) Refresh(); } private static void ClearChildren(Node node) { foreach (var child in node.GetChildren()) { node.RemoveChild(child); child.QueueFree(); } } } }