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

File diff suppressed because it is too large. Load diff
+371
View File
@@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Mods.ModManager
{
/// <summary>
/// Single entry point of the mod UI (the manifest <c>main_menu</c> button
/// opens this hub instead of two separate screens). A slim header — back
/// button, title and a two-position segment control "Mods | Collections" —
/// hosts the child screens: the embedded <see cref="ModManagerScreen"/>
/// (default tab) and, on the collections tab, the
/// <see cref="CollectionsGallery"/> plus the scheme editor
/// <see cref="ModCollectionsScreen"/> as its second level. The selected
/// tab lives in a static field, so it survives closing and reopening the
/// hub within one game session.
/// <para/>
/// The hub also carries the mockup's cross links: a collection link on a
/// mod card switches to the collections tab, a "not found" problem in the
/// editor jumps back to the mods tab with the id filled into the search
/// box (<see cref="ModManagerScreen.SetSearch"/>), and Escape walks back
/// editor → gallery → mods tab.
/// </summary>
public partial class ModHubScreen : ModWidget
{
private const string TabMods = "mods";
private const string TabCollections = "collections";
private const string ModsScene = "scenes/mod_manager_screen.tscn";
private const string EditorScene = "scenes/mod_collections_screen.tscn";
/// <summary>Active tab, remembered for the whole game session (mockup: "state is remembered").</summary>
private static string _activeTab = TabMods;
private Button _back;
private Label _title;
private HBoxContainer _segment;
private Button _tabMods;
private Button _tabCollections;
private LineEdit _gallerySearch;
private Button _galleryRefresh;
private PanelContainer _bannerPanel;
private Label _bannerLabel;
private Control _body;
private ModManagerScreen _mods;
private CollectionsGallery _gallery;
private ModCollectionsScreen _editor;
private bool _editorOpen;
/// <summary>Mods installed this session, shared by the gallery and the editor so both stay honest before a restart.</summary>
private readonly HashSet<string> _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
CthangoverTheme.Apply(this);
BuildHeader();
BuildBanner();
BuildBody();
ShowTab(_activeTab);
}
/* ═══════════════ Header and body ═══════════════ */
private void BuildHeader()
{
var root = GetNode<VBoxContainer>("Margin/Root");
root.AddThemeConstantOverride("separation", UiTokens.Space3);
var header = UiKit.Row(UiTokens.Space3);
root.AddChild(header);
_back = UiKit.Button(TranslationServer.Translate("mod_manager/hub_back_to_menu"));
_back.Pressed += OnBackPressed;
header.AddChild(_back);
_title = UiKit.Label(TranslationServer.Translate("mod_manager/menu_title"), UiTokens.FontLead, UiTokens.Ink);
_title.Uppercase = true;
header.AddChild(_title);
_segment = UiKit.Row(0);
var segmentPanel = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
segmentPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line));
segmentPanel.AddChild(_segment);
header.AddChild(segmentPanel);
var group = new ButtonGroup();
_tabMods = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_mods"), ButtonKind.Tab);
_tabMods.ToggleMode = true;
_tabMods.ButtonGroup = group;
_tabMods.Pressed += () => ShowTab(TabMods);
_segment.AddChild(_tabMods);
_tabCollections = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"), ButtonKind.Tab);
_tabCollections.ToggleMode = true;
_tabCollections.ButtonGroup = group;
_tabCollections.Pressed += () => ShowTab(TabCollections);
_segment.AddChild(_tabCollections);
header.AddChild(UiKit.Spacer());
_gallerySearch = new LineEdit
{
PlaceholderText = TranslationServer.Translate("mod_manager/gallery_search_placeholder"),
CustomMinimumSize = new Vector2(220, 0),
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
};
_gallerySearch.TextChanged += OnGallerySearchChanged;
header.AddChild(_gallerySearch);
_galleryRefresh = UiKit.Button(TranslationServer.Translate("mod_manager/refresh"));
_galleryRefresh.Pressed += OnGalleryRefreshPressed;
header.AddChild(_galleryRefresh);
}
private void BuildBanner()
{
var root = GetNode<VBoxContainer>("Margin/Root");
_bannerPanel = new PanelContainer { Visible = false };
_bannerPanel.AddThemeStyleboxOverride("panel", ModManagerTheme.MakeBox(
UiTokens.Alpha(UiTokens.Warning, 0.15f),
UiTokens.Alpha(UiTokens.Warning, 0.45f),
UiTokens.RadiusMd, UiTokens.Space4, UiTokens.Space2));
_bannerLabel = UiKit.Label(string.Empty, UiTokens.FontBodySmall, UiTokens.Warning);
_bannerLabel.HorizontalAlignment = HorizontalAlignment.Center;
_bannerPanel.AddChild(_bannerLabel);
root.AddChild(_bannerPanel);
}
private void BuildBody()
{
_body = new Control
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
GetNode<VBoxContainer>("Margin/Root").AddChild(_body);
}
/* ═══════════════ Tabs and levels ═══════════════ */
private void ShowTab(string tab)
{
_activeTab = tab;
_editorOpen = false;
if (tab == TabMods)
{
EnsureMods();
_mods.Visible = true;
if (_gallery != null)
_gallery.Visible = false;
if (_editor != null)
_editor.Visible = false;
}
else
{
EnsureGallery();
_gallery.Visible = true;
if (_mods != null)
_mods.Visible = false;
if (_editor != null)
_editor.Visible = false;
}
UpdateHeader();
}
/// <summary>Leaves the editor back to the games gallery (crumbs, back button, Escape).</summary>
private void ShowGallery()
{
_editorOpen = false;
if (_editor != null)
_editor.Visible = false;
EnsureGallery();
_gallery.Visible = true;
UpdateHeader();
}
/// <summary>
/// Deep link used by the main menu's game picker ("Configure"): shows
/// the collections tab with the scheme editor open on the game.
/// </summary>
public void OpenCollectionEditor(string collectionId) => OpenEditor(collectionId, false);
/// <summary>Opens the editor on the second level of the collections tab.</summary>
private void OpenEditor(string collectionId, bool newGame)
{
EnsureGallery();
EnsureEditor();
_gallery.Visible = false;
if (_mods != null)
_mods.Visible = false;
_editor.Visible = true;
_editorOpen = true;
if (newGame)
_editor.OpenNewGame();
else
_editor.OpenCollection(collectionId);
UpdateHeader();
}
private void UpdateHeader()
{
if (_editorOpen && _editor != null)
{
_back.Text = TranslationServer.Translate("mod_manager/hub_back_to_gallery");
_back.TooltipText = _back.Text;
_title.Text = _editor.CurrentCollectionTitle ?? TranslationServer.Translate("mod_manager/collections_title");
_segment.Visible = false;
_gallerySearch.Visible = false;
_galleryRefresh.Visible = false;
return;
}
_back.Text = TranslationServer.Translate("mod_manager/hub_back_to_menu");
_back.TooltipText = _back.Text;
_title.Text = TranslationServer.Translate("mod_manager/menu_title");
_segment.Visible = true;
_tabMods.ButtonPressed = _activeTab == TabMods;
_tabCollections.ButtonPressed = _activeTab == TabCollections;
var collections = _activeTab == TabCollections;
_gallerySearch.Visible = collections;
_galleryRefresh.Visible = collections;
}
private void OnBackPressed()
{
if (_editorOpen)
ShowGallery();
else
QueueFree();
}
/* ═══════════════ Hosted screens ═══════════════ */
private void EnsureMods()
{
if (_mods != null)
return;
_mods = TscnScenes.LoadAndBuild(ModsScene) as ModManagerScreen;
if (_mods == null)
{
GameLogger.Log("MOD_HUB", $"Failed to build '{ModsScene}'", LogLevel.Error);
return;
}
_mods.Embedded = true;
_body.AddChild(_mods);
_mods.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_mods.EnsureConstructed();
_mods.CollectionsLinkRequested += () => ShowTab(TabCollections);
}
private void EnsureGallery()
{
if (_gallery != null)
return;
_gallery = new CollectionsGallery();
_gallery.SetSessionInstalled(_sessionInstalled);
_gallery.StatusMessage += ShowBanner;
_gallery.EditSchemeRequested += id => OpenEditor(id, newGame: false);
_gallery.NewGameRequested += () => OpenEditor(null, newGame: true);
_body.AddChild(_gallery);
_gallery.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_gallery.Refresh();
}
private void EnsureEditor()
{
if (_editor != null)
return;
_editor = TscnScenes.LoadAndBuild(EditorScene) as ModCollectionsScreen;
if (_editor == null)
{
GameLogger.Log("MOD_HUB", $"Failed to build '{EditorScene}'", LogLevel.Error);
return;
}
_editor.Embedded = true;
_editor.SetSessionInstalled(_sessionInstalled);
_editor.GalleryRequested += ShowGallery;
_editor.OpenManagerRequested += OnOpenManagerRequested;
_editor.ViewChanged += UpdateHeader;
_body.AddChild(_editor);
_editor.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_editor.EnsureConstructed();
}
private void OnOpenManagerRequested(string searchText)
{
ShowTab(TabMods);
_mods?.SetSearch(searchText);
}
/* ═══════════════ Gallery header controls ═══════════════ */
private void OnGallerySearchChanged(string text)
{
_gallery?.ApplyFilter(text);
}
private async void OnGalleryRefreshPressed()
{
if (_galleryRefresh.Disabled)
return;
_galleryRefresh.Disabled = true;
ShowBanner(TranslationServer.Translate("mod_manager/loading"));
try
{
await CatalogService.RefreshAsync(forceRefresh: true);
}
catch (Exception ex)
{
GameLogger.Log("MOD_HUB", $"Catalog refresh failed: {ex.Message}", LogLevel.Warning);
}
if (!GodotObject.IsInstanceValid(this))
return;
_galleryRefresh.Disabled = false;
ShowBanner(null);
}
private void ShowBanner(string text)
{
if (!GodotObject.IsInstanceValid(_bannerPanel))
return;
_bannerPanel.Visible = !string.IsNullOrEmpty(text);
if (!string.IsNullOrEmpty(text))
_bannerLabel.Text = text;
}
/* ═══════════════ Escape chain ═══════════════ */
/// <summary>Escape walks back one level: editor → gallery → mods tab.</summary>
public override void _UnhandledInput(InputEvent @event)
{
if (!@event.IsActionPressed("ui_cancel"))
return;
if (_editorOpen)
{
ShowGallery();
GetViewport().SetInputAsHandled();
}
else if (_activeTab == TabCollections)
{
ShowTab(TabMods);
GetViewport().SetInputAsHandled();
}
}
}
}
+503
View File
@@ -0,0 +1,503 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.ModManager
{
/// <summary>
/// Root of the mod manager screen. Opened from the main menu through the
/// manifest <c>main_menu</c> button as a fullscreen overlay; closing frees the
/// scene. The visual layout is decomposed into editable scenes
/// (<c>mod_filter_panel</c>, <c>mod_grid_view</c>/<c>mod_grid_group</c>,
/// <c>mod_card</c>, <c>mod_detail_view</c>) referenced from
/// <c>scenes/mod_manager_screen.tscn</c>; this class only wires them to the
/// model, runs mod operations (install/update/delete/enable) and reflects that
/// all of them only take effect after a restart.
/// </summary>
public partial class ModManagerScreen : ModWidget
{
private ModManagerModel _model;
private ModFilterState _filter;
private ModGridView _grid;
private ModDetailView _detail;
private ModFilterPanel _filterPanel;
private LineEdit _search;
private OptionButton _sort;
private Label _resultLabel;
private PanelContainer _bannerPanel;
private Label _bannerLabel;
private Button _refreshButton;
private string _installingId;
private bool _restartRequired;
private string _statusMessage;
/// <summary>
/// Merged collection store keyed by id (as produced by
/// <see cref="ModCollectionStore.LoadAll"/>), refreshed together with
/// the model; feeds the "in N collections" badge on every tile.
/// </summary>
private Dictionary<string, ModCollection> _collectionIndex;
/// <summary>
/// When true the screen runs as a tab of the mods hub: its own header
/// close button and title are hidden and closing is handled by the
/// hosting <see cref="ModHubScreen"/>. Must be set before construction;
/// filters, search, install and delete behaviour are unchanged.
/// </summary>
public bool Embedded { get; set; }
/// <summary>Raised when a tile or the detail page asks to jump to the collections tab.</summary>
public event Action CollectionsLinkRequested;
/// <summary>
/// Applies an external search query — collection cross-links jump to
/// this tab with a mod id taken from a scheme problem. Updates the
/// search box and refreshes the grid without touching other filters.
/// </summary>
public void SetSearch(string text)
{
var query = text ?? string.Empty;
_filter.Search = query;
if (GodotObject.IsInstanceValid(_search)
&& !string.Equals(_search.Text, query, StringComparison.Ordinal))
{
_search.Text = query;
}
RefreshView();
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
CthangoverTheme.Apply(this);
_model = new ModManagerModel();
_filter = new ModFilterState();
BindSceneNodes();
SubscribeEvents();
CatalogService.LoadCache();
_model.Reload();
_filterPanel.Bind(_filter);
RefreshAll();
StartCatalogRefreshIfNeeded();
}
protected override void Destruct()
{
UnsubscribeEvents();
}
/// <summary>Fetches the scene-built nodes, sets static captions and hooks the layout controls.</summary>
private void BindSceneNodes()
{
var close = GetNode<Button>("Margin/Root/Header/Close");
var title = GetNode<Label>("Margin/Root/Header/Title");
_search = GetNode<LineEdit>("Margin/Root/Header/Search");
_sort = GetNode<OptionButton>("Margin/Root/Header/Sort");
_refreshButton = GetNode<Button>("Margin/Root/Header/Refresh");
_resultLabel = GetNode<Label>("Margin/Root/Header/Result");
_grid = GetNode<ModGridView>("Margin/Root/Body/Content/Grid");
_detail = GetNode<ModDetailView>("Margin/Root/Body/Content/Detail");
_filterPanel = GetNode<ModFilterPanel>("Margin/Root/Body/Sidebar/SidebarScroll/FilterPanel");
_bannerPanel = GetNode<PanelContainer>("Margin/Root/Banner");
_bannerLabel = GetNode<Label>("Margin/Root/Banner/BannerLabel");
close.Text = TranslationServer.Translate("mod_manager/back");
title.Text = TranslationServer.Translate("mod_manager/title");
if (Embedded)
{
// The hub header owns closing and the title; this row keeps
// only the search/sort/refresh controls.
close.Visible = false;
title.Visible = false;
}
_search.PlaceholderText = TranslationServer.Translate("mod_manager/search_placeholder");
_refreshButton.Text = TranslationServer.Translate("mod_manager/refresh");
_resultLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_name"));
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_author"));
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_status"));
_sort.Selected = (int)_filter.Sort;
_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;
_detail.Visible = false;
close.Pressed += OnClosePressed;
_search.TextChanged += OnSearchChanged;
_sort.ItemSelected += OnSortSelected;
_refreshButton.Pressed += OnRefreshPressed;
_grid.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_detail.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
}
private void SubscribeEvents()
{
_filterPanel.Changed += OnFilterChanged;
_filterPanel.ResetRequested += OnFilterResetRequested;
_grid.CardClicked += OnCardClicked;
_grid.ActionPressed += OnActionPressed;
_grid.EnabledToggled += OnEnabledToggled;
_grid.VersionChanged += OnVersionChanged;
_grid.CollectionsLinkRequested += OnCollectionsLinkRequested;
_detail.BackRequested += OnDetailBackRequested;
_detail.ActionPressed += OnActionPressed;
_detail.DeleteRequested += OnDeleteRequested;
_detail.EnabledToggled += OnEnabledToggled;
_detail.VersionChanged += OnVersionChanged;
_detail.CollectionsLinkRequested += OnCollectionsLinkRequested;
ModDownloader.OnProgress += OnInstallProgress;
CatalogService.OnRefreshed += OnCatalogRefreshed;
}
private void UnsubscribeEvents()
{
_filterPanel.Changed -= OnFilterChanged;
_filterPanel.ResetRequested -= OnFilterResetRequested;
_grid.CardClicked -= OnCardClicked;
_grid.ActionPressed -= OnActionPressed;
_grid.EnabledToggled -= OnEnabledToggled;
_grid.VersionChanged -= OnVersionChanged;
_grid.CollectionsLinkRequested -= OnCollectionsLinkRequested;
_detail.BackRequested -= OnDetailBackRequested;
_detail.ActionPressed -= OnActionPressed;
_detail.DeleteRequested -= OnDeleteRequested;
_detail.EnabledToggled -= OnEnabledToggled;
_detail.VersionChanged -= OnVersionChanged;
_detail.CollectionsLinkRequested -= OnCollectionsLinkRequested;
ModDownloader.OnProgress -= OnInstallProgress;
CatalogService.OnRefreshed -= OnCatalogRefreshed;
}
private void RefreshAll()
{
_collectionIndex = BuildCollectionIndex();
_model.Reload();
RefreshView();
}
/// <summary>Rebuilds the merged collection index from the live store.</summary>
private static Dictionary<string, ModCollection> BuildCollectionIndex()
{
var index = new Dictionary<string, ModCollection>(StringComparer.OrdinalIgnoreCase);
foreach (var collection in ModCollectionStore.LoadAll())
{
if (!string.IsNullOrEmpty(collection?.Id))
index[collection.Id] = collection;
}
return index;
}
/// <summary>Counts playable games referencing the mod; zero when the store is unavailable.</summary>
private int CountGamesUsing(string modId)
{
if (_collectionIndex == null || _collectionIndex.Count == 0 || string.IsNullOrEmpty(modId))
return 0;
return ModCollectionGallery.FindGamesUsingMod(_collectionIndex, modId)
.TryGetValue(modId, out var games) ? games.Count : 0;
}
/// <summary>
/// Rebuilds grid and counters from the current model without re-reading
/// the registry/catalog. Filter, search and sort changes take this cheap
/// path; install/delete/enable and catalog refreshes call
/// <see cref="RefreshAll"/> first.
/// </summary>
private void RefreshView()
{
var items = _filter.Apply(_model.Items);
foreach (var item in items)
item.CollectionsCount = CountGamesUsing(item.Id);
_grid.SetItems(items, _filter.GroupByCategory);
_filterPanel.RefreshCounts(_model.CountCategories(_filter), _model.CountStatuses(_filter));
_resultLabel.Text = string.Format(TranslationServer.Translate("mod_manager/results"), items.Count);
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;
}
/// <summary>Finds the refreshed model row matching a card, including its catalog variant.</summary>
private ModCardData FindItem(ModCardData data)
{
foreach (var item in _model.Items)
{
if (string.Equals(item.MatchKey, data.MatchKey, StringComparison.OrdinalIgnoreCase))
return item;
}
return null;
}
private void StartCatalogRefreshIfNeeded()
{
if (CatalogService.CachedEntries == null && ModConfig.Instance.CatalogUrls.Count > 0)
RefreshCatalog();
else
UpdateBanner();
}
private async void RefreshCatalog()
{
if (GodotObject.IsInstanceValid(_refreshButton))
_refreshButton.Disabled = true;
UpdateBanner();
try
{
await CatalogService.RefreshAsync(forceRefresh: true);
}
catch (Exception ex)
{
GameLogger.Log("MOD_MANAGER", $"Catalog refresh failed: {ex.Message}", LogLevel.Warning);
}
if (!GodotObject.IsInstanceValid(this))
return;
if (GodotObject.IsInstanceValid(_refreshButton))
_refreshButton.Disabled = false;
UpdateBanner();
}
private void OnCatalogRefreshed()
{
if (!GodotObject.IsInstanceValid(this))
return;
RefreshAll();
}
private void OnInstallProgress(InstallProgress progress)
{
if (string.IsNullOrEmpty(_installingId) || progress == null)
return;
SetBanner(progress.Message);
}
private void OnClosePressed()
{
QueueFree();
}
private void OnDetailBackRequested()
{
_detail.Visible = false;
_grid.Visible = true;
}
private void OnCardClicked(ModCardData data)
{
_detail.Open(data);
_detail.Visible = true;
_grid.Visible = false;
}
private void OnSearchChanged(string text)
{
_filter.Search = text;
RefreshView();
}
private void OnSortSelected(long index)
{
_filter.Sort = (ModSortMode)(int)index;
RefreshView();
}
private void OnFilterChanged()
{
RefreshView();
}
private void OnCollectionsLinkRequested()
{
CollectionsLinkRequested?.Invoke();
}
private void OnFilterResetRequested()
{
if (!string.IsNullOrEmpty(_search.Text))
_search.Text = string.Empty;
}
private void OnVersionChanged(ModCardData data)
{
_filterPanel.RefreshCounts(_model.CountCategories(_filter), _model.CountStatuses(_filter));
}
private void OnActionPressed(ModCardData data)
{
var kind = ModActions.GetPrimary(data);
if (kind == null)
return;
ExecuteAction(data, kind.Value);
}
private void OnDeleteRequested(ModCardData data)
{
ExecuteAction(data, ModActionKind.Delete);
}
private void OnEnabledToggled(ModCardData data)
{
_model.SetEnabled(data.Id, data.IsDisabled);
_restartRequired = true;
_statusMessage = null;
RefreshAll();
if (_detail.Visible)
{
var updated = FindItem(data);
if (updated != null)
_detail.Open(updated);
else
OnDetailBackRequested();
}
}
private void ExecuteAction(ModCardData data, ModActionKind kind)
{
if (kind == ModActionKind.Delete)
{
ConfirmDelete(data);
return;
}
StartInstall(data, kind);
}
private async void StartInstall(ModCardData data, ModActionKind kind)
{
if (!string.IsNullOrEmpty(_installingId))
return;
var version = data.SelectedDownload?.Version;
_installingId = data.Id;
_statusMessage = null;
SetBanner(string.Format(TranslationServer.Translate("mod_manager/progress_installing"), data.Title));
bool success;
try
{
success = await ModDownloader.InstallAsync(data.Id, version, data.PrimarySource);
}
catch (Exception ex)
{
GameLogger.Log("MOD_MANAGER", $"Install '{data.Id}' failed: {ex.Message}", LogLevel.Error);
success = false;
}
_installingId = null;
if (!GodotObject.IsInstanceValid(this))
return;
if (success)
{
_model.MarkInstalled(data.Id, version);
_restartRequired = true;
_statusMessage = null;
}
else
{
_statusMessage = TranslationServer.Translate("mod_manager/install_failed");
}
RefreshAll();
if (_detail.Visible)
{
var updated = FindItem(data);
if (updated != null)
_detail.Open(updated);
}
}
private void ConfirmDelete(ModCardData data)
{
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/delete_title"),
DialogText = string.Format(TranslationServer.Translate("mod_manager/delete_text"), data.Title),
OkButtonText = TranslationServer.Translate("mod_manager/delete_confirm"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
var modId = data.Id;
dialog.Confirmed += () =>
{
OnDeleteConfirmed(modId);
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
private void OnDeleteConfirmed(string modId)
{
if (!ModUninstaller.Uninstall(modId))
{
_statusMessage = TranslationServer.Translate("mod_manager/delete_failed");
UpdateBanner();
return;
}
_model.MarkRemoved(modId);
_restartRequired = true;
_statusMessage = null;
if (_detail.Visible)
{
_detail.Visible = false;
_grid.Visible = true;
}
RefreshAll();
}
private void OnRefreshPressed()
{
RefreshCatalog();
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Mods.ModManager
{
/// <summary>
/// User-triggered mod operation. The screen resolves a tile's primary action
/// via <see cref="ModActions.GetPrimary"/> and executes the kind here; the
/// detail view maps its buttons to the same kinds. Every kind is persisted to
/// disk immediately but only takes effect after a restart.
/// </summary>
public enum ModActionKind
{
/// <summary>Download and install the selected catalog version.</summary>
Install,
/// <summary>Install a newer catalog version over the local copy.</summary>
Update,
/// <summary>Install an older catalog version over the local copy.</summary>
Downgrade,
/// <summary>Reinstall the same version to replace locally modified files.</summary>
Reinstall,
/// <summary>Delete the installed mod (user mods root only), then clean per-mod state.</summary>
Delete,
/// <summary>Allow the mod to load on next start.</summary>
Enable,
/// <summary>Prevent the mod from loading on next start.</summary>
Disable,
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Mods.ModManager
{
/// <summary>
/// Business rules deciding which operation a mod tile offers. The tile shows
/// a single contextual button; its meaning follows the state machine:
/// catalog-only → Install, newer remote → Update, older remote → Downgrade,
/// same version but edited locally → Reinstall, otherwise → Delete for
/// deletable local mods and nothing for protected/shipped ones.
/// </summary>
public static class ModActions
{
/// <summary>Primary action for a tile/detail page, or null when there is nothing to do.</summary>
public static ModActionKind? GetPrimary(ModCardData data)
{
if (data == null)
return null;
if (!data.IsInstalled)
return data.HasRemote ? ModActionKind.Install : null;
if (data.HasRemote)
{
switch (data.Status)
{
case ModItemStatus.UpdateAvailable:
return ModActionKind.Update;
case ModItemStatus.DowngradeAvailable:
return ModActionKind.Downgrade;
case ModItemStatus.ModifiedLocally:
return ModActionKind.Reinstall;
}
}
return data.IsDeletable ? ModActionKind.Delete : null;
}
/// <summary>True for actions that download and install files.</summary>
public static bool IsInstall(ModActionKind kind)
=> kind == ModActionKind.Install
|| kind == ModActionKind.Update
|| kind == ModActionKind.Downgrade
|| kind == ModActionKind.Reinstall;
}
}
+155
View File
@@ -0,0 +1,155 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
namespace Mods.ModManager
{
/// <summary>
/// One row of the mod manager grid: everything the tile and the detail page
/// need to render a mod, regardless of whether it exists locally, in the
/// online catalog, or both. The manager builds these in
/// <see cref="ModManagerModel.Reload"/> by merging
/// <c>ModRegistry.AllDiscoveredMods</c> with <c>CatalogService.CachedEntries</c>.
/// <para/>
/// <see cref="Status"/> is derived (not stored by the registry) from the local
/// version, the selected catalog download and the local install hash — call
/// <see cref="RecomputeStatus"/> after changing <see cref="SelectedDownload"/>,
/// <see cref="IsDisabled"/> or the install state.
/// </summary>
public class ModCardData
{
/// <summary>Stable mod id shared by the local mod and the catalog entry.</summary>
public string Id { get; set; }
/// <summary>Localized display title (local manifest name or catalog locale name).</summary>
public string Title { get; set; }
/// <summary>Author string from whichever source is available.</summary>
public string Author { get; set; }
/// <summary>Localized description.</summary>
public string Description { get; set; }
/// <summary>Installed version string, or null for catalog-only mods.</summary>
public string LocalVersion { get; set; }
/// <summary>Manifest category (local) or catalog category (remote), flags combined.</summary>
public CategoryType Category { get; set; } = CategoryType.Other;
/// <summary>Loaded local mod, or null when only the catalog knows this id.</summary>
public ModInfo LocalInfo { get; set; }
/// <summary>Merged catalog entry, or null for local-only (development) mods.</summary>
public CatalogExportEntry Catalog { get; set; }
/// <summary>True when a local copy exists (including an install done in this session).</summary>
public bool IsInstalled { get; set; }
/// <summary>True when the local copy is listed in <c>disabled_mods.json</c>.</summary>
public bool IsDisabled { get; set; }
/// <summary>True when <see cref="ModUninstaller"/> may delete the local copy.</summary>
public bool IsDeletable { get; set; }
/// <summary>True for the mod manager itself, which cannot be disabled or deleted.</summary>
public bool IsProtected { get; set; }
/// <summary>Derived tile state; see <see cref="RecomputeStatus"/>.</summary>
public ModItemStatus Status { get; set; }
/// <summary>Catalog downloads, newest first (as merged by the catalog service).</summary>
public List<CatalogEntryDownload> Downloads { get; set; } = new();
/// <summary>Version chosen in the tile/detail combo box; defaults to the newest download.</summary>
public CatalogEntryDownload SelectedDownload { get; set; }
/// <summary>True when an online catalog entry exists for this mod.</summary>
public bool HasRemote => Catalog != null;
/// <summary>
/// True when several catalogs publish this id with the same version and
/// title but different content — the tile and detail page warn about it.
/// </summary>
public bool IsConflict => Catalog?.VariantConflict == true;
/// <summary>
/// How many <see cref="ModCollectionKind.Game"/> collections reference
/// this mod (directly or through includes). Computed by the manager
/// screen via <see cref="ModCollectionGallery.FindGamesUsingMod"/> on
/// every grid refresh; zero means the mod belongs to no playable game.
/// </summary>
public int CollectionsCount { get; set; }
/// <summary>Catalog URLs that published this variant, or null for local-only mods.</summary>
public IReadOnlyList<string> SourceUrls => Catalog?.SourceUrls;
/// <summary>First catalog URL; used to install exactly this variant and to match cards after reload.</summary>
public string PrimarySource => ModCatalogSource.FirstUrl(SourceUrls);
/// <summary>
/// Identity of a card across model reloads: the mod id plus the catalog
/// variant it represents. Needed because conflicting variants share an id.
/// </summary>
public string MatchKey => $"{Id}|{PrimarySource ?? string.Empty}";
/// <summary>True when the tile should show the enable/disable switch.</summary>
public bool CanToggleEnabled => IsInstalled && !IsProtected;
/// <summary>Version to display next to the title: installed version or newest catalog version.</summary>
public string DisplayVersion => !string.IsNullOrEmpty(LocalVersion)
? LocalVersion
: SelectedDownload?.Version ?? Catalog?.Version;
/// <summary>
/// Derives <see cref="Status"/> from the local and selected remote versions.
/// Priority: not installed → disabled → version comparison →
/// hash comparison. A missing catalog entry always yields
/// <see cref="ModItemStatus.Installed"/> for a local copy.
/// </summary>
public void RecomputeStatus()
{
if (!IsInstalled)
{
Status = ModItemStatus.NotInstalled;
return;
}
if (IsDisabled)
{
Status = ModItemStatus.Disabled;
return;
}
var remoteVersion = SelectedDownload?.Version ?? Catalog?.Version;
if (string.IsNullOrEmpty(remoteVersion) || string.IsNullOrEmpty(LocalVersion))
{
Status = ModItemStatus.Installed;
return;
}
var comparison = VersionHelper.Compare(remoteVersion, LocalVersion);
if (comparison > 0)
{
Status = ModItemStatus.UpdateAvailable;
return;
}
if (comparison < 0)
{
Status = ModItemStatus.DowngradeAvailable;
return;
}
var remoteHash = SelectedDownload?.InstallSha256;
if (!string.IsNullOrEmpty(remoteHash)
&& !string.IsNullOrEmpty(LocalInfo?.InstallSha256)
&& !string.Equals(LocalInfo.InstallSha256, remoteHash, System.StringComparison.OrdinalIgnoreCase))
{
Status = ModItemStatus.ModifiedLocally;
return;
}
Status = ModItemStatus.Installed;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Mods.ModManager
{
/// <summary>
/// One row of the "+ мод" picker: the mods known to the local registry and
/// the online catalog cache, merged by id. The picker excludes ids that are
/// already resolved in the scheme, so this entity only ever describes mods
/// the player can still add; <see cref="ModChoice.Id"/> becomes a new string
/// in the target mechanic's <c>mods</c> array.
/// </summary>
public class ModChoice
{
/// <summary>Canonical mod id written into the collection document.</summary>
public string Id { get; set; }
/// <summary>Display title (localized catalog name or manifest name).</summary>
public string Title { get; set; }
/// <summary>Localized description shown under the title.</summary>
public string Description { get; set; }
}
}
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// Live filter state of the manager. Empty status/category sets mean "all",
/// which matches Nexus-style sidebars where checkboxes only ever narrow the
/// list. Search matches title, id and author case-insensitively. The state
/// also owns the sort mode and the optional grouping toggle.
/// </summary>
public class ModFilterState
{
/// <summary>Free-text query matched against title, id and author.</summary>
public string Search { get; set; } = string.Empty;
/// <summary>Selected statuses; empty means no status restriction.</summary>
public HashSet<ModItemStatus> Statuses { get; } = new();
/// <summary>Selected categories; empty means no category restriction.</summary>
public HashSet<CategoryType> Categories { get; } = new();
/// <summary>Grid ordering mode.</summary>
public ModSortMode Sort { get; set; } = ModSortMode.Name;
/// <summary>When true the grid renders category headers between groups.</summary>
public bool GroupByCategory { get; set; }
/// <summary>Full match: search + status + category.</summary>
public bool Matches(ModCardData item)
=> MatchesSearch(item) && MatchesStatuses(item) && MatchesCategories(item);
/// <summary>Match ignoring the category set — used to compute category counts.</summary>
public bool MatchesExceptCategory(ModCardData item)
=> MatchesSearch(item) && MatchesStatuses(item);
/// <summary>Match ignoring the status set — used to compute status counts.</summary>
public bool MatchesExceptStatus(ModCardData item)
=> MatchesSearch(item) && MatchesCategories(item);
/// <summary>Returns matching items in the configured order.</summary>
public List<ModCardData> Apply(IEnumerable<ModCardData> source)
{
var result = new List<ModCardData>();
foreach (var item in source)
{
if (Matches(item))
result.Add(item);
}
result.Sort(Compare);
return result;
}
/// <summary>Clears search, statuses and categories; keeps sort and grouping.</summary>
public void Reset()
{
Search = string.Empty;
Statuses.Clear();
Categories.Clear();
}
private int Compare(ModCardData a, ModCardData b)
{
switch (Sort)
{
case ModSortMode.Author:
var byAuthor = string.Compare(a.Author, b.Author, StringComparison.OrdinalIgnoreCase);
return byAuthor != 0 ? byAuthor : CompareTitle(a, b);
case ModSortMode.Status:
var byStatus = ModStatusNames.SortRank(a.Status).CompareTo(ModStatusNames.SortRank(b.Status));
return byStatus != 0 ? byStatus : CompareTitle(a, b);
default:
return CompareTitle(a, b);
}
}
private static int CompareTitle(ModCardData a, ModCardData b)
=> string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
private bool MatchesSearch(ModCardData item)
{
if (string.IsNullOrWhiteSpace(Search))
return true;
var query = Search.Trim();
return Contains(item.Title, query) || Contains(item.Id, query) || Contains(item.Author, query);
}
private bool MatchesStatuses(ModCardData item)
=> Statuses.Count == 0 || Statuses.Contains(item.Status);
private bool MatchesCategories(ModCardData item)
=> Categories.Count == 0 || (item.Category & AsFlags()) != 0;
private CategoryType AsFlags()
{
var flags = (CategoryType)0;
foreach (var category in Categories)
flags |= category;
return flags;
}
private static bool Contains(string value, string query)
=> !string.IsNullOrEmpty(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase);
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Mods.ModManager
{
/// <summary>
/// UI-level state of a mod tile. Combines the local install state with the
/// online catalog comparison so a single value drives the tile badge and the
/// contextual action button. <see cref="ModifiedLocally"/> means the local
/// content hash differs from the catalog entry of the same version — the
/// player edited the mod and overwriting would lose their changes.
/// </summary>
public enum ModItemStatus
{
/// <summary>No local copy exists; only the catalog entry is shown.</summary>
NotInstalled,
/// <summary>Installed and up-to-date with the selected catalog version (or no catalog entry).</summary>
Installed,
/// <summary>The catalog has a newer version than the installed one.</summary>
UpdateAvailable,
/// <summary>The catalog's selected version is older than the installed one.</summary>
DowngradeAvailable,
/// <summary>Same version as the catalog, but the local content hash differs.</summary>
ModifiedLocally,
/// <summary>Installed but disabled by the player (<c>disabled_mods.json</c>).</summary>
Disabled,
}
}
+264
View File
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Merges everything known about mods into the manager's grid model: local
/// mods discovered by <see cref="ModRegistry"/> (with enable state and
/// deletability) and online catalog entries from <see cref="CatalogService"/>.
/// A mod present in both sources becomes one combined card; catalog-only mods
/// appear as installable tiles.
/// <para/>
/// The model also owns the session overrides that keep the UI honest after
/// file operations: a mod installed or deleted from the manager is not yet
/// reflected by the registry (which is loaded once per launch), so
/// <see cref="MarkInstalled"/> / <see cref="MarkRemoved"/> adjust the view and
/// the restart banner tells the player when the change becomes real.
/// </summary>
public class ModManagerModel
{
/// <summary>Id of the mod that renders this UI; it can never be disabled or removed.</summary>
public const string SelfModId = "mod_manager";
private readonly List<ModCardData> _items = new();
private readonly HashSet<string> _removed = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _installed = new(StringComparer.OrdinalIgnoreCase);
/// <summary>All cards, unsorted and unfiltered; rebuild with <see cref="Reload"/>.</summary>
public IReadOnlyList<ModCardData> Items => _items;
/// <summary>
/// Rebuilds the card list from the current registry, disabled store and
/// catalog cache. Safe to call on every action completion and after a
/// catalog refresh.
/// </summary>
public void Reload()
{
_items.Clear();
var disabled = DisabledModsStore.Load();
var variantsById = BuildCatalogVariants();
var localIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ordered = ModRegistry.Instance.GetOrderedModIds(filterDisabled: false);
foreach (var id in ordered)
{
if (_removed.Contains(id))
continue;
if (!ModRegistry.Instance.AllDiscoveredMods.TryGetValue(id, out var info) || info is not ModInfo modInfo)
continue;
localIds.Add(id);
var isDisabled = disabled.Contains(id);
if (variantsById.TryGetValue(id, out var variants))
{
foreach (var variant in variants)
_items.Add(BuildLocalCard(id, modInfo, isDisabled, variant));
}
else
{
_items.Add(BuildLocalCard(id, modInfo, isDisabled, null));
}
}
foreach (var pair in variantsById)
{
if (localIds.Contains(pair.Key) || _removed.Contains(pair.Key))
continue;
foreach (var variant in pair.Value)
_items.Add(BuildRemoteCard(variant));
}
}
/// <summary>Remembers that a mod was deleted this session so it stays hidden until restart.</summary>
public void MarkRemoved(string modId)
{
_removed.Add(modId);
_installed.Remove(modId);
}
/// <summary>Remembers a successful install so the card shows as installed before restart.</summary>
public void MarkInstalled(string modId, string version)
{
_removed.Remove(modId);
_installed[modId] = version ?? string.Empty;
}
/// <summary>
/// Applies an enable/disable toggle with the same cascade as the legacy
/// menu: disabling a mod also disables its dependents, enabling one also
/// re-enables the dependencies it needs.
/// </summary>
public void SetEnabled(string modId, bool enabled)
{
var disabled = DisabledModsStore.Load();
if (enabled)
{
disabled.Remove(modId);
var info = ModRegistry.Instance.GetMod(modId) as ModInfo;
var dependencies = info?.Manifest?.DependencyIds;
if (dependencies != null)
{
foreach (var dependency in dependencies)
disabled.Remove(dependency);
}
}
else
{
disabled.Add(modId);
CollectDependents(modId, disabled);
}
DisabledModsStore.Save(disabled);
}
/// <summary>Category buckets for the sidebar, respecting every filter except the category set.</summary>
public Dictionary<CategoryType, int> CountCategories(ModFilterState filter)
{
var counts = new Dictionary<CategoryType, int>();
foreach (var item in _items)
{
if (!filter.MatchesExceptCategory(item))
continue;
foreach (var category in ModCategoryNames.Ordered)
{
if ((item.Category & category) == 0)
continue;
counts[category] = counts.GetValueOrDefault(category) + 1;
}
}
return counts;
}
/// <summary>Status buckets for the sidebar, respecting every filter except the status set.</summary>
public Dictionary<ModItemStatus, int> CountStatuses(ModFilterState filter)
{
var counts = new Dictionary<ModItemStatus, int>();
foreach (var item in _items)
{
if (!filter.MatchesExceptStatus(item))
continue;
counts[item.Status] = counts.GetValueOrDefault(item.Status) + 1;
}
return counts;
}
/// <summary>
/// Indexes catalog entries by mod id. Normally one entry per id, but a
/// variant conflict (same id/version/title, different content across
/// catalogs) produces several entries that the UI shows as separate mods.
/// </summary>
private Dictionary<string, List<CatalogExportEntry>> BuildCatalogVariants()
{
var index = new Dictionary<string, List<CatalogExportEntry>>(StringComparer.OrdinalIgnoreCase);
var entries = CatalogService.CachedEntries;
if (entries == null)
return index;
foreach (var entry in entries)
{
if (string.IsNullOrEmpty(entry?.Id))
continue;
if (!index.TryGetValue(entry.Id, out var list))
{
list = new List<CatalogExportEntry>();
index[entry.Id] = list;
}
list.Add(entry);
}
return index;
}
private ModCardData BuildLocalCard(string id, ModInfo modInfo, bool disabled, CatalogExportEntry catalog)
{
var data = new ModCardData
{
Id = id,
Title = modInfo.DisplayTitle,
Author = modInfo.Author,
Description = modInfo.LocalizedDescription,
LocalVersion = modInfo.Version,
Category = modInfo.Manifest?.Category ?? CategoryType.Other,
LocalInfo = modInfo,
Catalog = catalog,
IsInstalled = true,
IsDisabled = disabled,
IsDeletable = ModUninstaller.CanUninstall(modInfo),
IsProtected = string.Equals(id, SelfModId, StringComparison.OrdinalIgnoreCase),
};
if (_installed.TryGetValue(id, out var installedVersion))
data.LocalVersion = installedVersion;
ApplyDownloads(data, catalog);
data.RecomputeStatus();
return data;
}
private ModCardData BuildRemoteCard(CatalogExportEntry catalog)
{
var locale = GetCatalogLocale();
var data = new ModCardData
{
Id = catalog.Id,
Title = catalog.GetLocalizedName(locale),
Author = catalog.Author,
Description = catalog.GetLocalizedDescription(locale),
Category = catalog.Category,
Catalog = catalog,
IsInstalled = false,
};
if (_installed.TryGetValue(catalog.Id, out var installedVersion))
{
data.IsInstalled = true;
data.LocalVersion = installedVersion;
}
ApplyDownloads(data, catalog);
data.RecomputeStatus();
return data;
}
private static void ApplyDownloads(ModCardData data, CatalogExportEntry catalog)
{
data.Downloads = catalog?.Downloads ?? new List<CatalogEntryDownload>();
data.SelectedDownload = data.Downloads.Count > 0 ? data.Downloads[0] : null;
}
private static string GetCatalogLocale()
{
var locale = TranslationServer.GetLocale();
return string.IsNullOrEmpty(locale) ? "en" : locale;
}
private static void CollectDependents(string modId, HashSet<string> disabled)
{
var allMods = ModRegistry.Instance.AllDiscoveredMods;
foreach (var pair in allMods)
{
if (disabled.Contains(pair.Key) || pair.Value is not ModInfo info)
continue;
var dependencies = info.Manifest?.DependencyIds;
if (dependencies != null && dependencies.Contains(modId))
{
disabled.Add(pair.Key);
CollectDependents(pair.Key, disabled);
}
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// Read-only cross-mod lookups used by the detail page: direct dependencies,
/// reverse dependencies (which mods depend on this one) and declared
/// conflicts. Prefers local manifest data and falls back to the catalog entry
/// for mods that are not installed.
/// </summary>
public static class ModQueries
{
/// <summary>Dependency strings (<c>core&gt;=1</c> format) of a mod, whatever source knows it.</summary>
public static List<string> GetDependencies(ModCardData data)
{
var manifestDeps = data?.LocalInfo?.Manifest?.DependencyIds;
if (manifestDeps != null && manifestDeps.Count > 0)
return manifestDeps;
return data?.Catalog?.Depends ?? new List<string>();
}
/// <summary>Ids of installed mods whose manifests depend on <paramref name="modId"/>.</summary>
public static List<string> GetDependents(string modId)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
var allMods = ModRegistry.Instance.AllDiscoveredMods;
if (allMods == null)
return result;
foreach (var pair in allMods)
{
if (pair.Key == modId || pair.Value is not ModInfo info)
continue;
var deps = info.Manifest?.DependencyIds;
if (deps != null && deps.Contains(modId))
result.Add(pair.Key);
}
return result;
}
/// <summary>Declared mutually exclusive mod ids (manifest first, catalog fallback).</summary>
public static List<string> GetConflicts(ModCardData data)
{
var manifestConflicts = data?.LocalInfo?.Manifest?.ConflictsWith;
if (manifestConflicts != null && manifestConflicts.Count > 0)
return manifestConflicts;
return data?.Catalog?.ConflictsWith ?? new List<string>();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Mods.ModManager
{
/// <summary>Ordering mode of the mod grid; grouping by category is a separate toggle.</summary>
public enum ModSortMode
{
/// <summary>Alphabetical by display title.</summary>
Name,
/// <summary>Alphabetical by author, then title.</summary>
Author,
/// <summary>By install/update state (installed, updates, catalog-only).</summary>
Status,
}
}
+31
View File
@@ -0,0 +1,31 @@
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// One dependency line of the scheme: the two chips it connects plus the
/// flags that decide its paint style. Edges are rebuilt on every canvas
/// rebuild and drawn by <see cref="CollectionEdgeLayer"/> from the chips'
/// live global rectangles, so dragging a chip or a whole group keeps the
/// lines attached without recomputing them.
/// <para/>
/// <see cref="Cross"/> is the "dangerous layout" flag: the dependency's two
/// mods live in different mechanics, which means the player separated what
/// belongs together. Cross edges are painted amber (red when the selection
/// touches them) so the mistake is visible across the whole canvas.
/// </summary>
public class CollectionEdge
{
/// <summary>Mod id of the dependency (the side the arrow starts at).</summary>
public string FromModId { get; set; }
/// <summary>Mod id of the dependent mod.</summary>
public string ToModId { get; set; }
/// <summary>True when the two mods belong to different groups.</summary>
public bool Cross { get; set; }
/// <summary>True when the selection touches one of the endpoints.</summary>
public bool Active { get; set; }
}
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.ModManager
{
/// <summary>
/// Transparent overlay that paints the scheme's dependency lines above the
/// group cards, the Godot counterpart of the mockup's SVG layer. Drawing is
/// connected through the <c>"draw"</c> signal rather than a <c>_Draw</c>
/// override, because virtual overrides are unreliable in mod assemblies.
/// <para/>
/// The layer stores only edge descriptors and chip references; coordinates
/// are read from the chips' global rectangles at paint time, so the canvas
/// never has to update line geometry — it only calls <see cref="QueueRedraw"/>
/// after a drag or resize.
/// </summary>
public partial class CollectionEdgeLayer : ModWidget
{
private readonly List<CollectionEdge> _edges = new();
private readonly Dictionary<string, CollectionModChip> _chips = new();
private Control _canvas;
/// <summary>Replaces the drawn edges. <paramref name="canvas"/> anchors the local coordinate space.</summary>
public void SetEdges(IEnumerable<CollectionEdge> edges, Dictionary<string, CollectionModChip> chips, Control canvas)
{
EnsureConstructed();
_canvas = canvas;
_edges.Clear();
_chips.Clear();
if (edges != null)
{
foreach (var edge in edges)
_edges.Add(edge);
}
if (chips != null)
{
foreach (var pair in chips)
_chips[pair.Key] = pair.Value;
}
QueueRedraw();
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Ignore;
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
Connect("draw", Callable.From((Action)DrawAll));
}
private void DrawAll()
{
if (_canvas == null || !GodotObject.IsInstanceValid(_canvas))
return;
var origin = _canvas.GetGlobalRect().Position;
foreach (var edge in _edges)
{
if (!_chips.TryGetValue(edge.FromModId, out var from) || !GodotObject.IsInstanceValid(from))
continue;
if (!_chips.TryGetValue(edge.ToModId, out var to) || !GodotObject.IsInstanceValid(to))
continue;
var a = from.GetGlobalRect().GetCenter() - origin;
var b = to.GetGlobalRect().GetCenter() - origin;
var points = BuildCurve(a, b);
var color = edge.Cross ? (edge.Active ? UiTokens.Danger : UiTokens.Warning) : (edge.Active ? UiTokens.Accent : UiTokens.LineStrong);
var width = edge.Cross ? (edge.Active ? 3f : 2f) : (edge.Active ? 2.5f : 1.5f);
if (edge.Cross)
{
for (var i = 0; i < points.Count - 1; i++)
DrawDashedLine(points[i], points[i + 1], color, width, 5f);
DrawCircle(points[^1], 2.5f, color);
}
else
{
DrawPolyline(points.ToArray(), color, width, true);
}
}
}
private static List<Vector2> BuildCurve(Vector2 a, Vector2 b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
Vector2 c1, c2;
if (Mathf.Abs(dx) >= Mathf.Abs(dy))
{
c1 = new Vector2(a.X + dx * 0.4f, a.Y);
c2 = new Vector2(b.X - dx * 0.4f, b.Y);
}
else
{
c1 = new Vector2(a.X, a.Y + dy * 0.4f);
c2 = new Vector2(b.X, b.Y - dy * 0.4f);
}
var points = new List<Vector2>();
const int steps = 24;
for (var i = 0; i <= steps; i++)
points.Add(Cubic(a, c1, c2, b, i / (float)steps));
return points;
}
private static Vector2 Cubic(Vector2 a, Vector2 c1, Vector2 c2, Vector2 b, float t)
{
var u = 1f - t;
return u * u * u * a + 3f * u * u * t * c1 + 3f * u * t * t * c2 + t * t * t * b;
}
}
}
+325
View File
@@ -0,0 +1,325 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Mods.ModManager
{
/// <summary>
/// One contour of the collection scheme: the Godot counterpart of the
/// mockup's <c>.ce-group</c>. Renders a movable and resizable frame with the
/// mechanic role, name, state badge and description, hosts the draggable mod
/// chips on an absolute layer and draws the faint watermark label on the
/// background.
/// <para/>
/// The card owns no data beyond the resolved <see cref="ModCollectionGroup"/>
/// it displays: the header carries only the state pill and the optional
/// contour's on/off toggle (raising <see cref="Command"/> with "toggle"),
/// while editing commands live in the screen's inspector panel. Group and
/// chip coordinates are reported
/// through <see cref="Moved"/>, <see cref="Resized"/> and
/// <see cref="DragFinished"/> so the canvas can persist them through
/// <see cref="CollectionLayoutStore"/>.
/// </summary>
public partial class CollectionGroupCard : ModWidget
{
/// <summary>Minimum frame width enforced while resizing.</summary>
public const int MinWidth = 300;
/// <summary>Minimum frame height enforced while resizing.</summary>
public const int MinHeight = 140;
/// <summary>Stable group key matching <see cref="ModCollectionGroup.Key"/>.</summary>
public string GroupKey { get; private set; }
/// <summary>Resolved group shown by this card.</summary>
public ModCollectionGroup Group { get; private set; }
/// <summary>Layer the canvas adds mod chips to; absolute coordinates start at the frame's top-left.</summary>
public Control ModsLayer { get; private set; }
/// <summary>
/// Card-local Y where the free chip area begins: the bottom of the
/// header and description block plus a small gap. <see cref="CollectionModChip"/>
/// clamps vertical drags to this line, so a chip can never be parked on
/// top of the group title, its header buttons or its description. The
/// canvas keeps its own default for freshly packed slots.
/// </summary>
public float ModsTop
{
get
{
EnsureConstructed();
var block = _content.Position.Y + _desc.Position.Y + _desc.Size.Y;
return Mathf.Max(0f, block + UiTokens.Space2);
}
}
/// <summary>Raised on left click anywhere on the card that is not a chip or button.</summary>
public event Action<string> Selected;
/// <summary>Raised by the header buttons with a command id (see class summary).</summary>
public event Action<string, string> Command;
/// <summary>Raised while dragging the header with the desired canvas position.</summary>
public event Action<string, Vector2> Moved;
/// <summary>Raised while dragging the resize corner with the desired frame size.</summary>
public event Action<string, Vector2> Resized;
/// <summary>Raised once a header drag or resize ends, so layout can be flushed.</summary>
public event Action DragFinished;
private PanelContainer _panel;
private VBoxContainer _content;
private HBoxContainer _header;
private Label _role;
private Label _name;
private Label _desc;
private Label _watermark;
private PanelContainer _stateChip;
private Label _stateLabel;
private CheckButton _optional;
private CollectionResizeHandle _resize;
private StyleBoxFlat _style;
private Color _baseBorder = UiTokens.LineCard;
private bool _dragging;
private bool _resizing;
private bool _moved;
private bool _updatingOptional;
/// <summary>
/// Binds the card to a resolved group. Must be called after the card is
/// added to the tree; safe to call again to refresh texts and states.
/// </summary>
public void Init(ModCollectionGroup group, string stateLabel, Color stateColor, bool selected)
{
EnsureConstructed();
Group = group;
GroupKey = group.Key;
_role.Text = string.IsNullOrEmpty(group.Role) ? string.Empty : group.Role.ToUpperInvariant();
_name.Text = group.Name;
_desc.Text = group.Description ?? string.Empty;
_watermark.Text = group.Name ?? string.Empty;
_stateLabel.Text = stateLabel;
_stateLabel.AddThemeColorOverride("font_color", stateColor);
_stateChip.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(stateColor), UiTokens.BadgeLine(stateColor)));
if (_optional != null)
{
_updatingOptional = true;
_optional.ButtonPressed = group.Selected;
_updatingOptional = false;
_optional.Visible = group.Optional;
}
_header.TooltipText = group.Description ?? string.Empty;
Modulate = new Color(1, 1, 1, group.Selected ? 1f : 0.55f);
SetSelected(selected);
}
/// <summary>Moves and sizes the frame in canvas coordinates.</summary>
public void SetFrame(Rect2 rect)
{
Position = rect.Position;
Size = rect.Size;
_watermark.Size = new Vector2(Mathf.Max(0f, rect.Size.X - 24f), 52f);
_watermark.Position = new Vector2(8f, Mathf.Max(0f, rect.Size.Y - 56f));
_resize.Position = new Vector2(Mathf.Max(0f, rect.Size.X - CollectionResizeHandle.HandleSize), Mathf.Max(0f, rect.Size.Y - CollectionResizeHandle.HandleSize));
}
/// <summary>Highlights the frame as the current canvas/inspector selection.</summary>
public void SetSelected(bool selected)
{
EnsureConstructed();
_style.BorderColor = selected ? UiTokens.Accent : _baseBorder;
_style.BorderWidthTop = 1;
_style.BorderWidthRight = 1;
_style.BorderWidthBottom = 1;
_style.BorderWidthLeft = 1;
}
/// <summary>Updates only the state pill, used when the plan changes the availability.</summary>
public void SetState(string stateLabel, Color stateColor)
{
EnsureConstructed();
_stateLabel.Text = stateLabel;
_stateLabel.AddThemeColorOverride("font_color", stateColor);
_stateChip.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(stateColor), UiTokens.BadgeLine(stateColor)));
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Ignore;
_baseBorder = UiTokens.LineCard;
_style = UiKit.Frame(12, new Color(UiTokens.Surface1, 0.92f), _baseBorder);
_panel = new PanelContainer { MouseFilter = MouseFilterEnum.Stop };
_panel.AddThemeStyleboxOverride("panel", _style);
_panel.GuiInput += OnPanelGuiInput;
AddChild(_panel);
_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
ModsLayer = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(ModsLayer);
ModsLayer.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var contentHost = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(contentHost);
contentHost.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
margin.AddThemeConstantOverride("margin_left", UiTokens.Space6);
margin.AddThemeConstantOverride("margin_right", UiTokens.Space6);
margin.AddThemeConstantOverride("margin_top", UiTokens.Space5);
margin.AddThemeConstantOverride("margin_bottom", UiTokens.Space5);
contentHost.AddChild(margin);
margin.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_content = new VBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
_content.AddThemeConstantOverride("separation", 6);
margin.AddChild(_content);
_header = new HBoxContainer { MouseFilter = MouseFilterEnum.Stop };
_header.AddThemeConstantOverride("separation", UiTokens.Space3);
_header.GuiInput += OnHeaderGuiInput;
_content.AddChild(_header);
_role = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.AccentDim);
_role.Uppercase = true;
_role.SizeFlagsVertical = SizeFlags.ShrinkCenter;
_header.AddChild(_role);
_name = UiKit.Label(string.Empty, UiTokens.FontLead, UiTokens.InkBright);
_name.SizeFlagsVertical = SizeFlags.ShrinkCenter;
_name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_name.ClipText = true;
_header.AddChild(_name);
_stateLabel = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.Success);
_stateLabel.SizeFlagsVertical = SizeFlags.ShrinkCenter;
_stateChip = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
_stateChip.AddChild(_stateLabel);
_header.AddChild(_stateChip);
// Commands (add mod, rename, replace, remove) live only in the
// right-hand inspector — the header stays a compact title row with
// the state pill and, for optional contours, the on/off toggle.
_optional = new CheckButton { Visible = false };
_optional.Toggled += OnOptionalToggled;
_header.AddChild(_optional);
_desc = UiKit.Label(string.Empty, UiTokens.FontSmall, UiTokens.Muted);
_desc.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_desc.MaxLinesVisible = 3;
_content.AddChild(_desc);
_watermark = UiKit.Label(string.Empty, 42, new Color(1f, 1f, 1f, 0.04f));
_watermark.Uppercase = true;
_watermark.ClipText = true;
_watermark.HorizontalAlignment = HorizontalAlignment.Right;
AddChild(_watermark);
_resize = new CollectionResizeHandle();
_resize.GuiInput += OnResizeGuiInput;
AddChild(_resize);
_resize.EnsureConstructed();
}
private void OnOptionalToggled(bool pressed)
{
if (_updatingOptional)
return;
Command?.Invoke(GroupKey, "toggle");
}
private void OnPanelGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left && button.Pressed)
{
Selected?.Invoke(GroupKey);
AcceptEvent();
}
}
private void OnHeaderGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
{
if (button.Pressed)
{
_dragging = true;
_moved = false;
Selected?.Invoke(GroupKey);
}
else
{
_dragging = false;
if (_moved)
DragFinished?.Invoke();
}
AcceptEvent();
return;
}
if (@event is InputEventMouseMotion motion && _dragging)
{
var desired = Position + motion.Relative;
var clamped = new Vector2(Mathf.Max(0f, desired.X), Mathf.Max(0f, desired.Y));
if (clamped.DistanceTo(Position) > 0.5f)
_moved = true;
Position = clamped;
Moved?.Invoke(GroupKey, clamped);
AcceptEvent();
}
}
private void OnResizeGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
{
if (button.Pressed)
{
_resizing = true;
_moved = false;
}
else
{
_resizing = false;
if (_moved)
DragFinished?.Invoke();
}
AcceptEvent();
return;
}
if (@event is InputEventMouseMotion motion && _resizing)
{
var size = new Vector2(
Mathf.Max(MinWidth, Size.X + motion.Relative.X),
Mathf.Max(MinHeight, Size.Y + motion.Relative.Y));
if (size.DistanceTo(Size) > 0.5f)
_moved = true;
SetFrame(new Rect2(Position, size));
Resized?.Invoke(GroupKey, size);
AcceptEvent();
}
}
protected override void Destruct()
{
_optional.Toggled -= OnOptionalToggled;
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using System;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Mods.ModManager
{
/// <summary>
/// Draggable chip of one mod inside a scheme group, the Godot counterpart of
/// the mockup's <c>.ce-mod</c>. Built fully in code and used through the
/// <see cref="ModWidget"/> contract: the canvas adds it to the mods layer
/// first and only then calls <see cref="Init"/>, which builds the visuals
/// under a valid tree.
/// <para/>
/// The chip is click-selectable and freely draggable inside its group: the
/// drag pattern keeps the pointer over the panel because the control follows
/// the mouse by the same delta (the same trick the map editor handles use).
/// It owns no data — the canvas persists the resulting position into
/// <see cref="Cthangover.Core.Mods.ModCollectionLayout"/> and decides what a
/// click means.
/// </summary>
public partial class CollectionModChip : ModWidget
{
/// <summary>Fixed chip width in canvas pixels; the canvas layout math depends on it.</summary>
public const int ChipWidth = 210;
/// <summary>Fixed chip height in canvas pixels.</summary>
public const int ChipHeight = 94;
/// <summary>Canonical mod id this chip represents.</summary>
public string ModId { get; private set; }
/// <summary>Raised on left click; the canvas turns it into a selection.</summary>
public event Action<string> Selected;
/// <summary>Raised while dragging with the desired position inside the group's mods layer.</summary>
public event Action<string, Vector2> Moved;
/// <summary>Raised when a drag ends, after the last <see cref="Moved"/>.</summary>
public event Action DragFinished;
private PanelContainer _panel;
private Label _role;
private Label _name;
private Label _meta;
private ColorRect _dot;
private StyleBoxFlat _style;
private Color _stateColor = UiTokens.Success;
private bool _dragging;
private bool _moved;
/// <summary>
/// Binds the chip to a resolved mod and paints it. Must be called after
/// the chip is added to the tree; safe to call again to refresh the
/// status without rebuilding the node.
/// </summary>
public void Init(string modId, string role, string title, string version, string stateLabel, Color stateColor, string tooltip)
{
EnsureConstructed();
ModId = modId;
_stateColor = stateColor;
_role.Text = string.IsNullOrEmpty(role) ? string.Empty : role.ToUpperInvariant();
_name.Text = title;
_meta.Text = string.IsNullOrEmpty(version) ? stateLabel : $"{version} · {stateLabel}";
_dot.Color = stateColor;
_style.BorderColor = stateColor;
_panel.TooltipText = tooltip ?? string.Empty;
}
/// <summary>Highlights the chip as the current canvas/inspector selection.</summary>
public void SetSelected(bool selected)
{
EnsureConstructed();
_style.BorderColor = selected ? UiTokens.Accent : _stateColor;
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Ignore;
CustomMinimumSize = new Vector2(ChipWidth, ChipHeight);
Size = new Vector2(ChipWidth, ChipHeight);
_style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1, UiTokens.LineCard);
_style.BorderWidthLeft = 3;
_panel = new PanelContainer { MouseFilter = MouseFilterEnum.Stop };
_panel.AddThemeStyleboxOverride("panel", _style);
_panel.GuiInput += OnGuiInput;
AddChild(_panel);
_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
margin.AddThemeConstantOverride("margin_right", 18);
margin.AddThemeConstantOverride("margin_top", 6);
margin.AddThemeConstantOverride("margin_bottom", 7);
_panel.AddChild(margin);
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", 3);
margin.AddChild(column);
_role = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.Faint);
_role.Uppercase = true;
_role.ClipText = true;
column.AddChild(_role);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", 6);
_dot = new ColorRect { Color = UiTokens.Success, CustomMinimumSize = new Vector2(7, 7), MouseFilter = MouseFilterEnum.Ignore };
_dot.SizeFlagsVertical = SizeFlags.ShrinkBegin;
row.AddChild(_dot);
_name = UiKit.Label(string.Empty, UiTokens.FontBodySmall, UiTokens.InkBright);
_name.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_name.MaxLinesVisible = 3;
row.AddChild(_name);
column.AddChild(row);
_meta = UiKit.Label(string.Empty, UiTokens.FontTiny, UiTokens.Faint);
_meta.SizeFlagsVertical = SizeFlags.ExpandFill;
_meta.VerticalAlignment = VerticalAlignment.Bottom;
column.AddChild(_meta);
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
{
if (button.Pressed)
{
_dragging = true;
_moved = false;
Selected?.Invoke(ModId);
}
else
{
_dragging = false;
if (_moved)
DragFinished?.Invoke();
}
AcceptEvent();
return;
}
if (@event is InputEventMouseMotion motion && _dragging)
{
var layer = GetParent<Control>();
var card = layer?.GetParent<Control>() as CollectionGroupCard;
var bounds = layer?.Size ?? Vector2.Zero;
if (card != null)
bounds = new Vector2(Mathf.Max(bounds.X, card.Size.X), Mathf.Max(bounds.Y, card.Size.Y));
var max = new Vector2(
Mathf.Max(0f, bounds.X - Size.X - 2f),
Mathf.Max(0f, bounds.Y - Size.Y - 2f));
var minY = Mathf.Min(card?.ModsTop ?? 0f, max.Y);
var desired = Position + motion.Relative;
var clamped = new Vector2(Mathf.Clamp(desired.X, 0f, max.X), Mathf.Clamp(desired.Y, minY, max.Y));
if (clamped.DistanceTo(Position) > 0.5f)
_moved = true;
Position = clamped;
Moved?.Invoke(ModId, clamped);
AcceptEvent();
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using System;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.ModManager
{
/// <summary>
/// Visible resize grip in the bottom-right corner of a scheme group. The
/// mockup showed three diagonal stripes there; in Godot they are painted
/// through the <c>"draw"</c> signal because virtual overrides are unreliable
/// in mod assemblies, and the control itself only reports raw GuiInput to its
/// <see cref="CollectionGroupCard"/> owner.
/// </summary>
public partial class CollectionResizeHandle : ModWidget
{
/// <summary>Grip side in pixels; the card positions this control at its bottom-right corner.</summary>
public const int HandleSize = 18;
public CollectionResizeHandle()
{
MouseFilter = MouseFilterEnum.Stop;
CustomMinimumSize = new Vector2(HandleSize, HandleSize);
Size = new Vector2(HandleSize, HandleSize);
MouseDefaultCursorShape = CursorShape.Fdiagsize;
}
protected override void Construct()
{
Connect("draw", Callable.From((Action)DrawStripes));
}
private void DrawStripes()
{
for (var i = 0; i < 3; i++)
{
var offset = 5f + i * 5f;
DrawLine(
new Vector2(Size.X - offset, Size.Y),
new Vector2(Size.X, Size.Y - offset),
UiTokens.LineStrong,
1.5f,
true);
}
}
}
}
+567
View File
@@ -0,0 +1,567 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.ModManager
{
/// <summary>
/// Free-form scheme canvas: the whole game on one "whatman", exactly like
/// the approved mockup. Renders one <see cref="CollectionGroupCard"/> per
/// resolved <see cref="ModCollectionGroup"/> with its draggable
/// <see cref="CollectionModChip"/>s and paints dependency lines through
/// <see cref="CollectionEdgeLayer"/>.
/// <para/>
/// The canvas owns only presentation and the layout dictionary: it computes
/// a packed default arrangement for groups the player has not placed yet
/// (new groups appear below the existing ones so nothing overlaps), clamps
/// drags to the canvas, persists nothing itself and reports edits upward via
/// <see cref="LayoutChanged"/>, <see cref="GroupSelected"/>,
/// <see cref="ModSelected"/> and <see cref="GroupCommand"/>. The screen
/// decides what a command means for the current draft and when to save.
/// </summary>
public partial class CollectionSchemeCanvas : ModWidget
{
private const float ColumnWidth = 460f;
private const float GapX = 18f;
private const float GapY = 16f;
private const float Margin = 10f;
private const float ModX0 = 14f;
private const float ModY0 = 76f;
private const float ModGapX = 8f;
private const float ModGapY = 8f;
private const int ModColumns = 2;
/// <summary>
/// Floor for the scrollable workspace. A draggable canvas is useless if
/// the scrollbars disappear once the packed layout happens to fit the
/// window, so the workspace is always at least this large; content that
/// grows beyond it extends the area further.
/// </summary>
private const float MinCanvasWidth = 3200f;
/// <summary>See <see cref="MinCanvasWidth"/>.</summary>
private const float MinCanvasHeight = 2000f;
/// <summary>Raised when a group card is clicked; payload is the group key.</summary>
public event Action<string> GroupSelected;
/// <summary>Raised when a mod chip is clicked; payload is the mod id.</summary>
public event Action<string> ModSelected;
/// <summary>Raised by group header buttons; payload is the group and the command id.</summary>
public event Action<ModCollectionGroup, string> GroupCommand;
/// <summary>Raised after any drag or resize ends, so the layout can be flushed to the store.</summary>
public event Action LayoutChanged;
/// <summary>Whether dependency lines are painted; toggled from the header.</summary>
public bool ShowEdges { get; set; } = true;
/// <summary>Currently highlighted group key, if any.</summary>
public string SelectedGroupKey { get; private set; }
/// <summary>Currently highlighted mod id, if any.</summary>
public string SelectedModId { get; private set; }
private CollectionEdgeLayer _edges;
private ModCollectionGraph _graph;
private ModCollectionLayout _layout = new();
private readonly Dictionary<string, CollectionGroupCard> _cards = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, CollectionModChip> _chips = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _chipGroups = new(StringComparer.OrdinalIgnoreCase);
private bool _panning;
private Vector2 _panStartMouse;
private Vector2 _panStartScroll;
/// <summary>
/// Rebuilds the canvas for a resolved graph and its saved layout. The
/// layout object is mutated in place (new groups/mods get default
/// coordinates); pass the instance owned by the screen so the next
/// <see cref="LayoutChanged"/> can persist it.
/// </summary>
public void Build(ModCollectionGraph graph, ModCollectionLayout layout)
{
EnsureConstructed();
_graph = graph;
_layout = layout ?? new ModCollectionLayout();
_layout.Groups ??= new Dictionary<string, ModCollectionLayoutRect>();
_layout.Mods ??= new Dictionary<string, ModCollectionLayoutPoint>();
foreach (var child in GetChildren())
{
if (child != _edges)
child.QueueFree();
}
_cards.Clear();
_chips.Clear();
_chipGroups.Clear();
if (graph != null && graph.Groups.Count > 0)
{
EnsureLayout();
BuildCards();
}
_edges.SetEdges(null, _chips, this);
MoveChild(_edges, GetChildCount() - 1);
_edges.Visible = ShowEdges;
UpdateCanvasSize();
RebuildEdges();
}
/// <summary>Highlights one group and/or one mod; pass nulls to clear.</summary>
public void SetSelection(string groupKey, string modId)
{
EnsureConstructed();
SelectedGroupKey = groupKey;
SelectedModId = modId;
foreach (var pair in _cards)
pair.Value.SetSelected(string.Equals(pair.Key, groupKey, StringComparison.OrdinalIgnoreCase));
foreach (var pair in _chips)
pair.Value.SetSelected(string.Equals(pair.Key, modId, StringComparison.OrdinalIgnoreCase));
RebuildEdges();
}
/// <summary>Shows or hides dependency lines.</summary>
public void SetEdgesVisible(bool visible)
{
EnsureConstructed();
ShowEdges = visible;
_edges.Visible = visible;
if (visible)
_edges.QueueRedraw();
}
/// <summary>Finds the group key owning a rendered mod chip, for inspector navigation.</summary>
public string GetGroupKeyOfMod(string modId)
=> modId != null && _chipGroups.TryGetValue(modId, out var key) ? key : null;
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
GuiInput += OnCanvasGuiInput;
_edges = new CollectionEdgeLayer();
AddChild(_edges);
_edges.EnsureConstructed();
}
private void BuildCards()
{
foreach (var group in _graph.Groups)
{
var rect = _layout.Groups[group.Key];
var card = new CollectionGroupCard();
AddChild(card);
var summary = Summarize(group);
card.Init(group, summary.Label, summary.Color, string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase));
card.SetFrame(new Rect2(rect.X ?? 0f, rect.Y ?? 0f, rect.W, rect.H));
card.Selected += OnCardSelected;
card.Command += OnCardCommand;
card.Moved += OnCardMoved;
card.Resized += OnCardResized;
card.DragFinished += OnDragFinished;
_cards[group.Key] = card;
foreach (var mod in group.Mods)
{
var key = group.Key + "|" + mod.ModId;
var point = _layout.Mods.TryGetValue(key, out var stored) ? stored : null;
var chip = new CollectionModChip();
card.ModsLayer.AddChild(chip);
var node = FindNode(mod.ModId);
chip.Init(
mod.ModId,
mod.Role,
node?.Title ?? mod.ModId,
VersionOf(node),
StateLabel(node),
StateColor(node),
BuildModTooltip(mod, node));
chip.Position = new Vector2(point?.X ?? ModX0, point?.Y ?? ModY0);
chip.Selected += OnChipSelected;
chip.Moved += OnChipMoved;
chip.DragFinished += OnDragFinished;
if (!_chips.ContainsKey(mod.ModId))
{
_chips[mod.ModId] = chip;
_chipGroups[mod.ModId] = group.Key;
}
}
}
}
/// <summary>
/// Packs groups the player has not placed yet: a fresh canvas uses a
/// masonry layout, an already arranged canvas appends new groups below
/// everything so an added mechanic never lands on top of the work.
/// </summary>
private void EnsureLayout()
{
var unplaced = _graph.Groups
.Where(g => !_layout.Groups.TryGetValue(g.Key, out var rect) || rect == null || !rect.X.HasValue || !rect.Y.HasValue)
.Select(g => g.Key)
.ToList();
var hasPlaced = unplaced.Count < _graph.Groups.Count;
foreach (var group in _graph.Groups)
{
if (!_layout.Groups.TryGetValue(group.Key, out var rect) || rect == null)
{
rect = new ModCollectionLayoutRect();
_layout.Groups[group.Key] = rect;
}
var used = new List<ModCollectionLayoutPoint>();
foreach (var mod in group.Mods)
{
var key = group.Key + "|" + mod.ModId;
if (!_layout.Mods.TryGetValue(key, out var point) || point == null)
{
point = NextFreeModSlot(used);
_layout.Mods[key] = point;
}
used.Add(point);
}
if (rect.W <= 0f || rect.H <= 0f)
{
var maxX = used.Count > 0 ? used.Max(p => p.X + CollectionModChip.ChipWidth) : 0f;
var maxY = used.Count > 0 ? used.Max(p => p.Y + CollectionModChip.ChipHeight) : 0f;
rect.W = Mathf.Max(CollectionGroupCard.MinWidth, maxX + 16f);
rect.H = Mathf.Max(CollectionGroupCard.MinHeight, maxY + 16f);
}
}
var defaults = ComputeDefaultLayout();
var bottom = Margin;
foreach (var group in _graph.Groups)
{
var rect = _layout.Groups[group.Key];
if (rect.X.HasValue && rect.Y.HasValue && !unplaced.Contains(group.Key))
bottom = Mathf.Max(bottom, rect.Y.Value + rect.H + GapY);
}
foreach (var group in _graph.Groups)
{
var rect = _layout.Groups[group.Key];
if (!rect.X.HasValue || !rect.Y.HasValue)
{
if (!hasPlaced && defaults.TryGetValue(group.Key, out var packed))
{
rect.X = packed.X;
rect.Y = packed.Y;
}
else
{
rect.X = Margin;
rect.Y = bottom;
bottom += rect.H + GapY;
}
}
}
}
private Dictionary<string, Vector2> ComputeDefaultLayout()
{
var result = new Dictionary<string, Vector2>();
var groups = _graph?.Groups ?? new List<ModCollectionGroup>();
if (groups.Count == 0)
return result;
var maxCols = Mathf.Min(3, Mathf.Max(1, Mathf.CeilToInt(groups.Count / 2f)));
var heights = new float[maxCols];
foreach (var group in groups)
{
var height = _layout.Groups[group.Key].H;
var column = 0;
for (var i = 1; i < maxCols; i++)
{
if (heights[i] < heights[column])
column = i;
}
result[group.Key] = new Vector2(Margin + column * (ColumnWidth + GapX), Margin + heights[column]);
heights[column] += height + GapY;
}
return result;
}
private static ModCollectionLayoutPoint NextFreeModSlot(List<ModCollectionLayoutPoint> used)
{
for (var i = 0; i < 400; i++)
{
var column = i % ModColumns;
var row = i / ModColumns;
var point = new ModCollectionLayoutPoint
{
X = ModX0 + column * (CollectionModChip.ChipWidth + ModGapX),
Y = ModY0 + row * (CollectionModChip.ChipHeight + ModGapY),
};
if (!used.Any(u => Mathf.Abs(u.X - point.X) < CollectionModChip.ChipWidth * 0.9f
&& Mathf.Abs(u.Y - point.Y) < CollectionModChip.ChipHeight * 0.9f))
{
return point;
}
}
var bottom = used.Count > 0 ? used.Max(p => p.Y) : ModY0;
return new ModCollectionLayoutPoint { X = ModX0, Y = bottom + CollectionModChip.ChipHeight + ModGapY };
}
private ModCollectionNode FindNode(string modId)
{
if (_graph?.Nodes == null)
return null;
foreach (var node in _graph.Nodes)
{
if (string.Equals(node.ModId, modId, StringComparison.OrdinalIgnoreCase))
return node;
}
return null;
}
private static string VersionOf(ModCollectionNode node)
{
var version = node?.InstalledVersion;
return string.IsNullOrEmpty(version) ? null : "v" + version;
}
private static string StateLabel(ModCollectionNode node)
=> node == null ? CollectionStateNames.Translate(ModCollectionNodeState.Missing) : CollectionStateNames.Translate(node.State);
private static Color StateColor(ModCollectionNode node)
=> node == null ? CollectionStateNames.Color(ModCollectionNodeState.Missing) : CollectionStateNames.Color(node.State);
private static string BuildModTooltip(ModCollectionModRef mod, ModCollectionNode node)
{
var label = node?.Title ?? mod.ModId;
return $"{label} · {mod.ModId} · {StateLabel(node)}";
}
/// <summary>Worst-state summary shown on the group card: missing beats mismatch beats downloads beats disable.</summary>
private (string Label, Color Color) Summarize(ModCollectionGroup group)
{
if (!group.Selected)
return (TranslationServer.Translate("mod_manager/collection_group_off"), UiTokens.Warning);
var states = new List<ModCollectionNodeState>();
foreach (var mod in group.Mods)
{
var node = FindNode(mod.ModId);
states.Add(node?.State ?? ModCollectionNodeState.Missing);
}
if (states.Count == 0)
return (TranslationServer.Translate("mod_manager/collection_group_empty"), UiTokens.Info);
if (states.Any(s => s == ModCollectionNodeState.Missing))
return (CountLabel(ModCollectionNodeState.Missing, states), UiTokens.Danger);
if (states.Any(s => s == ModCollectionNodeState.VersionMismatch))
return (CountLabel(ModCollectionNodeState.VersionMismatch, states), UiTokens.Warning);
if (states.Any(s => s == ModCollectionNodeState.InCatalog))
return (CountLabel(ModCollectionNodeState.InCatalog, states), UiTokens.Info);
if (states.Any(s => s == ModCollectionNodeState.Disabled))
return (CountLabel(ModCollectionNodeState.Disabled, states), UiTokens.Warning);
return (TranslationServer.Translate("mod_manager/collection_group_ready"), UiTokens.Success);
}
private static string CountLabel(ModCollectionNodeState state, List<ModCollectionNodeState> states)
=> $"{CollectionStateNames.Translate(state)}: {states.Count(s => s == state)}";
private void OnCardSelected(string groupKey)
{
SelectedGroupKey = groupKey;
SelectedModId = null;
GroupSelected?.Invoke(groupKey);
}
private void OnChipSelected(string modId)
{
SelectedModId = modId;
SelectedGroupKey = null;
ModSelected?.Invoke(modId);
}
private void OnCardCommand(string groupKey, string command)
{
if (_cards.TryGetValue(groupKey, out var card))
GroupCommand?.Invoke(card.Group, command);
}
private void OnCardMoved(string groupKey, Vector2 position)
{
var rect = Rect(groupKey);
rect.X = position.X;
rect.Y = position.Y;
UpdateCanvasSize();
RebuildEdges();
}
private void OnCardResized(string groupKey, Vector2 size)
{
var rect = Rect(groupKey);
rect.W = size.X;
rect.H = size.Y;
UpdateCanvasSize();
RebuildEdges();
}
private void OnChipMoved(string modId, Vector2 position)
{
if (!_chipGroups.TryGetValue(modId, out var groupKey))
return;
var key = groupKey + "|" + modId;
if (!_layout.Mods.TryGetValue(key, out var point) || point == null)
{
point = new ModCollectionLayoutPoint();
_layout.Mods[key] = point;
}
point.X = position.X;
point.Y = position.Y;
RebuildEdges();
}
private void OnDragFinished() => LayoutChanged?.Invoke();
private ModCollectionLayoutRect Rect(string groupKey)
{
if (!_layout.Groups.TryGetValue(groupKey, out var rect) || rect == null)
{
rect = new ModCollectionLayoutRect();
_layout.Groups[groupKey] = rect;
}
return rect;
}
private void UpdateCanvasSize()
{
var maxX = 0f;
var maxY = 0f;
foreach (var card in _cards.Values)
{
if (!GodotObject.IsInstanceValid(card))
continue;
maxX = Mathf.Max(maxX, card.Position.X + card.Size.X);
maxY = Mathf.Max(maxY, card.Position.Y + card.Size.Y);
}
var size = new Vector2(
Mathf.Max(maxX + GapX + 160f, MinCanvasWidth),
Mathf.Max(maxY + GapY + 120f, MinCanvasHeight));
// Drives the canvas rect through the surrounding ScrollContainer.
// _edges carries FullRect anchors (CollectionEdgeLayer.Construct),
// so it follows this resize automatically; assigning its Size
// directly would be overridden and logs an anchors warning.
CustomMinimumSize = size;
}
private void RebuildEdges()
{
var edges = new List<CollectionEdge>();
if (_graph?.Edges != null)
{
foreach (var edge in _graph.Edges)
{
if (!_chips.TryGetValue(edge.FromModId, out _) || !_chips.TryGetValue(edge.ToModId, out _))
continue;
var fromGroup = GetGroupKeyOfMod(edge.FromModId);
var toGroup = GetGroupKeyOfMod(edge.ToModId);
edges.Add(new CollectionEdge
{
FromModId = edge.FromModId,
ToModId = edge.ToModId,
Cross = !string.Equals(fromGroup, toGroup, StringComparison.OrdinalIgnoreCase),
Active = string.Equals(edge.FromModId, SelectedModId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(edge.ToModId, SelectedModId, StringComparison.OrdinalIgnoreCase),
});
}
}
_edges.SetEdges(edges, _chips, this);
}
/// <summary>
/// Pans the surrounding ScrollContainer by dragging the empty canvas
/// with the left button held. The canvas floor is the only control that
/// receives this event: group cards, chips, headers and buttons consume
/// their presses first (Godot then keeps them as the mouse focus until
/// release), so their own drags stay untouched. Positions are anchored
/// to the press point instead of accumulated deltas, so a scroll edge
/// does not build up drift once the pointer returns.
/// </summary>
private void OnCanvasGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
{
if (button.Pressed)
{
var scroll = GetParent<ScrollContainer>();
if (scroll == null)
return;
_panning = true;
_panStartMouse = button.GlobalPosition;
_panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
MouseDefaultCursorShape = CursorShape.Drag;
}
else if (_panning)
{
EndPan();
}
AcceptEvent();
return;
}
if (@event is InputEventMouseMotion motion && _panning)
{
if (!motion.ButtonMask.HasFlag(MouseButtonMask.Left))
{
EndPan();
return;
}
var scroll = GetParent<ScrollContainer>();
if (scroll != null)
{
var delta = motion.GlobalPosition - _panStartMouse;
scroll.ScrollHorizontal = Mathf.RoundToInt(_panStartScroll.X - delta.X);
scroll.ScrollVertical = Mathf.RoundToInt(_panStartScroll.Y - delta.Y);
}
AcceptEvent();
}
}
private void EndPan()
{
_panning = false;
MouseDefaultCursorShape = CursorShape.Arrow;
}
}
}
+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();
}
}
}
}
+290
View File
@@ -0,0 +1,290 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Nexus-style mod tile. The layout lives in
/// <c>scenes/components/mod_card.tscn</c> so it can be edited in the Godot
/// editor; this script only binds data and reacts to input. It exposes a
/// cover image, title (always two lines tall), author, catalog source, status
/// badge, version combo box, a single contextual action button and an
/// enable/disable switch.
/// <para/>
/// All rows are reserved to the same height on purpose: tiles in the grid must
/// look identical regardless of whether a mod has a catalog entry, versions,
/// an author or an action. Version selection mutates the shared
/// <see cref="ModCardData.SelectedDownload"/> and refreshes the tile in place,
/// so changing versions never rebuilds the grid or loses scroll position.
/// </summary>
public partial class ModCard : PanelContainer
{
private ModCardData _data;
private TextureRect _image;
private Label _title;
private Label _author;
private Label _source;
private Label _status;
private OptionButton _versions;
private Button _action;
private CheckButton _enabled;
private HBoxContainer _metaRow;
private Button _collectionsLink;
private bool _updatingEnabled;
private bool _initialized;
/// <summary>Raised when the card body (not a control) is clicked.</summary>
public event Action<ModCardData> Clicked;
/// <summary>Raised when the "in N collections" cross-link badge is pressed.</summary>
public event Action CollectionsLinkRequested;
/// <summary>Raised when the contextual action button is pressed.</summary>
public event Action<ModCardData> ActionPressed;
/// <summary>Raised when the enable switch is flipped; the status is already updated.</summary>
public event Action<ModCardData> EnabledToggled;
/// <summary>Raised after a different catalog version was selected.</summary>
public event Action<ModCardData> VersionChanged;
/// <summary>
/// Binds the tile to its data. Called once by <see cref="ModGridView"/>
/// right after the scene is instantiated; the card cannot be used before.
/// </summary>
public void Init(ModCardData data)
{
if (_initialized)
return;
_initialized = true;
_data = data;
_image = GetNode<TextureRect>("VBox/Cover/Image");
_title = GetNode<Label>("VBox/Body/Box/Title");
_author = GetNode<Label>("VBox/Body/Box/Author");
_source = GetNode<Label>("VBox/Body/Box/Source");
_status = GetNode<Label>("VBox/Body/Box/MetaRow/Status");
_versions = GetNode<OptionButton>("VBox/Body/Box/MetaRow/VersionHost/Versions");
_action = GetNode<Button>("VBox/Body/Box/ActionRow/ActionHost/Action");
_enabled = GetNode<CheckButton>("VBox/Body/Box/ActionRow/EnabledHost/Enabled");
_metaRow = GetNode<HBoxContainer>("VBox/Body/Box/MetaRow");
_author.AddThemeColorOverride("font_color", UiTokens.Muted);
_source.AddThemeColorOverride("font_color", UiTokens.Muted);
AddThemeStyleboxOverride("panel", ModManagerTheme.CreateCardStyle(data.IsConflict));
_versions.ItemSelected += OnVersionSelected;
_action.Pressed += OnActionPressed;
_enabled.Toggled += OnEnabledPressed;
GuiInput += OnGuiInput;
BuildCollectionsLink();
PopulateVersions();
RefreshState();
LoadPreview();
}
/// <summary>Resynchronizes badge, version list, action and switch with the data.</summary>
public void RefreshState()
{
if (!_initialized)
return;
_title.Text = _data.Title ?? _data.Id;
_author.Text = _data.Author ?? string.Empty;
UpdateSourceLabel();
UpdateCollectionsLink();
if (_data.IsConflict)
{
_status.Text = TranslationServer.Translate("mod_manager/status_conflict");
_status.AddThemeColorOverride("font_color", UiTokens.Danger);
_status.TooltipText = TranslationServer.Translate("mod_manager/conflict_warning");
}
else
{
_status.Text = ModStatusNames.Translate(_data.Status);
_status.AddThemeColorOverride("font_color", ModStatusNames.Color(_data.Status));
_status.TooltipText = string.Empty;
}
UpdateActionButton();
UpdateEnabledToggle();
}
private void UpdateSourceLabel()
{
if (_data.Catalog == null)
{
_source.Text = TranslationServer.Translate("mod_manager/source_local");
_source.TooltipText = string.Empty;
return;
}
var label = ModCatalogSource.Label(_data.SourceUrls) ?? "—";
_source.Text = string.Format(TranslationServer.Translate("mod_manager/source_catalog"), label);
_source.TooltipText = ModCatalogSource.Tooltip(_data.SourceUrls) ?? string.Empty;
}
private void PopulateVersions()
{
_versions.Clear();
if (_data.Downloads.Count == 0)
{
_versions.AddItem("v" + (_data.LocalVersion ?? "—"));
_versions.Disabled = true;
return;
}
_versions.Disabled = false;
var selectedIndex = 0;
for (var i = 0; i < _data.Downloads.Count; i++)
{
var download = _data.Downloads[i];
var label = "v" + (download.Version ?? "?");
if (i == 0)
label += " · " + TranslationServer.Translate("mod_manager/latest");
if (_data.IsInstalled
&& !string.IsNullOrEmpty(download.Version)
&& string.Equals(download.Version, _data.LocalVersion, StringComparison.OrdinalIgnoreCase))
{
label += " · " + TranslationServer.Translate("mod_manager/installed_marker");
}
_versions.AddItem(label);
if (ReferenceEquals(download, _data.SelectedDownload))
selectedIndex = i;
}
_versions.Selected = selectedIndex;
}
/// <summary>
/// Builds the flat "in N collections" cross-link shown between the
/// status label and the version combo. The link is part of the hub
/// mockup: pressing it jumps to the collections tab. Hidden entirely
/// for mods that no playable game references.
/// </summary>
private void BuildCollectionsLink()
{
_collectionsLink = new Button
{
FocusMode = FocusModeEnum.None,
Visible = false,
};
_collectionsLink.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
_collectionsLink.AddThemeColorOverride("font_color", UiTokens.TypeTarget);
_collectionsLink.AddThemeColorOverride("font_hover_color", UiTokens.TypeTarget);
_collectionsLink.AddThemeColorOverride("font_pressed_color", UiTokens.TypeTarget);
_collectionsLink.AddThemeColorOverride("font_focus_color", UiTokens.TypeTarget);
foreach (var state in new[] { "normal", "hover", "pressed", "disabled", "focus" })
_collectionsLink.AddThemeStyleboxOverride(state, new StyleBoxEmpty());
_collectionsLink.Pressed += () => CollectionsLinkRequested?.Invoke();
_metaRow.AddChild(_collectionsLink);
_metaRow.MoveChild(_collectionsLink, 1);
}
private void UpdateCollectionsLink()
{
var text = ModCollectionLinks.BadgeText(_data.CollectionsCount);
_collectionsLink.Visible = text.Length > 0;
_collectionsLink.Text = text;
_collectionsLink.TooltipText = text;
}
private void UpdateActionButton()
{
var kind = ModActions.GetPrimary(_data);
if (kind == null)
{
_action.Visible = false;
return;
}
_action.Visible = true;
_action.Text = ModActionNames.Translate(kind.Value);
if (ModActionNames.IsDanger(kind.Value))
CthangoverTheme.ApplyDanger(_action);
else
CthangoverTheme.ApplyPrimary(_action);
}
private void UpdateEnabledToggle()
{
_enabled.Visible = _data.CanToggleEnabled;
if (!_data.CanToggleEnabled)
return;
_updatingEnabled = true;
_enabled.ButtonPressed = !_data.IsDisabled;
_updatingEnabled = false;
_enabled.TooltipText = TranslationServer.Translate(_data.IsDisabled
? "mod_manager/action_enable"
: "mod_manager/action_disable");
}
private void LoadPreview()
{
if (_data.LocalInfo != null)
{
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
if (local != null)
{
_image.Texture = local;
return;
}
}
if (_data.Catalog != null)
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
}
private void OnRemotePreviewLoaded(Texture2D texture)
{
if (texture != null && GodotObject.IsInstanceValid(_image))
_image.Texture = texture;
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mouseButton
&& mouseButton.Pressed
&& mouseButton.ButtonIndex == MouseButton.Left)
{
Clicked?.Invoke(_data);
AcceptEvent();
}
}
private void OnActionPressed()
{
ActionPressed?.Invoke(_data);
}
private void OnEnabledPressed(bool pressed)
{
if (_updatingEnabled || !_data.CanToggleEnabled)
return;
_data.IsDisabled = !pressed;
_data.RecomputeStatus();
RefreshState();
EnabledToggled?.Invoke(_data);
}
private void OnVersionSelected(long index)
{
if (index < 0 || index >= _data.Downloads.Count)
return;
_data.SelectedDownload = _data.Downloads[(int)index];
_data.RecomputeStatus();
RefreshState();
VersionChanged?.Invoke(_data);
}
}
}
+341
View File
@@ -0,0 +1,341 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Full-screen page dedicated to one mod (opened by clicking a tile). The
/// layout lives in <c>scenes/components/mod_detail_view.tscn</c> so it can be
/// edited visually; this script fills it with data, rebuilds the contextual
/// action row and reports user intent through events — the manager screen
/// performs the actual file operations.
/// <para/>
/// Conflicting catalog variants (same version, different content) get a red
/// warning block explaining both sources, on top of the red tile border the
/// card already shows.
/// </summary>
public partial class ModDetailView : VBoxContainer
{
private ModCardData _data;
private bool _nodesReady;
private TextureRect _coverImage;
private Button _back;
private Label _title;
private Label _author;
private Label _meta;
private OptionButton _versions;
private Label _installed;
private Label _size;
private HBoxContainer _actionsRow;
private VBoxContainer _conflictBox;
private Label _conflictText;
private Label _descriptionText;
private Label _changelogText;
private VBoxContainer _dependsBox;
private Label _dependsText;
private VBoxContainer _usedByBox;
private Label _usedByText;
private VBoxContainer _conflictsBox;
private Label _conflictsText;
/// <summary>Raised by the back button.</summary>
public event Action BackRequested;
/// <summary>Raised when the primary contextual action is pressed.</summary>
public event Action<ModCardData> ActionPressed;
/// <summary>Raised when the standalone delete button is pressed.</summary>
public event Action<ModCardData> DeleteRequested;
/// <summary>Raised when the enable/disable button is pressed.</summary>
public event Action<ModCardData> EnabledToggled;
/// <summary>Raised after a different catalog version is selected.</summary>
public event Action<ModCardData> VersionChanged;
/// <summary>Raised when the "in N collections" cross-link is pressed.</summary>
public event Action CollectionsLinkRequested;
/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
public void Open(ModCardData data)
{
_data = data;
Rebuild();
}
private void EnsureNodes()
{
if (_nodesReady)
return;
_nodesReady = true;
_coverImage = GetNode<TextureRect>("Scroll/Content/Hero/HeroMargin/HeroRow/Cover/Image");
_back = GetNode<Button>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Back");
_title = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Title");
_author = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Author");
_meta = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Meta");
_versions = GetNode<OptionButton>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Versions");
_installed = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Installed");
_size = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Size");
_actionsRow = GetNode<HBoxContainer>("Scroll/Content/ActionsRow");
_conflictBox = GetNode<VBoxContainer>("Scroll/Content/ConflictBox");
_conflictText = GetNode<Label>("Scroll/Content/ConflictBox/Text");
_descriptionText = GetNode<Label>("Scroll/Content/DescriptionBox/Text");
_changelogText = GetNode<Label>("Scroll/Content/ChangelogBox/Text");
_dependsBox = GetNode<VBoxContainer>("Scroll/Content/DependsBox");
_dependsText = GetNode<Label>("Scroll/Content/DependsBox/Text");
_usedByBox = GetNode<VBoxContainer>("Scroll/Content/UsedByBox");
_usedByText = GetNode<Label>("Scroll/Content/UsedByBox/Text");
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
_back.Text = TranslationServer.Translate("mod_manager/back_to_list");
_back.Pressed += OnBackPressed;
_versions.ItemSelected += OnVersionSelected;
_author.AddThemeColorOverride("font_color", UiTokens.Muted);
_installed.AddThemeColorOverride("font_color", UiTokens.Muted);
_size.AddThemeColorOverride("font_color", UiTokens.Muted);
_conflictText.AddThemeColorOverride("font_color", UiTokens.Danger);
SetCaption("Scroll/Content/ConflictBox/Caption", "mod_manager/conflict_title");
SetCaption("Scroll/Content/DescriptionBox/Caption", "mod_manager/description");
SetCaption("Scroll/Content/ChangelogBox/Caption", "mod_manager/changelog");
SetCaption("Scroll/Content/DependsBox/Caption", "mod_manager/depends");
SetCaption("Scroll/Content/UsedByBox/Caption", "mod_manager/used_by");
SetCaption("Scroll/Content/ConflictsBox/Caption", "mod_manager/conflicts");
SetCaption("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Caption", "mod_manager/version");
}
private void SetCaption(string path, string localeKey)
{
var caption = GetNode<Label>(path);
caption.Text = TranslationServer.Translate(localeKey);
caption.AddThemeColorOverride("font_color", UiTokens.Muted);
}
private void Rebuild()
{
EnsureNodes();
if (_data == null)
return;
_title.Text = _data.Title ?? _data.Id;
_author.Text = _data.Author ?? string.Empty;
if (_data.IsConflict)
{
_meta.Text = TranslationServer.Translate("mod_manager/status_conflict");
_meta.AddThemeColorOverride("font_color", UiTokens.Danger);
}
else
{
_meta.Text = $"{ModCategoryNames.Translate(ModCategoryNames.Primary(_data.Category))} · {ModStatusNames.Translate(_data.Status)}";
_meta.AddThemeColorOverride("font_color", ModStatusNames.Color(_data.Status));
}
PopulateVersions();
_installed.Text = _data.IsInstalled
? string.Format(TranslationServer.Translate("mod_manager/installed_version"), _data.LocalVersion ?? "—")
: TranslationServer.Translate("mod_manager/not_installed_hint").ToString();
var size = _data.SelectedDownload?.SizeBytes ?? 0;
_size.Text = size > 0 ? FormatSize(size) : string.Empty;
BuildActions();
BuildConflictBox();
_descriptionText.Text = NullIfEmpty(_data.Description) ?? "—";
_changelogText.Text = NullIfEmpty(_data.SelectedDownload?.Changelog) ?? "—";
SetOptional(_dependsBox, _dependsText, JoinOrEmpty(ModQueries.GetDependencies(_data)));
SetOptional(_usedByBox, _usedByText, JoinOrEmpty(ModQueries.GetDependents(_data.Id)));
SetOptional(_conflictsBox, _conflictsText, JoinOrEmpty(ModQueries.GetConflicts(_data)));
LoadCover();
}
private void PopulateVersions()
{
_versions.Clear();
_versions.Visible = _data.Downloads.Count > 0;
if (_data.Downloads.Count == 0)
return;
var selectedIndex = 0;
for (var i = 0; i < _data.Downloads.Count; i++)
{
var download = _data.Downloads[i];
var label = "v" + (download.Version ?? "?");
if (i == 0)
label += " · " + TranslationServer.Translate("mod_manager/latest");
if (_data.IsInstalled
&& !string.IsNullOrEmpty(download.Version)
&& string.Equals(download.Version, _data.LocalVersion, StringComparison.OrdinalIgnoreCase))
{
label += " · " + TranslationServer.Translate("mod_manager/installed_marker");
}
_versions.AddItem(label);
if (ReferenceEquals(download, _data.SelectedDownload))
selectedIndex = i;
}
_versions.Selected = selectedIndex;
}
private void BuildActions()
{
foreach (var child in _actionsRow.GetChildren())
{
_actionsRow.RemoveChild(child);
child.QueueFree();
}
var primary = ModActions.GetPrimary(_data);
if (primary != null)
{
var button = new Button { Text = ModActionNames.Translate(primary.Value) };
if (ModActionNames.IsDanger(primary.Value))
CthangoverTheme.ApplyDanger(button);
else
CthangoverTheme.ApplyPrimary(button);
button.Pressed += OnPrimaryPressed;
_actionsRow.AddChild(button);
}
if (primary != ModActionKind.Delete && _data.IsDeletable)
{
var delete = new Button { Text = ModActionNames.Translate(ModActionKind.Delete) };
CthangoverTheme.ApplyDanger(delete);
delete.Pressed += OnDeletePressed;
_actionsRow.AddChild(delete);
}
if (_data.CanToggleEnabled)
{
var toggle = new Button
{
Text = ModActionNames.Translate(_data.IsDisabled
? ModActionKind.Enable
: ModActionKind.Disable),
};
if (_data.IsDisabled)
CthangoverTheme.ApplyPrimary(toggle);
else
CthangoverTheme.ApplyDanger(toggle);
toggle.Pressed += OnTogglePressed;
_actionsRow.AddChild(toggle);
}
if (_data.IsProtected)
{
var hint = new Label
{
Text = TranslationServer.Translate("mod_manager/self_hint"),
VerticalAlignment = VerticalAlignment.Center,
};
hint.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
hint.AddThemeColorOverride("font_color", UiTokens.Muted);
_actionsRow.AddChild(hint);
}
var collectionsText = ModCollectionLinks.BadgeText(_data.CollectionsCount);
if (collectionsText.Length > 0)
{
var link = new Button
{
Text = collectionsText,
TooltipText = collectionsText,
FocusMode = FocusModeEnum.None,
};
link.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
link.AddThemeColorOverride("font_color", UiTokens.TypeTarget);
link.AddThemeColorOverride("font_hover_color", UiTokens.TypeTarget);
link.Pressed += () => CollectionsLinkRequested?.Invoke();
_actionsRow.AddChild(link);
}
}
private void BuildConflictBox()
{
_conflictBox.Visible = _data.IsConflict;
if (!_data.IsConflict)
{
_conflictText.Text = string.Empty;
return;
}
var sources = ModCatalogSource.Tooltip(_data.SourceUrls);
_conflictText.Text = sources == null
? TranslationServer.Translate("mod_manager/conflict_warning")
: $"{TranslationServer.Translate("mod_manager/conflict_warning")}\n{sources}";
}
private void LoadCover()
{
_coverImage.Texture = null;
if (_data.LocalInfo != null)
{
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
if (local != null)
{
_coverImage.Texture = local;
return;
}
}
if (_data.Catalog != null)
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
}
private void OnRemotePreviewLoaded(Texture2D texture)
{
if (texture != null && GodotObject.IsInstanceValid(_coverImage))
_coverImage.Texture = texture;
}
private void OnBackPressed() => BackRequested?.Invoke();
private void OnPrimaryPressed() => ActionPressed?.Invoke(_data);
private void OnDeletePressed() => DeleteRequested?.Invoke(_data);
private void OnTogglePressed() => EnabledToggled?.Invoke(_data);
private void OnVersionSelected(long index)
{
if (index < 0 || index >= _data.Downloads.Count)
return;
_data.SelectedDownload = _data.Downloads[(int)index];
_data.RecomputeStatus();
Rebuild();
VersionChanged?.Invoke(_data);
}
private static void SetOptional(VBoxContainer box, Label text, string value)
{
box.Visible = !string.IsNullOrEmpty(value);
text.Text = value ?? string.Empty;
}
private static string NullIfEmpty(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
private static string JoinOrEmpty(System.Collections.Generic.List<string> values)
=> values == null || values.Count == 0 ? null : string.Join(", ", values);
private static string FormatSize(long bytes)
{
if (bytes >= 1024L * 1024L)
return $"{bytes / (1024f * 1024f):F1} MB";
if (bytes >= 1024L)
return $"{bytes / 1024f:F0} KB";
return $"{bytes} B";
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Left filter sidebar. The frame (title, reset button, section captions and
/// the grouping toggle) lives in
/// <c>scenes/components/mod_filter_panel.tscn</c> for visual editing; this
/// script fills the two list containers with per-status and per-category
/// checkboxes and keeps the shared <see cref="ModFilterState"/> in sync.
/// The panel does not own the state and never rebuilds the grid itself — it
/// announces every change through <see cref="Changed"/>.
/// </summary>
public partial class ModFilterPanel : VBoxContainer
{
private ModFilterState _state;
private Label _title;
private Button _reset;
private Label _statusCaption;
private Label _categoryCaption;
private CheckButton _groupToggle;
private VBoxContainer _statusList;
private VBoxContainer _categoryList;
private readonly Dictionary<ModItemStatus, CheckBox> _statusBoxes = new();
private readonly Dictionary<CategoryType, CheckBox> _categoryBoxes = new();
/// <summary>Raised after any filter or grouping change.</summary>
public event Action Changed;
/// <summary>
/// Raised after the reset button cleared the state. The panel cannot
/// touch the header's search box, so the screen clears it in response.
/// </summary>
public event Action ResetRequested;
/// <summary>Binds the sidebar to the shared filter state; call once, after the scene is built.</summary>
public void Bind(ModFilterState state)
{
_state = state;
_title = GetNode<Label>("Title");
_reset = GetNode<Button>("Reset");
_statusCaption = GetNode<Label>("StatusCaption");
_categoryCaption = GetNode<Label>("CategoryCaption");
_groupToggle = GetNode<CheckButton>("GroupToggle");
_statusList = GetNode<VBoxContainer>("StatusList");
_categoryList = GetNode<VBoxContainer>("CategoryList");
_title.Text = TranslationServer.Translate("mod_manager/filters");
_reset.Text = TranslationServer.Translate("mod_manager/reset_filters");
_statusCaption.Text = TranslationServer.Translate("mod_manager/filter_status");
_categoryCaption.Text = TranslationServer.Translate("mod_manager/filter_categories");
_groupToggle.Text = TranslationServer.Translate("mod_manager/group_by_category");
_groupToggle.ButtonPressed = _state.GroupByCategory;
_statusCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
_categoryCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
_reset.Pressed += OnResetPressed;
_groupToggle.Toggled += OnGroupToggled;
foreach (var status in Enum.GetValues<ModItemStatus>())
AddStatusCheck(status);
foreach (var category in ModCategoryNames.Ordered)
AddCategoryCheck(category);
}
/// <summary>Refreshes the numbers in the checkbox captions.</summary>
public void RefreshCounts(
Dictionary<CategoryType, int> categoryCounts,
Dictionary<ModItemStatus, int> statusCounts)
{
foreach (var pair in _statusBoxes)
{
var count = statusCounts.GetValueOrDefault(pair.Key);
pair.Value.Text = $"{ModStatusNames.Translate(pair.Key)} ({count})";
}
foreach (var pair in _categoryBoxes)
{
var count = categoryCounts.GetValueOrDefault(pair.Key);
pair.Value.Text = $"{ModCategoryNames.Translate(pair.Key)} ({count})";
}
}
private void AddStatusCheck(ModItemStatus status)
{
var box = new CheckBox
{
Text = ModStatusNames.Translate(status),
ButtonPressed = _state.Statuses.Contains(status),
};
box.Toggled += pressed => OnStatusToggled(status, pressed);
_statusBoxes[status] = box;
_statusList.AddChild(box);
}
private void AddCategoryCheck(CategoryType category)
{
var box = new CheckBox
{
Text = ModCategoryNames.Translate(category),
ButtonPressed = _state.Categories.Contains(category),
};
box.Toggled += pressed => OnCategoryToggled(category, pressed);
_categoryBoxes[category] = box;
_categoryList.AddChild(box);
}
private void OnStatusToggled(ModItemStatus status, bool pressed)
{
if (pressed)
_state.Statuses.Add(status);
else
_state.Statuses.Remove(status);
Changed?.Invoke();
}
private void OnCategoryToggled(CategoryType category, bool pressed)
{
if (pressed)
_state.Categories.Add(category);
else
_state.Categories.Remove(category);
Changed?.Invoke();
}
private void OnGroupToggled(bool pressed)
{
_state.GroupByCategory = pressed;
Changed?.Invoke();
}
private void OnResetPressed()
{
_state.Reset();
foreach (var box in _statusBoxes.Values)
box.SetPressedNoSignal(false);
foreach (var box in _categoryBoxes.Values)
box.SetPressedNoSignal(false);
ResetRequested?.Invoke();
Changed?.Invoke();
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// One section of the mod grid: an optional category header plus the
/// <see cref="HFlowContainer"/> that wraps the tiles. The layout lives in
/// <c>scenes/components/mod_grid_group.tscn</c> so the header style and flow
/// spacing can be tuned in the editor; the grid instantiates one group per
/// category when grouping is enabled, or a single header-less group otherwise.
/// </summary>
public partial class ModGridGroup : VBoxContainer
{
private Label _header;
private HFlowContainer _flow;
/// <summary>Container the grid adds <see cref="ModCard"/> tiles to.</summary>
public HFlowContainer Flow
{
get
{
EnsureNodes();
return _flow;
}
}
/// <summary>Sets (or hides with null/empty) the section header text.</summary>
public void SetHeader(string text)
{
EnsureNodes();
_header.Text = text ?? string.Empty;
_header.Visible = !string.IsNullOrEmpty(text);
}
private void EnsureNodes()
{
if (_flow != null)
return;
_header = GetNode<Label>("Header");
_flow = GetNode<HFlowContainer>("Flow");
}
}
}
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Mods.ModManager
{
/// <summary>
/// Scrollable grid of <see cref="ModCard"/> tiles, composed of
/// <see cref="ModGridGroup"/> sections (see
/// <c>scenes/components/mod_grid_view.tscn</c>). Cards flow inside
/// <see cref="HFlowContainer"/>s, so the number of columns adapts to the
/// viewport width — the Godot answer to Nexus's responsive auto-fill grid.
/// In grouping mode one section per category is created with a header; in flat
/// mode a single header-less section is used.
/// </summary>
public partial class ModGridView : ScrollContainer
{
private const string GroupScene = "scenes/components/mod_grid_group.tscn";
private const string CardScene = "scenes/components/mod_card.tscn";
private VBoxContainer _host;
/// <summary>Raised when a card body is clicked.</summary>
public event Action<ModCardData> CardClicked;
/// <summary>Raised when a card's action button is pressed.</summary>
public event Action<ModCardData> ActionPressed;
/// <summary>Raised when a card's enable switch is flipped.</summary>
public event Action<ModCardData> EnabledToggled;
/// <summary>Raised after a card's version selection changes.</summary>
public event Action<ModCardData> VersionChanged;
/// <summary>Raised when any card's "in N collections" cross-link is pressed.</summary>
public event Action CollectionsLinkRequested;
/// <summary>Rebuilds the grid from scratch; called on every filter change.</summary>
public void SetItems(IReadOnlyList<ModCardData> items, bool groupByCategory)
{
if (_host == null)
_host = GetNode<VBoxContainer>("Host");
foreach (var child in _host.GetChildren())
child.QueueFree();
if (items == null || items.Count == 0)
{
_host.AddChild(BuildEmptyLabel());
return;
}
if (!groupByCategory)
{
FillGroup(AddGroup(null), items);
return;
}
foreach (var category in ModCategoryNames.Ordered)
{
var group = new List<ModCardData>();
foreach (var item in items)
{
if (ModCategoryNames.Primary(item.Category) == category)
group.Add(item);
}
if (group.Count == 0)
continue;
var header = $"{ModCategoryNames.Translate(category)} · {group.Count}";
FillGroup(AddGroup(header), group);
}
}
private ModGridGroup AddGroup(string header)
{
var group = TscnScenes.LoadAndBuild(GroupScene) as ModGridGroup;
if (group == null)
{
GameLogger.Log("MOD_MANAGER", $"Grid: failed to build '{GroupScene}'", LogLevel.Error);
return null;
}
group.SetHeader(header);
_host.AddChild(group);
return group;
}
private void FillGroup(ModGridGroup group, IReadOnlyList<ModCardData> items)
{
if (group == null)
return;
foreach (var data in items)
{
var card = TscnScenes.LoadAndBuild(CardScene) as ModCard;
if (card == null)
{
GameLogger.Log("MOD_MANAGER", $"Grid: failed to build '{CardScene}'", LogLevel.Error);
continue;
}
group.Flow.AddChild(card);
card.Clicked += OnCardClicked;
card.ActionPressed += OnCardActionPressed;
card.EnabledToggled += OnCardEnabledToggled;
card.VersionChanged += OnCardVersionChanged;
card.CollectionsLinkRequested += () => CollectionsLinkRequested?.Invoke();
card.Init(data);
}
}
private static Label BuildEmptyLabel()
{
return UiKit.EmptyState(TranslationServer.Translate("mod_manager/no_results"));
}
private void OnCardClicked(ModCardData data) => CardClicked?.Invoke(data);
private void OnCardActionPressed(ModCardData data) => ActionPressed?.Invoke(data);
private void OnCardEnabledToggled(ModCardData data) => EnabledToggled?.Invoke(data);
private void OnCardVersionChanged(ModCardData data) => VersionChanged?.Invoke(data);
}
}
+81
View File
@@ -0,0 +1,81 @@
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Mods.ModManager
{
/// <summary>
/// Compatibility facade over the central <see cref="CthangoverTheme"/> and
/// <see cref="UiTokens"/>. The mod manager once carried its own Nexus-like
/// palette; now every colour is a design token and the shared theme cascades
/// from the screen root. Public names are preserved so existing widgets keep
/// compiling, but each one is an alias or a thin wrapper around the core
/// theming API. New code should reference <see cref="UiTokens"/>,
/// <see cref="CthangoverTheme"/> and <see cref="UiKit"/> directly.
/// </summary>
public static class ModManagerTheme
{
/// <summary>Page background.</summary>
public static readonly Color BgBase = UiTokens.Surface0;
/// <summary>Card and sidebar surface.</summary>
public static readonly Color SurfaceLow = UiTokens.Surface1;
/// <summary>Raised control surface.</summary>
public static readonly Color SurfaceRaised = UiTokens.Surface3;
/// <summary>Primary text.</summary>
public static readonly Color TextStrong = UiTokens.InkBright;
/// <summary>Secondary text.</summary>
public static readonly Color TextMuted = UiTokens.Muted;
/// <summary>Hairline border.</summary>
public static readonly Color BorderWeak = UiTokens.Line;
/// <summary>Control border.</summary>
public static readonly Color BorderStrong = UiTokens.LineStrong;
/// <summary>Gold accent.</summary>
public static readonly Color Accent = UiTokens.Accent;
/// <summary>Text on accent buttons.</summary>
public static readonly Color AccentText = UiTokens.Surface0;
/// <summary>Danger red for destructive actions.</summary>
public static readonly Color Danger = UiTokens.Danger;
/// <summary>Returns the single shared theme; the manager no longer owns a private one.</summary>
public static Theme Create() => CthangoverTheme.Get();
/// <summary>
/// Style for a mod tile. The negative top/left content margins shift the
/// cover image 1px up and make it 1px wider so it sits flush over the
/// card border. A conflicting catalog variant gets a danger border.
/// </summary>
public static StyleBoxFlat CreateCardStyle(bool conflict)
{
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, conflict ? UiTokens.Danger : UiTokens.Line);
style.ContentMarginTop = -1;
style.ContentMarginLeft = -1;
return style;
}
/// <summary>Applies the shared primary accent variation to a button.</summary>
public static void ApplyPrimaryButton(Button button) => CthangoverTheme.ApplyPrimary(button);
/// <summary>Applies the shared danger variation to a button.</summary>
public static void ApplyDangerButton(Button button) => CthangoverTheme.ApplyDanger(button);
/// <summary>Creates a flat style box with an optional border and padding.</summary>
public static StyleBoxFlat MakeBox(Color background, Color border, int radius, int paddingH, int paddingV)
{
var style = UiKit.Frame(radius, background, border);
style.ContentMarginLeft = paddingH;
style.ContentMarginRight = paddingH;
style.ContentMarginTop = paddingV;
style.ContentMarginBottom = paddingV;
return style;
}
}
}
+59
View File
@@ -0,0 +1,59 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Deleting a player's game collection, shared by the gallery card and the
/// scheme editor header so both ask the same question. Only the player's
/// own collections can go: a collection a mod or the catalog ships would
/// come back on the next merge (its edited version is dropped with
/// "Revert to built-in" instead). Deleting touches only
/// <c>user://collections.json</c> — the mods of the collection stay
/// installed and its saves stay on disk (they reappear if a collection
/// with the same id comes back), which the confirmation spells out.
/// </summary>
public static class CollectionDeletion
{
/// <summary>True for a player-made collection (saved or still a draft) that no mod or catalog ships.</summary>
public static bool CanDelete(ModCollection collection)
{
return collection != null
&& collection.Origin == ModCollectionOrigin.User
&& !ModCollectionStore.HasShippedVersion(collection.Id);
}
/// <summary>
/// Asks for confirmation under <paramref name="host"/>; on "delete"
/// removes the saved collection and calls <paramref name="deleted"/>
/// (the caller also drops its unsaved draft and refreshes its view).
/// </summary>
public static void Confirm(Node host, ModCollection collection, Action deleted)
{
if (host == null || !CanDelete(collection))
return;
var name = CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale()));
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("mod_manager/collection_delete"),
DialogText = string.Format(TranslationServer.Translate("mod_manager/collection_delete_confirm"),
name, SaveService.ListSaves(collection.Id).Count),
OkButtonText = TranslationServer.Translate("mod_manager/collection_delete"),
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
Exclusive = true,
};
dialog.Confirmed += () =>
{
ModCollectionStore.RemoveUser(collection.Id);
dialog.QueueFree();
deleted?.Invoke();
};
dialog.Canceled += dialog.QueueFree;
host.AddChild(dialog);
dialog.PopupCentered();
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using Cthangover.Core.Mods;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Locale keys and node colors for <see cref="ModCollectionNodeState"/>,
/// following the same pattern as <see cref="ModStatusNames"/> so the
/// collections graph reads like the rest of the manager: green — ready to
/// play, amber — needs an action (enable, resolve a version pin), blue —
/// downloadable from the catalog, red — impossible to resolve.
/// </summary>
public static class CollectionStateNames
{
/// <summary>Localized state label shown inside graph nodes and in the details panel.</summary>
public static string Translate(ModCollectionNodeState state)
=> TranslationServer.Translate(Key(state));
/// <summary>Translation key of a state.</summary>
public static string Key(ModCollectionNodeState state) => state switch
{
ModCollectionNodeState.Installed => "mod_manager/collection_state_installed",
ModCollectionNodeState.Disabled => "mod_manager/collection_state_disabled",
ModCollectionNodeState.InCatalog => "mod_manager/collection_state_catalog",
ModCollectionNodeState.VersionMismatch => "mod_manager/collection_state_mismatch",
_ => "mod_manager/collection_state_missing",
};
/// <summary>Titlebar/badge color for a state.</summary>
public static Color Color(ModCollectionNodeState state) => state switch
{
ModCollectionNodeState.Installed => UiTokens.Success,
ModCollectionNodeState.Disabled => UiTokens.Warning,
ModCollectionNodeState.InCatalog => UiTokens.Info,
ModCollectionNodeState.VersionMismatch => UiTokens.Warning,
_ => UiTokens.Danger,
};
}
}
+27
View File
@@ -0,0 +1,27 @@
using Godot;
namespace Mods.ModManager
{
/// <summary>Locale keys and styling hints for <see cref="ModActionKind"/>.</summary>
public static class ModActionNames
{
/// <summary>Localized action button caption.</summary>
public static string Translate(ModActionKind kind)
=> TranslationServer.Translate(Key(kind));
/// <summary>Translation key of an action.</summary>
public static string Key(ModActionKind kind) => kind switch
{
ModActionKind.Install => "mod_manager/action_install",
ModActionKind.Update => "mod_manager/action_update",
ModActionKind.Downgrade => "mod_manager/action_downgrade",
ModActionKind.Reinstall => "mod_manager/action_reinstall",
ModActionKind.Delete => "mod_manager/action_delete",
ModActionKind.Enable => "mod_manager/action_enable",
_ => "mod_manager/action_disable",
};
/// <summary>True for destructive actions that deserve the danger button style.</summary>
public static bool IsDanger(ModActionKind kind) => kind == ModActionKind.Delete;
}
}
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace Mods.ModManager
{
/// <summary>
/// Presentation helpers for catalog provenance. The manager shows where a mod
/// comes from without cluttering the tile: a short host label plus the full
/// URL list in the tooltip, and a "+N" marker when several catalogs publish
/// the same build.
/// </summary>
public static class ModCatalogSource
{
/// <summary>First source URL, or null when there are none.</summary>
public static string FirstUrl(IReadOnlyList<string> urls)
=> urls != null && urls.Count > 0 ? urls[0] : null;
/// <summary>Host of a URL (fallback: the raw string when it does not parse).</summary>
public static string Host(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
return uri.Host;
return url;
}
/// <summary>Short label: host plus "+N" for extra catalogs, or null when unknown.</summary>
public static string Label(IReadOnlyList<string> urls)
{
var first = FirstUrl(urls);
if (first == null)
return null;
var host = Host(first);
var extra = urls.Count - 1;
return extra > 0 ? $"{host} +{extra}" : host;
}
/// <summary>Newline-joined full URL list for tooltips.</summary>
public static string Tooltip(IReadOnlyList<string> urls)
=> urls == null || urls.Count == 0 ? null : string.Join("\n", urls);
}
}
+69
View File
@@ -0,0 +1,69 @@
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// Presentation names for <see cref="CategoryType"/>. Kept inside the
/// mod_manager mod (keys prefixed <c>mod_manager/category_</c>) so the manager
/// does not depend on the interface mod's locale for its filter sidebar.
/// </summary>
public static class ModCategoryNames
{
/// <summary>Display order of category filters and group headers.</summary>
public static readonly CategoryType[] Ordered =
{
CategoryType.Story,
CategoryType.Characters,
CategoryType.Gameplay,
CategoryType.Visual,
CategoryType.Audio,
CategoryType.UI,
CategoryType.Locale,
CategoryType.Library,
CategoryType.Engine,
CategoryType.Overhaul,
CategoryType.Tools,
CategoryType.Editor,
CategoryType.Debug,
CategoryType.Other,
};
/// <summary>
/// First single category of a (possibly combined) flags value, used to
/// place a mod into exactly one grid group and one count bucket per
/// category it belongs to.
/// </summary>
public static CategoryType Primary(CategoryType category)
{
foreach (var candidate in Ordered)
{
if ((category & candidate) != 0)
return candidate;
}
return CategoryType.Other;
}
/// <summary>Localized category name for filters and group headers.</summary>
public static string Translate(CategoryType category)
=> Godot.TranslationServer.Translate(Key(category));
/// <summary>Translation key of a single (non-combined) category.</summary>
public static string Key(CategoryType category) => category switch
{
CategoryType.Story => "mod_manager/category_story",
CategoryType.Characters => "mod_manager/category_characters",
CategoryType.Gameplay => "mod_manager/category_gameplay",
CategoryType.Visual => "mod_manager/category_visual",
CategoryType.Audio => "mod_manager/category_audio",
CategoryType.UI => "mod_manager/category_ui",
CategoryType.Locale => "mod_manager/category_locale",
CategoryType.Library => "mod_manager/category_library",
CategoryType.Engine => "mod_manager/category_engine",
CategoryType.Overhaul => "mod_manager/category_overhaul",
CategoryType.Tools => "mod_manager/category_tools",
CategoryType.Editor => "mod_manager/category_editor",
CategoryType.Debug => "mod_manager/category_debug",
_ => "mod_manager/category_other",
};
}
}
+25
View File
@@ -0,0 +1,25 @@
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Shared texts for the mod-to-collection cross links required by the hub
/// mockup: mod tiles and the detail page show a clickable "in N
/// collections" badge that jumps to the collections tab. Centralized so
/// the card and the detail page always render the same pluralized string.
/// </summary>
public static class ModCollectionLinks
{
/// <summary>Localized "in N collection(s)" label for the given count; empty when the mod belongs to no game.</summary>
public static string BadgeText(int count)
{
if (count <= 0)
return string.Empty;
var key = count == 1
? "mod_manager/hub_in_one_collection"
: "mod_manager/hub_in_many_collections";
return string.Format(TranslationServer.Translate(key), count);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Caches;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Loads preview images for mod tiles and the detail page. Local mods use
/// their bundled <c>mod.png</c> / <c>mod.jpg</c>; catalog-only mods fall back
/// to <see cref="ModIconCache"/>, which downloads and caches the entry's
/// <c>icon_url</c> under <c>user://mod_cache/icons</c>. Returning null is a
/// normal outcome — the card then keeps its flat placeholder.
/// </summary>
public static class ModPreviewLoader
{
/// <summary>Reads the mod's bundled preview image, or null when absent/unreadable.</summary>
public static Texture2D LoadLocal(ModInfo info)
{
var provider = info?.FileProvider;
if (provider == null)
return null;
byte[] data = null;
if (provider.FileExists("mod.png"))
data = provider.ReadFileBinary("mod.png");
else if (provider.FileExists("mod.jpg"))
data = provider.ReadFileBinary("mod.jpg");
if (data == null || data.Length == 0)
return null;
var image = new Image();
Error error;
if (data[0] == 0x89)
error = image.LoadPngFromBuffer(data);
else if (data[0] == 0xFF)
error = image.LoadJpgFromBuffer(data);
else
return null;
if (error != Error.Ok)
{
image.Dispose();
return null;
}
var texture = ImageTexture.CreateFromImage(image);
image.Dispose();
return texture;
}
/// <summary>
/// Asynchronously resolves a catalog icon; the callback may fire later
/// (network) or immediately (memory/disk cache).
/// </summary>
public static void LoadRemote(string modId, string iconUrl, Action<Texture2D> onLoaded)
{
if (string.IsNullOrEmpty(iconUrl))
return;
ModIconCache.GetOrDownload(modId, iconUrl, onLoaded);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Locale keys, badge colors and sort ranks for <see cref="ModItemStatus"/>.
/// Colors are semantic <see cref="UiTokens"/> entries shared with the rest of
/// the UI: success installed, warning update, info downgrade, danger locally
/// modified, muted disabled, faint not installed.
/// </summary>
public static class ModStatusNames
{
/// <summary>Localized status label shown on tiles and the detail page.</summary>
public static string Translate(ModItemStatus status)
=> TranslationServer.Translate(Key(status));
/// <summary>Translation key of a status.</summary>
public static string Key(ModItemStatus status) => status switch
{
ModItemStatus.Installed => "mod_manager/status_installed",
ModItemStatus.UpdateAvailable => "mod_manager/status_update",
ModItemStatus.DowngradeAvailable => "mod_manager/status_downgrade",
ModItemStatus.ModifiedLocally => "mod_manager/status_modified",
ModItemStatus.Disabled => "mod_manager/status_disabled",
_ => "mod_manager/status_not_installed",
};
/// <summary>Badge color for a status.</summary>
public static Color Color(ModItemStatus status) => status switch
{
ModItemStatus.Installed => UiTokens.Success,
ModItemStatus.UpdateAvailable => UiTokens.Warning,
ModItemStatus.DowngradeAvailable => UiTokens.Info,
ModItemStatus.ModifiedLocally => UiTokens.Danger,
ModItemStatus.Disabled => UiTokens.Muted,
_ => UiTokens.Faint,
};
/// <summary>
/// Rank used by <see cref="ModSortMode.Status"/>: highlights actionable
/// states (update, modified) before plain installed and catalog-only mods.
/// </summary>
public static int SortRank(ModItemStatus status) => status switch
{
ModItemStatus.UpdateAvailable => 0,
ModItemStatus.ModifiedLocally => 1,
ModItemStatus.Installed => 2,
ModItemStatus.DowngradeAvailable => 3,
ModItemStatus.Disabled => 4,
_ => 5,
};
}
}