Files
mod_mod_manager/src/UI/CollectionsGallery.cs
T

513 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// Level 1 of the collections tab: the gallery of ready-to-play games from
/// the hub mockup. One card per <see cref="ModCollectionKind.Game"/>
/// collection, built exclusively from
/// <see cref="ModCollectionGallery.Build"/> over the live
/// <see cref="ModCollectionStore.LoadAll"/> merge — there are no hardcoded
/// collection ids, so any mod, catalog or player document appears here
/// automatically. Every card shows the source badge from
/// <see cref="ModCollection.Origin"/>, 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
/// <see cref="ModCollectionInstaller"/> exactly like the scheme editor does.
/// <para/>
/// 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.
/// </summary>
public partial class CollectionsGallery : ScrollContainer
{
/// <summary>Requested to open the scheme editor for the collection id.</summary>
public event Action<string> EditSchemeRequested;
/// <summary>Requested to create a fresh player game in the editor.</summary>
public event Action NewGameRequested;
/// <summary>Progress/status line for the hosting hub banner; null hides it.</summary>
public event Action<string> StatusMessage;
private VBoxContainer _host;
private HFlowContainer _grid;
private VBoxContainer _emptyBox;
private Label _emptyHint;
private readonly Dictionary<string, ModCollection> _index = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ModCollectionPlan> _plans = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ModCollectionGameSummary> _summaries = new();
private HashSet<string> _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;
}
/// <summary>Unsubscribes the static events; the gallery is freed together with the hub.</summary>
public override void _ExitTree()
{
ModDownloader.OnProgress -= OnInstallProgress;
CatalogService.OnRefreshed -= OnCatalogRefreshed;
}
/// <summary>Shares the hub-wide set of mods installed during this session.</summary>
public void SetSessionInstalled(HashSet<string> sessionInstalled)
=> _sessionInstalled = sessionInstalled ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Applies the hub header search query; empty text shows everything.</summary>
public void ApplyFilter(string query)
{
_query = query ?? string.Empty;
RenderGrid();
}
/// <summary>
/// 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.
/// </summary>
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<CatalogExportEntry>();
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<ModCollectionGameSummary> 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<string> 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;
}
/// <summary>Cover box with the first grapheme of the game name (mockup's emoji slot).</summary>
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<string>();
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);
}
/// <summary>Source badge colours per <see cref="ModCollection.Origin"/> (blue built-in/catalog, violet player).</summary>
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",
};
/// <summary>
/// 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
/// <see cref="ModCollectionGameSummary.IsReady"/>, so the badges follow
/// the summary, not raw node states.
/// </summary>
private static List<(string Text, Color Color)> BuildStatusBadges(ModCollectionGameSummary summary)
{
var badges = new List<(string, Color)>();
Func<string, string> 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;
}
/// <summary>The card's primary action: install the plan, enable the disabled mods, continue a draft or rest on a ready game.</summary>
private Button BuildPrimaryButton(ModCollectionGameSummary summary)
{
Func<string, string> 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<CatalogExportEntry> catalog)
{
var graph = ModCollectionResolver.Resolve(
summary.Collection,
_index,
ModRegistry.Instance.AllDiscoveredMods,
DisabledModsStore.Load(),
catalog,
_sessionInstalled,
TranslationServer.GetLocale(),
CollectionText.Resolve);
return ModCollectionPlan.Build(graph, catalog);
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}