This commit is contained in:
iisaev committed 2026-10-18 12:41:39 +03:00
commit e8e0c5159d
46 files changed
+7923

No files matched your search

+511
View File
@@ -0,0 +1,511 @@
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);
_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,
null,
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();
}
}
}
}