Improve collection graph controls, version rules and help

This commit is contained in:
cthangover committed 2026-10-26 20:22:58 +03:00
1 parent b6c61ea3df
commit 7ae31f180c
24 files changed
+2201 -311

No files matched your search

+375 -42
View File
@@ -11,6 +11,7 @@ using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
namespace Mods.ModManager
{
/// <summary>
@@ -39,7 +40,7 @@ namespace Mods.ModManager
private Button _newGame;
private Button _apply;
private OptionButton _gameSelect;
private CheckBox _edgesToggle;
private OptionButton _edgesToggle;
private Button _pillProblems;
private Button _pillPlan;
private Label _modsCount;
@@ -86,6 +87,17 @@ namespace Mods.ModManager
private Button _revert;
private Button _delete;
private Label _savesInfo;
private Button _addMod;
private LineEdit _canvasSearch;
private Label _zoomLabel;
private VBoxContainer _versionEditor;
private OptionButton _versionPolicy;
private LineEdit _versionValue;
private Label _versionRule;
private Label _versionFeedback;
private ModCollectionModRef _versionReference;
private bool _fitAfterBuild = true;
private bool _fitQueued;
/// <summary>Drafts created by "Duplicate", mapped to the game they were copied from (offer to copy its saves).</summary>
private readonly Dictionary<string, string> _duplicatedFrom = new(StringComparer.OrdinalIgnoreCase);
@@ -134,6 +146,7 @@ namespace Mods.ModManager
public void OpenCollection(string collectionId)
{
_currentId = collectionId;
_fitAfterBuild = true;
_selectedGroupKey = null;
_selectedModId = null;
// The gallery re-reads the store on every refresh; re-read it here
@@ -163,6 +176,254 @@ namespace Mods.ModManager
UnsubscribeEvents();
}
private void BindGraphiteControls()
{
var tools = "Margin/Root/Tools/";
_addMod = GetNode<Button>(tools + "AddMod");
_addMod.Text = TranslationServer.Translate("mod_manager/collection_add_mod");
_addMod.Pressed += OnAddModPressed;
_canvasSearch = GetNode<LineEdit>(tools + "Search");
_canvasSearch.PlaceholderText = TranslationServer.Translate("mod_manager/collection_canvas_search");
_canvasSearch.TextChanged += _canvas.SetSearch;
_zoomLabel = GetNode<Label>(tools + "Zoom");
GetNode<Button>(tools + "ZoomOut").Pressed += _canvas.ZoomOut;
GetNode<Button>(tools + "ZoomIn").Pressed += _canvas.ZoomIn;
var fit = GetNode<Button>(tools + "Fit");
fit.Text = TranslationServer.Translate("mod_manager/collection_canvas_fit");
fit.Pressed += _canvas.FitWidth;
var arrange = GetNode<Button>(tools + "Arrange");
arrange.Text = TranslationServer.Translate("mod_manager/collection_canvas_arrange");
arrange.Pressed += _canvas.ArrangeGroups;
var focus = GetNode<Button>(tools + "Focus");
focus.Text = TranslationServer.Translate("mod_manager/collection_canvas_focus");
focus.Pressed += _canvas.ShowSelection;
_canvas.SetMiniMap(GetNode<CollectionMiniMap>("Margin/Root/Body/CanvasHost/MiniMap"));
var path = "Margin/Root/Body/Details/DetailScroll/Detail/VersionEditor";
_versionEditor = GetNode<VBoxContainer>(path);
_versionPolicy = GetNode<OptionButton>(path + "/Fields/Policy");
_versionValue = GetNode<LineEdit>(path + "/Fields/Value");
_versionRule = GetNode<Label>(path + "/Rule");
_versionFeedback = GetNode<Label>(path + "/Feedback");
GetNode<Label>(path + "/Caption").Text = TranslationServer.Translate("mod_manager/collection_version_caption");
GetNode<Label>(path + "/Hint").Text = TranslationServer.Translate("mod_manager/collection_version_hint");
var save = GetNode<Button>(path + "/SaveRule");
save.Text = TranslationServer.Translate("mod_manager/collection_version_edit");
save.Pressed += OnSaveVersionPressed;
_versionPolicy.ItemSelected += OnVersionPolicySelected;
_versionValue.TextChanged += OnVersionValueChanged;
_versionRule.AddThemeColorOverride("font_color", UiTokens.Accent);
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
var policies = new[]
{
"mod_manager/collection_version_any", "mod_manager/collection_version_minimum",
"mod_manager/collection_version_exact", "mod_manager/collection_version_branch",
"mod_manager/collection_version_maximum", "mod_manager/collection_version_greater",
"mod_manager/collection_version_less",
};
var operators = new[] { "", ">=", "=", "^", "<=", ">", "<" };
for (var i = 0; i < policies.Length; i++)
{
_versionPolicy.AddItem(TranslationServer.Translate(policies[i]));
_versionPolicy.SetItemMetadata(i, operators[i]);
}
}
private void OnGalleryPressed() => GalleryRequested?.Invoke();
private void OnBuildManagerPressed() => SelectTab("plan");
private void OnAddModPressed()
{
var group = _selectedGroupKey == null ? null : FindGroup(_selectedGroupKey);
if (group != null)
OpenModPicker(group);
}
private void OnCanvasViewChanged()
{
if (_zoomLabel != null)
_zoomLabel.Text = Mathf.RoundToInt(_canvas.Zoom * 100f) + "%";
}
private void OnCanvasFirstFrame()
{
GetTree().ProcessFrame -= OnCanvasFirstFrame;
_fitQueued = false;
_fitAfterBuild = false;
_canvas.FitWidth();
while (_canvas.Zoom < 0.75f)
_canvas.ZoomIn();
_canvas.ShowSelection();
}
private void BindVersionRule(ModCollectionGroup group, string modId)
{
foreach (var reference in group.Mods)
{
if (string.Equals(reference.ModId, modId, StringComparison.OrdinalIgnoreCase))
{
_versionReference = reference;
break;
}
}
if (_versionReference == null)
return;
var owner = EffectiveCollection(_versionReference.CollectionId);
foreach (var mechanic in owner?.Mechanics ?? new List<ModCollectionMechanic>())
{
if (!string.Equals(mechanic.Id, _versionReference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
foreach (var raw in mechanic.Mods ?? new List<string>())
{
var requirement = ModDependency.Parse(raw);
if (!requirement.IsValid || !string.Equals(requirement.ModId, modId, StringComparison.OrdinalIgnoreCase))
continue;
for (var i = 0; i < _versionPolicy.ItemCount; i++)
{
if (_versionPolicy.GetItemMetadata(i).AsString() == requirement.Operator)
_versionPolicy.Selected = i;
}
_versionValue.Text = requirement.VersionRaw ?? string.Empty;
if (requirement.VersionRaw == null
&& ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId, out var installed))
_versionValue.Text = installed.Version ?? string.Empty;
_versionEditor.Visible = true;
_versionEditor.GetNode<Label>("Caption").Text = TranslationServer.Translate("mod_manager/collection_version_caption")
+ " · " + group.Name;
OnVersionPolicySelected(_versionPolicy.Selected);
return;
}
}
}
private void OnVersionPolicySelected(long index)
{
var op = _versionPolicy.GetItemMetadata((int)index).AsString();
_versionValue.Editable = op.Length > 0;
OnVersionValueChanged(_versionValue.Text);
}
private void OnVersionValueChanged(string value)
{
if (_versionReference == null || _versionPolicy.Selected < 0)
return;
var op = _versionPolicy.GetItemMetadata(_versionPolicy.Selected).AsString();
_versionRule.Text = _versionReference.ModId + op + (op.Length > 0 ? value.Trim() : string.Empty);
_versionFeedback.Text = op == "^"
? TranslationServer.Translate("mod_manager/collection_version_branch_hint") : string.Empty;
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.Muted);
foreach (var node in _graph?.Nodes ?? new List<ModCollectionNode>())
{
if (node.ModId != _versionReference.ModId || node.State != ModCollectionNodeState.VersionMismatch)
continue;
var requirements = new List<string>();
foreach (var requirement in node.Requirements)
{
if (!string.IsNullOrEmpty(requirement.Operator))
requirements.Add(requirement.Operator + requirement.VersionRaw);
}
_versionFeedback.Text = string.Format(TranslationServer.Translate("mod_manager/installed_version"), node.InstalledVersion)
+ "\n" + string.Format(TranslationServer.Translate("mod_manager/collection_requires"), string.Join(", ", requirements));
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
break;
}
}
private void OnSaveVersionPressed()
{
if (_versionReference == null || _versionPolicy.Selected < 0)
return;
var group = FindGroup(_selectedGroupKey);
if (group == null)
return;
var reference = _versionReference;
var op = _versionPolicy.GetItemMetadata(_versionPolicy.Selected).AsString();
var owner = EffectiveCollection(reference.CollectionId);
var valid = false;
var changed = false;
foreach (var mechanic in owner?.Mechanics ?? new List<ModCollectionMechanic>())
{
if (!string.Equals(mechanic.Id, reference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
var preview = new ModCollectionMechanic { Mods = new List<string>(mechanic.Mods ?? new List<string>()) };
valid = preview.TrySetModVersion(reference.ModId, op, _versionValue.Text);
for (var i = 0; i < preview.Mods.Count; i++)
{
if (preview.Mods[i] != mechanic.Mods[i])
changed = true;
}
break;
}
if (!valid)
{
_versionFeedback.Text = TranslationServer.Translate("mod_manager/collection_version_invalid");
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
return;
}
if (!changed)
return;
var collection = DraftForEdit(group, reference.CollectionId);
if (collection == null)
return;
foreach (var mechanic in collection.Mechanics ?? new List<ModCollectionMechanic>())
{
if (!string.Equals(mechanic.Id, reference.MechanicId, StringComparison.OrdinalIgnoreCase))
continue;
if (!mechanic.TrySetModVersion(reference.ModId, op, _versionValue.Text))
{
_versionFeedback.Text = TranslationServer.Translate("mod_manager/collection_version_invalid");
_versionFeedback.AddThemeColorOverride("font_color", UiTokens.DangerText);
return;
}
Rebuild();
return;
}
}
private void AddModRelationships(string modId)
{
AddInfoLine(TranslationServer.Translate("mod_manager/collection_role_hint"));
foreach (var edge in _graph.Edges)
{
if (string.Equals(edge.FromModId, modId, StringComparison.OrdinalIgnoreCase))
{
var consumer = LookupModTitle(edge.ToModId);
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_used_by"), consumer));
}
}
foreach (var collection in _collections)
{
if (string.Equals(collection.Id, _currentId, StringComparison.OrdinalIgnoreCase)
|| collection.Kind != ModCollectionKind.Game)
continue;
var effective = EffectiveCollection(collection.Id);
foreach (var mechanic in effective?.Mechanics ?? new List<ModCollectionMechanic>())
{
foreach (var raw in mechanic.Mods ?? new List<string>())
{
if (!string.Equals(ParseModId(raw), modId, StringComparison.OrdinalIgnoreCase))
continue;
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_other_role"),
CollectionText.Resolve(effective.GetLocalizedName(TranslationServer.GetLocale())),
CollectionText.Resolve(mechanic.GetLocalizedName(TranslationServer.GetLocale()))));
break;
}
}
}
}
private string LookupModTitle(string modId)
{
foreach (var node in _graph.Nodes)
{
if (string.Equals(node.ModId, modId, StringComparison.OrdinalIgnoreCase))
return node.Title;
}
return modId;
}
private void BindSceneNodes()
{
_close = GetNode<Button>("Margin/Root/Header/Close");
@@ -170,7 +431,7 @@ namespace Mods.ModManager
_addMechanic = GetNode<Button>("Margin/Root/Header/AddMechanic");
_newGame = GetNode<Button>("Margin/Root/Header/NewGame");
_gameSelect = GetNode<OptionButton>("Margin/Root/Header/GameSelect");
_edgesToggle = GetNode<CheckBox>("Margin/Root/Header/EdgesToggle");
_edgesToggle = GetNode<OptionButton>("Margin/Root/Header/EdgesToggle");
_pillProblems = GetNode<Button>("Margin/Root/Header/Problems");
_pillPlan = GetNode<Button>("Margin/Root/Header/Plan");
_modsCount = GetNode<Label>("Margin/Root/Header/ModsCount");
@@ -180,7 +441,7 @@ namespace Mods.ModManager
_save = GetNode<Button>("Margin/Root/Header/Save");
_bannerPanel = GetNode<PanelContainer>("Margin/Root/Banner");
_bannerLabel = GetNode<Label>("Margin/Root/Banner/BannerLabel");
_canvas = GetNode<CollectionSchemeCanvas>("Margin/Root/Body/CanvasScroll/SchemeCanvas");
_canvas = GetNode<CollectionSchemeCanvas>("Margin/Root/Body/CanvasHost/CanvasScroll/SchemeCanvas");
var detail = "Margin/Root/Body/Details/DetailScroll/Detail/";
_detailTitle = GetNode<Label>(detail + "DetailTitle");
@@ -199,10 +460,6 @@ namespace Mods.ModManager
_newGame.Text = TranslationServer.Translate("mod_manager/collection_new_game");
_pillProblems.Text = TranslationServer.Translate("mod_manager/collection_problems");
_pillPlan.Text = TranslationServer.Translate("mod_manager/collection_plan");
_close.TooltipText = _close.Text;
_refresh.TooltipText = _refresh.Text;
_addMechanic.TooltipText = _addMechanic.Text;
_newGame.TooltipText = _newGame.Text;
_gameSelect.TooltipText = TranslationServer.Translate("mod_manager/collection_game_tooltip");
_pillProblems.TooltipText = TranslationServer.Translate("mod_manager/collection_problems_tooltip");
_pillPlan.TooltipText = TranslationServer.Translate("mod_manager/collection_plan_tooltip");
@@ -215,21 +472,34 @@ namespace Mods.ModManager
_tabPlan.Text = TranslationServer.Translate("mod_manager/collection_tab_plan");
_json.TooltipText = TranslationServer.Translate("mod_manager/collection_json_tooltip");
_reset.TooltipText = TranslationServer.Translate("mod_manager/collection_reset_tooltip");
_save.TooltipText = _save.Text;
_tabInspector.TooltipText = _tabInspector.Text;
_tabProblems.TooltipText = _tabProblems.Text;
_tabPlan.TooltipText = _tabPlan.Text;
_apply.Text = TranslationServer.Translate("mod_manager/collection_apply");
_edgesToggle.Text = TranslationServer.Translate("mod_manager/collection_links");
_apply.TooltipText = _apply.Text;
_edgesToggle.TooltipText = _edgesToggle.Text;
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_selected"));
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_all"));
_edgesToggle.AddItem(TranslationServer.Translate("mod_manager/collection_links_none"));
var orderLegend = GetNode<Label>("Margin/Root/OrderLegend");
orderLegend.Text = TranslationServer.Translate("mod_manager/collection_order_legend");
orderLegend.AddThemeColorOverride("font_color", UiTokens.Faint);
orderLegend.SetMeta(ModHelpPanel.HintTranslationMetadata, "collection_dependencies");
ModHelpPanel.Attach(_refresh, "refresh");
ModHelpPanel.Attach(_addMechanic, "collection_add_mechanic");
ModHelpPanel.Attach(_newGame, "collection_new");
ModHelpPanel.Attach(_gameSelect, "collection_game");
ModHelpPanel.Attach(_pillProblems, "collection_problems");
ModHelpPanel.Attach(_pillPlan, "collection_plan");
ModHelpPanel.Attach(_json, "collection_json");
ModHelpPanel.Attach(_reset, "collection_reset");
ModHelpPanel.Attach(_apply, "collection_apply");
ModHelpPanel.Attach(_edgesToggle, "collection_links");
ModHelpPanel.Attach(GetNode<Label>("Margin/Root/OrderLegend"), "load_order");
var header = _json.GetParent();
_savesInfo = new Label { VerticalAlignment = VerticalAlignment.Center };
_savesInfo.AddThemeColorOverride("font_color", UiTokens.Muted);
_duplicate = new Button { Text = TranslationServer.Translate("mod_manager/collection_duplicate"), TooltipText = TranslationServer.Translate("mod_manager/collection_duplicate") };
_revert = new Button { Text = TranslationServer.Translate("mod_manager/collection_revert"), TooltipText = TranslationServer.Translate("mod_manager/collection_revert") };
_delete = new Button { Text = TranslationServer.Translate("mod_manager/collection_delete"), TooltipText = TranslationServer.Translate("mod_manager/collection_delete") };
_duplicate = new Button { Text = TranslationServer.Translate("mod_manager/collection_duplicate") };
_revert = new Button { Text = TranslationServer.Translate("mod_manager/collection_revert") };
_delete = new Button { Text = TranslationServer.Translate("mod_manager/collection_delete") };
ModHelpPanel.Attach(_delete, "collection_delete");
foreach (var control in new Control[] { _savesInfo, _duplicate, _revert, _delete })
{
header.AddChild(control);
@@ -252,6 +522,7 @@ namespace Mods.ModManager
_bannerLabel.AddThemeColorOverride("font_color", UiTokens.Warning);
_bannerPanel.Visible = false;
BindGraphiteControls();
BuildCrumbs();
BuildBuildBar();
if (Embedded)
@@ -271,7 +542,7 @@ namespace Mods.ModManager
_crumbCollections = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"));
_crumbCollections.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_crumbCollections.Pressed += () => GalleryRequested?.Invoke();
_crumbCollections.Pressed += OnGalleryPressed;
_crumbCollections.TooltipText = TranslationServer.Translate("mod_manager/gallery_crumb");
_crumbs.AddChild(_crumbCollections);
_crumbs.AddChild(UiKit.Label("›", UiTokens.FontBodySmall, UiTokens.Faint));
@@ -281,10 +552,19 @@ namespace Mods.ModManager
_crumbs.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_crumb_scheme"), UiTokens.FontBodySmall, UiTokens.Body));
_crumbs.AddChild(UiKit.Spacer());
GetNode("Margin/Root/Header/AddMechanic").Reparent(_crumbs);
GetNode("Margin/Root/Header/EdgesToggle").Reparent(_crumbs);
GetNode("Margin/Root/Header/Problems").Reparent(_crumbs);
GetNode("Margin/Root/Header/ModsCount").Reparent(_crumbs);
var header = GetNode<HBoxContainer>("Margin/Root/Header");
_crumbs.Reparent(header);
header.MoveChild(_crumbs, 0);
_crumbs.SizeFlagsHorizontal = SizeFlags.ExpandFill;
GetNode<Control>("Margin/Root/Header/Spacer").Visible = false;
var tools = GetNode<HBoxContainer>("Margin/Root/Tools");
_addMechanic.Reparent(tools);
tools.MoveChild(_addMechanic, 0);
_edgesToggle.Reparent(tools);
tools.MoveChild(_edgesToggle, 2);
_pillProblems.Reparent(tools);
tools.MoveChild(_pillProblems, 3);
}
/// <summary>
@@ -317,13 +597,13 @@ namespace Mods.ModManager
row.AddChild(_buildHint);
row.AddChild(UiKit.Spacer());
_buildManager = UiKit.Button(TranslationServer.Translate("mod_manager/build_open_manager"));
_buildManager.TooltipText = _buildManager.Text;
_buildManager.Pressed += () => OpenManagerRequested?.Invoke(null);
_buildManager = UiKit.Button(TranslationServer.Translate("mod_manager/collection_tab_problems"));
ModHelpPanel.Attach(_buildManager, "collection_plan");
_buildManager.Pressed += OnBuildManagerPressed;
row.AddChild(_buildManager);
_buildApply = UiKit.Button(TranslationServer.Translate("mod_manager/collection_apply"), ButtonKind.Primary);
_buildApply.TooltipText = _buildApply.Text;
ModHelpPanel.Attach(_buildApply, "collection_apply");
_buildApply.Pressed += OnApplyPressed;
row.AddChild(_buildApply);
}
@@ -345,6 +625,7 @@ namespace Mods.ModManager
_close.Visible = false;
_refresh.Visible = false;
_newGame.Visible = false;
_pillPlan.Visible = false;
}
private void SubscribeEvents()
@@ -353,7 +634,7 @@ namespace Mods.ModManager
_refresh.Pressed += OnRefreshPressed;
_addMechanic.Pressed += OnAddMechanicPressed;
_newGame.Pressed += OnNewGamePressed;
_edgesToggle.Toggled += _canvas.SetEdgesVisible;
_edgesToggle.ItemSelected += _canvas.SetEdgeMode;
_pillProblems.Pressed += () => SelectTab("problems");
_pillPlan.Pressed += () => SelectTab("plan");
_json.Pressed += OpenJsonDialog;
@@ -369,6 +650,7 @@ namespace Mods.ModManager
_canvas.ModSelected += OnModSelected;
_canvas.GroupCommand += OnGroupCommand;
_canvas.LayoutChanged += OnLayoutChanged;
_canvas.ViewChanged += OnCanvasViewChanged;
ModDownloader.OnProgress += OnInstallProgress;
CatalogService.OnRefreshed += OnCatalogRefreshed;
@@ -378,6 +660,8 @@ namespace Mods.ModManager
{
ModDownloader.OnProgress -= OnInstallProgress;
CatalogService.OnRefreshed -= OnCatalogRefreshed;
if (_fitQueued)
GetTree().ProcessFrame -= OnCanvasFirstFrame;
}
/* ═══════════════ Data flow ═══════════════ */
@@ -396,12 +680,37 @@ namespace Mods.ModManager
private void Rebuild()
{
_graph = Resolve();
foreach (var node in _graph.Nodes)
{
if (ModRegistry.Instance.AllDiscoveredMods.TryGetValue(node.ModId, out var info))
node.Title = info.DisplayTitle;
}
_layout = _currentId == null ? new ModCollectionLayout() : CollectionLayoutStore.Load(_currentId);
_plan = ModCollectionPlan.Build(_graph, CatalogService.CachedEntries ?? new List<CatalogExportEntry>());
if (_selectedGroupKey == null && _graph.Groups.Count > 0)
{
var initial = _graph.Groups[0];
foreach (var group in _graph.Groups)
{
if (group.Role == "battle" && group.Selected && group.Mods.Count > 0)
{
initial = group;
break;
}
}
_selectedGroupKey = initial.Key;
_selectedModId = initial.Mods.Count > 0 ? initial.Mods[0].ModId : null;
}
_canvas.Build(_graph, _layout);
_canvas.SetSelection(_selectedGroupKey, _selectedModId);
_canvas.SetEdgesVisible(_edgesToggle.ButtonPressed);
_canvas.SetEdgeMode(_edgesToggle.Selected);
if (_fitAfterBuild && !_fitQueued)
{
_fitQueued = true;
GetTree().ProcessFrame += OnCanvasFirstFrame;
}
UpdateHeader();
UpdateInspector();
@@ -530,7 +839,7 @@ namespace Mods.ModManager
var current = EffectiveCollection(_currentId);
var isGame = current?.Kind == ModCollectionKind.Game;
_savesInfo.Visible = isGame;
_savesInfo.Visible = isGame && !Embedded;
if (isGame)
_savesInfo.Text = string.Format(TranslationServer.Translate("mod_manager/collection_saves"), SaveService.ListSaves(_currentId).Count);
_duplicate.Disabled = current == null;
@@ -559,6 +868,11 @@ namespace Mods.ModManager
_pillPlan.Text = TranslationServer.Translate("mod_manager/collection_plan");
return;
}
if (errors > 0)
{
_pillPlan.Text = $"{TranslationServer.Translate("mod_manager/collection_problems")}: {errors}";
return;
}
var parts = new List<string>();
if (_plan.Install.Count > 0)
@@ -571,7 +885,7 @@ namespace Mods.ModManager
}
private int CountErrors()
=> _graph?.Problems?.Count ?? 0;
=> (_graph?.Problems?.Count ?? 0) + (_plan?.Missing.Count ?? 0) + (_plan?.Mismatches.Count ?? 0);
private void OnGameSelected(long index)
{
@@ -580,6 +894,7 @@ namespace Mods.ModManager
return;
_currentId = id;
_fitAfterBuild = true;
_selectedGroupKey = null;
_selectedModId = null;
Rebuild();
@@ -587,6 +902,7 @@ namespace Mods.ModManager
private void OnNewGamePressed()
{
_fitAfterBuild = true;
var id = UniqueGameId();
var draft = new ModCollection
{
@@ -753,6 +1069,8 @@ namespace Mods.ModManager
_tabPlan.ButtonPressed = _tab == "plan";
ClearChildren(_detailExtra);
_versionEditor.Visible = false;
_versionReference = null;
_apply.Visible = !Embedded && _tab == "plan";
if (_tab == "problems")
@@ -801,6 +1119,7 @@ namespace Mods.ModManager
_detailMeta.Text = string.Join(" · ", metaParts);
_detailDescription.Text = string.IsNullOrEmpty(group.Description) ? "—" : group.Description;
AddInfoLine(TranslationServer.Translate("mod_manager/collection_role_hint"));
var collection = EffectiveCollection(group.CollectionId);
if (collection != null)
@@ -836,18 +1155,23 @@ namespace Mods.ModManager
{
var node = _graph?.Nodes?.FirstOrDefault(n => string.Equals(n.ModId, modId, StringComparison.OrdinalIgnoreCase));
_detailTitle.Text = node?.Title ?? modId;
_detailMeta.Text = $"{modId} · {CollectionStateNames.Translate(node?.State ?? ModCollectionNodeState.Missing)}";
_detailMeta.Text = $"{modId} · {node?.InstalledVersion ?? "—"} · {CollectionStateNames.Translate(node?.State ?? ModCollectionNodeState.Missing)}";
_detailDescription.Text = LookupModDescription(modId);
if (node != null && !string.IsNullOrEmpty(node.RequiredVersion))
AddInfoLine($"{TranslationServer.Translate("mod_manager/collection_requires")}: {node.RequiredVersion}");
if (node != null && !string.IsNullOrEmpty(node.InstalledVersion))
AddInfoLine($"{TranslationServer.Translate("mod_manager/installed_version")}: {node.InstalledVersion}");
var group = _selectedGroupKey == null ? null : FindGroup(_selectedGroupKey);
if (group == null && node != null)
{
foreach (var requirement in node.Requirements)
{
if (!string.IsNullOrEmpty(requirement.Operator))
AddInfoLine(string.Format(TranslationServer.Translate("mod_manager/collection_requires"), requirement.ToString()));
}
}
if (group != null)
{
AddInfoLine($"{TranslationServer.Translate("mod_manager/collection_in_group")}: {group.Name}");
BindVersionRule(group, modId);
AddModRelationships(modId);
var remove = MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_remove_mod"),
() => RemoveModFromGroup(group, modId), true);
@@ -888,13 +1212,16 @@ namespace Mods.ModManager
_detailMeta.Text = string.Empty;
_detailDescription.Text = string.Empty;
if (_graph == null || _graph.Problems.Count == 0)
if (_graph == null || CountErrors() == 0)
{
_detailExtra.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_problems_none"), UiTokens.FontBody, UiTokens.SuccessText));
UpdateApplyButton();
return;
}
AddPlanSection("mod_manager/collection_missing", _plan?.Missing, UiTokens.DangerText, findInCatalog: true);
AddPlanSection("mod_manager/collection_mismatch", _plan?.Mismatches, UiTokens.DangerText, findInCatalog: true);
foreach (var problem in _graph.Problems)
{
var row = UiKit.Column(UiTokens.Space1);
@@ -975,7 +1302,7 @@ namespace Mods.ModManager
AddPlanSection("mod_manager/collection_install_count", _plan.Install.Select(i => i.ModId), UiTokens.Info);
AddPlanSection("mod_manager/collection_enable_count", _plan.Enable, UiTokens.Warning);
AddPlanSection("mod_manager/collection_missing", _plan.Missing, UiTokens.Danger, findInCatalog: true);
AddPlanSection("mod_manager/collection_mismatch", _plan.Mismatches, UiTokens.Warning);
AddPlanSection("mod_manager/collection_mismatch", _plan.Mismatches, UiTokens.Warning, findInCatalog: true);
UpdateApplyButton();
}
@@ -1013,7 +1340,7 @@ namespace Mods.ModManager
var canApply = !_installing
&& _plan != null
&& !_plan.IsEmpty
&& _plan.Missing.Count == 0
&& !_plan.HasProblems
&& CountErrors() == 0;
_apply.Disabled = !canApply;
if (_buildApply != null)
@@ -1048,7 +1375,7 @@ namespace Mods.ModManager
_buildHint.Text = TranslationServer.Translate(blocked
? "mod_manager/build_hint_problems"
: "mod_manager/collection_plan_ready");
: _overrides.Count > 0 ? "mod_manager/collection_unsaved" : "mod_manager/collection_plan_ready");
_buildHint.AddThemeColorOverride("font_color", blocked ? UiTokens.Warning : UiTokens.SuccessText);
}
@@ -1095,7 +1422,6 @@ namespace Mods.ModManager
private Button MakeInspectorButton(string text, Action action, bool danger = false)
{
var button = UiKit.Button(text, danger ? ButtonKind.Danger : ButtonKind.Secondary);
button.TooltipText = text;
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
button.Pressed += action;
return button;
@@ -1606,12 +1932,17 @@ namespace Mods.ModManager
if (collection == null)
return;
var view = new TextEdit
var view = new CodeEdit
{
Text = JsonSerializer.Serialize(collection, JsonOptionsProvider.WriteIndentedOptions),
Editable = true,
GuttersDrawLineNumbers = true,
SyntaxHighlighter = SourceSyntax.CreateJsonHighlighter(),
IndentUseSpaces = true,
IndentSize = 2,
CustomMinimumSize = new Vector2(620, 420),
};
view.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
var dialog = new ConfirmationDialog
{
@@ -1798,6 +2129,8 @@ namespace Mods.ModManager
}
include.Ref = forkId;
if (string.Equals(_selectedGroupKey, group.Key, StringComparison.OrdinalIgnoreCase))
_selectedGroupKey = $"i:{group.ParentCollectionId}:{forkId}";
return Draft(forkId);
}
@@ -1849,7 +2182,7 @@ namespace Mods.ModManager
private async void OnApplyPressed()
{
if (_installing || _plan == null || _plan.IsEmpty
|| _plan.Missing.Count > 0 || CountErrors() > 0)
|| _plan.HasProblems || CountErrors() > 0)
return;
var plan = _plan;
+27 -23
View File
@@ -103,14 +103,14 @@ namespace Mods.ModManager
_tabMods.ToggleMode = true;
_tabMods.ButtonGroup = group;
_tabMods.Pressed += () => ShowTab(TabMods);
_tabMods.TooltipText = _tabMods.Text;
ModHelpPanel.Attach(_tabMods, "mods_tab");
_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);
_tabCollections.TooltipText = _tabCollections.Text;
ModHelpPanel.Attach(_tabCollections, "collections_tab");
_segment.AddChild(_tabCollections);
header.AddChild(UiKit.Spacer());
@@ -122,24 +122,22 @@ namespace Mods.ModManager
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
};
_gallerySearch.TextChanged += OnGallerySearchChanged;
_gallerySearch.TooltipText = _gallerySearch.PlaceholderText;
ModHelpPanel.Attach(_gallerySearch, "collection_search");
header.AddChild(_gallerySearch);
_galleryRefresh = UiKit.Button(TranslationServer.Translate("mod_manager/refresh"));
_galleryRefresh.Pressed += OnGalleryRefreshPressed;
_galleryRefresh.TooltipText = _galleryRefresh.Text;
ModHelpPanel.Attach(_galleryRefresh, "refresh");
header.AddChild(_galleryRefresh);
_helpButton = UiKit.IconButton("?", TranslationServer.Translate("mod_manager/help_open"));
_helpButton.Name = "HelpButton";
_helpButton.ToggleMode = true;
_helpButton.Pressed += OnHelpPressed;
header.AddChild(_helpButton);
}
/// <summary>
/// Builds the full-screen help overlay once; it stays hidden until the
/// header's "?" button opens the page of the level the player is on.
/// </summary>
/// <summary>Builds the non-modal inspection layer above every hub level.</summary>
private void BuildHelp()
{
_help = TscnScenes.LoadAndBuild(HelpScene) as ModHelpPanel;
@@ -157,19 +155,14 @@ namespace Mods.ModManager
private void OnHelpPressed()
{
_help?.Open(CurrentHelpLevel());
}
if (_help == null)
return;
/// <summary>The help page follows the hub level: the mods grid, an open mod page, the collections gallery or the scheme editor.</summary>
private string CurrentHelpLevel()
{
if (_editorOpen)
return "scheme";
if (_activeTab == TabCollections)
return "collections";
if (ModOpen)
return "mod_page";
return "mods";
if (_help.Visible)
_help.Close();
else
_help.Open();
_helpButton.ButtonPressed = _help.Visible;
}
private void BuildBanner()
@@ -265,7 +258,6 @@ namespace Mods.ModManager
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;
@@ -274,7 +266,6 @@ namespace Mods.ModManager
}
_back.Text = TranslationServer.Translate(ModOpen ? "mod_manager/hub_back_to_mods" : "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;
@@ -406,15 +397,28 @@ namespace Mods.ModManager
/* ═══════════════ Escape chain ═══════════════ */
/// <summary>Escape walks back one level: editor → gallery → mods tab, mod card → mods grid.</summary>
/// <summary>F1 explains the focused control; Escape leaves help before walking back a level.</summary>
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventKey key && key.Pressed && !key.Echo && key.Keycode == Key.F1)
{
if (_help != null)
{
_help.Open();
_help.ShowFor(GetViewport().GuiGetFocusOwner());
_helpButton.ButtonPressed = true;
}
GetViewport().SetInputAsHandled();
return;
}
if (!@event.IsActionPressed("ui_cancel"))
return;
if (_help != null && _help.Visible)
{
_help.Close();
_helpButton.ButtonPressed = false;
GetViewport().SetInputAsHandled();
return;
}
+3 -3
View File
@@ -117,7 +117,6 @@ namespace Mods.ModManager
_bannerLabel = GetNode<Label>("Margin/Root/Banner/BannerLabel");
close.Text = TranslationServer.Translate("mod_manager/back");
close.TooltipText = close.Text;
title.Text = TranslationServer.Translate("mod_manager/title");
if (Embedded)
{
@@ -127,9 +126,7 @@ namespace Mods.ModManager
title.Visible = false;
}
_search.PlaceholderText = TranslationServer.Translate("mod_manager/search_placeholder");
_search.TooltipText = _search.PlaceholderText;
_refreshButton.Text = TranslationServer.Translate("mod_manager/refresh");
_refreshButton.TooltipText = _refreshButton.Text;
_resultLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_name"));
@@ -137,6 +134,9 @@ namespace Mods.ModManager
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_status"));
_sort.Selected = (int)_filter.Sort;
_sort.TooltipText = TranslationServer.Translate("mod_manager/sort_tooltip");
ModHelpPanel.Attach(_search, "mod_search");
ModHelpPanel.Attach(_sort, "mod_sort");
ModHelpPanel.Attach(_refreshButton, "refresh");
_bannerPanel.AddThemeStyleboxOverride("panel", ModManagerTheme.MakeBox(
UiTokens.Alpha(UiTokens.Warning, 0.15f),
+6 -4
View File
@@ -9,10 +9,9 @@ namespace Mods.ModManager
/// 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.
/// A dependency may cross group boundaries normally: groups describe its
/// purpose in one game, not compatibility. Only <see cref="Problem"/> carries
/// an actual availability/version failure and receives warning styling.
/// </summary>
public class CollectionEdge
{
@@ -25,6 +24,9 @@ namespace Mods.ModManager
/// <summary>True when the two mods belong to different groups.</summary>
public bool Cross { get; set; }
/// <summary>At least one endpoint is missing or has an incompatible installed version.</summary>
public bool Problem { get; set; }
/// <summary>True when the selection touches one of the endpoints.</summary>
public bool Active { get; set; }
}
+23 -6
View File
@@ -60,7 +60,7 @@ namespace Mods.ModManager
if (_canvas == null || !GodotObject.IsInstanceValid(_canvas))
return;
var origin = _canvas.GetGlobalRect().Position;
var inverse = _canvas.GetGlobalTransform().AffineInverse();
foreach (var edge in _edges)
{
@@ -69,13 +69,16 @@ namespace Mods.ModManager
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 fromRect = from.GetGlobalRect();
var toRect = to.GetGlobalRect();
var a = inverse * BorderPoint(fromRect, toRect.GetCenter());
var b = inverse * BorderPoint(toRect, fromRect.GetCenter());
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);
var color = edge.Problem ? UiTokens.DangerText
: edge.Active ? UiTokens.Accent : new Color(UiTokens.LineStrong, 0.45f);
var width = edge.Active || edge.Problem ? 2f : 1.2f;
if (edge.Cross)
if (edge.Problem)
{
for (var i = 0; i < points.Count - 1; i++)
DrawDashedLine(points[i], points[i + 1], color, width, 5f);
@@ -85,9 +88,23 @@ namespace Mods.ModManager
{
DrawPolyline(points.ToArray(), color, width, true);
}
DrawCircle(a, 2.5f, color);
var direction = (points[^1] - points[^2]).Normalized();
var normal = new Vector2(-direction.Y, direction.X);
DrawLine(b, b - direction * 8f + normal * 3.5f, color, width, true);
DrawLine(b, b - direction * 8f - normal * 3.5f, color, width, true);
}
}
private static Vector2 BorderPoint(Rect2 rect, Vector2 target)
{
var center = rect.GetCenter();
var delta = target - center;
if (Mathf.Abs(delta.X) >= Mathf.Abs(delta.Y))
return new Vector2(delta.X >= 0 ? rect.End.X : rect.Position.X, center.Y);
return new Vector2(center.X, delta.Y >= 0 ? rect.End.Y : rect.Position.Y);
}
private static List<Vector2> BuildCurve(Vector2 a, Vector2 b)
{
var dx = b.X - a.X;
+2 -2
View File
@@ -158,7 +158,7 @@ namespace Mods.ModManager
MouseFilter = MouseFilterEnum.Ignore;
_baseBorder = UiTokens.LineCard;
_style = UiKit.Frame(12, new Color(UiTokens.Surface1, 0.92f), _baseBorder);
_style = UiKit.Frame(UiTokens.RadiusLg, new Color(UiTokens.Surface1, 0.92f), _baseBorder);
_panel = new PanelContainer { MouseFilter = MouseFilterEnum.Stop };
_panel.AddThemeStyleboxOverride("panel", _style);
_panel.GuiInput += OnPanelGuiInput;
@@ -217,7 +217,7 @@ namespace Mods.ModManager
_desc = UiKit.Label(string.Empty, UiTokens.FontSmall, UiTokens.Muted);
_desc.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_desc.MaxLinesVisible = 3;
_desc.MaxLinesVisible = 1;
_content.AddChild(_desc);
_watermark = UiKit.Label(string.Empty, 42, new Color(1f, 1f, 1f, 0.04f));
+67
View File
@@ -0,0 +1,67 @@
namespace Mods.ModManager
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Scene-positioned overview of the occupied collection canvas. Reads the
/// existing logical frames; clicking centers the scroll view without changing
/// composition or persisted layout. Drawing uses the signal-based mod path.
/// </summary>
public partial class CollectionMiniMap : ModWidget
{
private CollectionSchemeCanvas _canvas;
private StyleBoxFlat _style;
/// <summary>Attaches the live canvas; this control owns no copy of its layout.</summary>
public void Bind(CollectionSchemeCanvas canvas)
{
EnsureConstructed();
_canvas = canvas;
QueueRedraw();
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
_style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface2, UiTokens.LineStrong);
GuiInput += OnMapInput;
Connect("draw", Callable.From((Action)DrawMap));
}
private void DrawMap()
{
DrawStyleBox(_style, new Rect2(Vector2.Zero, Size));
if (_canvas == null || !GodotObject.IsInstanceValid(_canvas))
return;
var scale = (Size - Vector2.One * 8f) / _canvas.ContentBounds;
foreach (var pair in _canvas.GroupFrames)
{
var frame = pair.Value;
if (!frame.X.HasValue || !frame.Y.HasValue)
continue;
var rect = new Rect2(new Vector2(frame.X.Value, frame.Y.Value) * scale + Vector2.One * 4f,
new Vector2(frame.W, frame.H) * scale);
DrawRect(rect, UiTokens.Surface3);
DrawRect(rect, pair.Key == _canvas.SelectedGroupKey ? UiTokens.Accent : UiTokens.LineStrong, false);
}
var visible = _canvas.VisibleBounds;
DrawRect(new Rect2(visible.Position * scale + Vector2.One * 4f, visible.Size * scale), UiTokens.Accent, false);
}
private void OnMapInput(InputEvent @event)
{
if (_canvas == null || @event is not InputEventMouseButton button
|| !button.Pressed || button.ButtonIndex != MouseButton.Left)
return;
var scale = (Size - Vector2.One * 8f) / _canvas.ContentBounds;
_canvas.CenterOn((button.Position - Vector2.One * 4f) / scale);
AcceptEvent();
}
}
}
+17 -8
View File
@@ -32,11 +32,14 @@ namespace Mods.ModManager
/// <summary>Canonical mod id this chip represents.</summary>
public string ModId { get; private set; }
/// <summary>The displayed occurrence's group; a mod may have another chip in another group.</summary>
public string GroupKey { get; private set; }
/// <summary>Raised on left click; the canvas turns it into a selection.</summary>
public event Action<string> Selected;
public event Action<CollectionModChip> Selected;
/// <summary>Raised while dragging with the desired position inside the group's mods layer.</summary>
public event Action<string, Vector2> Moved;
public event Action<CollectionModChip, Vector2> Moved;
/// <summary>Raised when a drag ends, after the last <see cref="Moved"/>.</summary>
public event Action DragFinished;
@@ -57,17 +60,20 @@ namespace Mods.ModManager
/// 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)
public void Init(string groupKey, string modId, string role, string title, string version, string requirement, string stateLabel, Color stateColor, string tooltip)
{
EnsureConstructed();
ModId = modId;
GroupKey = groupKey;
_stateColor = stateColor;
_role.Text = string.IsNullOrEmpty(role) ? string.Empty : role.ToUpperInvariant();
_name.Text = title;
_meta.Text = string.IsNullOrEmpty(version) ? stateLabel : $"{version} · {stateLabel}";
_meta.Text = string.IsNullOrEmpty(version) ? stateLabel
: version + (string.IsNullOrEmpty(requirement) ? string.Empty : " · " + requirement);
_dot.Color = stateColor;
_style.BorderColor = stateColor;
_style.BorderColor = stateColor == UiTokens.Danger || stateColor == UiTokens.DangerText || stateColor == UiTokens.Warning
? stateColor : UiTokens.LineCard;
_panel.TooltipText = tooltip ?? string.Empty;
}
@@ -75,7 +81,10 @@ namespace Mods.ModManager
public void SetSelected(bool selected)
{
EnsureConstructed();
_style.BorderColor = selected ? UiTokens.Accent : _stateColor;
_style.BorderColor = selected ? UiTokens.Accent : UiTokens.LineCard;
_style.BgColor = selected ? UiTokens.SurfaceSelected : UiTokens.Surface1;
if (!selected && (_stateColor == UiTokens.Danger || _stateColor == UiTokens.DangerText || _stateColor == UiTokens.Warning))
_style.BorderColor = _stateColor;
}
protected override void Construct()
@@ -136,7 +145,7 @@ namespace Mods.ModManager
{
_dragging = true;
_moved = false;
Selected?.Invoke(ModId);
Selected?.Invoke(this);
}
else
{
@@ -168,7 +177,7 @@ namespace Mods.ModManager
_moved = true;
Position = clamped;
Moved?.Invoke(ModId, clamped);
Moved?.Invoke(this, clamped);
AcceptEvent();
}
}
+235 -21
View File
@@ -59,6 +59,9 @@ namespace Mods.ModManager
/// <summary>Raised after any drag or resize ends, so the layout can be flushed to the store.</summary>
public event Action LayoutChanged;
/// <summary>Scale or scroll changed; the toolbar and overview may refresh their presentation.</summary>
public event Action ViewChanged;
/// <summary>Whether dependency lines are painted; toggled from the header.</summary>
public bool ShowEdges { get; set; } = true;
@@ -74,6 +77,116 @@ namespace Mods.ModManager
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 readonly Dictionary<string, CollectionModChip> _instances = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ModCollectionLayoutRect> _visibleFrames = new(StringComparer.OrdinalIgnoreCase);
private Control _content;
private CollectionMiniMap _miniMap;
private long _edgeMode;
private float _zoom = 1f;
private string _search = string.Empty;
/// <summary>Scale of the canvas contents, independent of persisted logical coordinates.</summary>
public float Zoom => _zoom;
/// <summary>Occupied logical bounds, excluding the empty scrollable workspace floor.</summary>
public Vector2 ContentBounds { get; private set; } = new(460f, 240f);
/// <summary>Current visible rectangle in logical coordinates, used by the overview map.</summary>
public Rect2 VisibleBounds
{
get
{
var scroll = GetParent<ScrollContainer>();
return scroll == null ? new Rect2() : new Rect2(
new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical) / _zoom,
scroll.Size / _zoom);
}
}
/// <summary>Overview rectangles use the same logical geometry as the group frames.</summary>
public IReadOnlyDictionary<string, ModCollectionLayoutRect> GroupFrames => _visibleFrames;
/// <summary>Connects the scene-defined overview control after both controls entered the tree.</summary>
public void SetMiniMap(CollectionMiniMap miniMap)
{
_miniMap = miniMap;
_miniMap.Bind(this);
}
/// <summary>0: selected mod only, 1: all dependencies, 2: hidden; group boundaries never change severity.</summary>
public void SetEdgeMode(long mode)
{
_edgeMode = mode;
SetEdgesVisible(mode != 2);
RebuildEdges();
}
/// <summary>Filters presentation only; hidden search matches do not leave the game's composition.</summary>
public void SetSearch(string query)
{
_search = query?.Trim() ?? string.Empty;
foreach (var chip in _instances.Values)
{
var node = FindNode(chip.ModId);
var match = _search.Length == 0 || chip.ModId.Contains(_search, StringComparison.OrdinalIgnoreCase)
|| (node?.Title?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false);
chip.Modulate = new Color(1, 1, 1, match ? 1f : 0.2f);
}
}
/// <summary>Increases scale without rewriting any stored group or chip positions.</summary>
public void ZoomIn() => SetZoom(_zoom * 1.15f);
/// <summary>Decreases scale without rewriting any stored group or chip positions.</summary>
public void ZoomOut() => SetZoom(_zoom / 1.15f);
/// <summary>Fits occupied group bounds horizontally; the empty scroll floor does not shrink the scheme.</summary>
public void FitWidth()
{
var scroll = GetParent<ScrollContainer>();
if (scroll == null || scroll.Size.X <= 0f)
return;
SetZoom(Mathf.Min(1f, (scroll.Size.X - 24f) / Mathf.Max(1f, ContentBounds.X)));
scroll.ScrollHorizontal = 0;
scroll.ScrollVertical = 0;
RefreshViewport();
}
/// <summary>Re-packs the current groups; composition and individual chip locations stay unchanged.</summary>
public void ArrangeGroups()
{
var defaults = ComputeDefaultLayout();
foreach (var pair in defaults)
{
var rect = Rect(pair.Key);
rect.X = pair.Value.X;
rect.Y = pair.Value.Y;
_cards[pair.Key].SetFrame(new Rect2(pair.Value, new Vector2(rect.W, rect.H)));
}
UpdateCanvasSize();
RebuildEdges();
LayoutChanged?.Invoke();
}
/// <summary>Centers the scroll view on a logical canvas position (selection or overview click).</summary>
public void CenterOn(Vector2 position)
{
var scroll = GetParent<ScrollContainer>();
if (scroll == null)
return;
scroll.ScrollHorizontal = Mathf.RoundToInt(position.X * _zoom - scroll.Size.X / 2f);
scroll.ScrollVertical = Mathf.RoundToInt(position.Y * _zoom - scroll.Size.Y / 2f);
RefreshViewport();
}
/// <summary>Reveals the selected occurrence, keeping another group's copy from stealing focus.</summary>
public void ShowSelection()
{
if (SelectedModId != null && _instances.TryGetValue(SelectedGroupKey + "|" + SelectedModId, out var chip))
CenterOn(chip.Position + _cards[chip.GroupKey].Position + chip.Size / 2f);
else if (SelectedGroupKey != null && _cards.TryGetValue(SelectedGroupKey, out var card))
CenterOn(card.Position + card.Size / 2f);
}
private bool _panning;
private Vector2 _panStartMouse;
@@ -94,7 +207,7 @@ namespace Mods.ModManager
_layout.Groups ??= new Dictionary<string, ModCollectionLayoutRect>();
_layout.Mods ??= new Dictionary<string, ModCollectionLayoutPoint>();
foreach (var child in GetChildren())
foreach (var child in _content.GetChildren())
{
if (child != _edges)
child.QueueFree();
@@ -103,6 +216,8 @@ namespace Mods.ModManager
_cards.Clear();
_chips.Clear();
_chipGroups.Clear();
_instances.Clear();
_visibleFrames.Clear();
if (graph != null && graph.Groups.Count > 0)
{
@@ -110,11 +225,12 @@ namespace Mods.ModManager
BuildCards();
}
_edges.SetEdges(null, _chips, this);
MoveChild(_edges, GetChildCount() - 1);
_edges.SetEdges(null, _chips, _content);
_content.MoveChild(_edges, _content.GetChildCount() - 1);
_edges.Visible = ShowEdges;
UpdateCanvasSize();
RebuildEdges();
SetSearch(_search);
}
/// <summary>Highlights one group and/or one mod; pass nulls to clear.</summary>
@@ -127,8 +243,20 @@ namespace Mods.ModManager
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));
foreach (var pair in _chipGroups)
{
if (_instances.TryGetValue(pair.Value + "|" + pair.Key, out var primary))
_chips[pair.Key] = primary;
}
foreach (var chip in _instances.Values)
{
var selected = string.Equals(chip.GroupKey, groupKey, StringComparison.OrdinalIgnoreCase)
&& string.Equals(chip.ModId, modId, StringComparison.OrdinalIgnoreCase);
chip.SetSelected(selected);
if (selected)
_chips[chip.ModId] = chip;
}
RebuildEdges();
}
@@ -145,15 +273,42 @@ namespace Mods.ModManager
/// <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;
{
if (modId != null && string.Equals(modId, SelectedModId, StringComparison.OrdinalIgnoreCase)
&& SelectedGroupKey != null)
return SelectedGroupKey;
return modId != null && _chipGroups.TryGetValue(modId, out var key) ? key : null;
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
GuiInput += OnCanvasGuiInput;
_content = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(_content);
_content.Connect("draw", Callable.From((Action)DrawGrid));
_edges = new CollectionEdgeLayer();
AddChild(_edges);
_content.AddChild(_edges);
_edges.EnsureConstructed();
var scroll = GetParent<ScrollContainer>();
if (scroll != null)
{
scroll.GetHScrollBar().ValueChanged += OnScrollChanged;
scroll.GetVScrollBar().ValueChanged += OnScrollChanged;
scroll.Resized += RefreshViewport;
}
}
protected override void Destruct()
{
GuiInput -= OnCanvasGuiInput;
var scroll = GetParent<ScrollContainer>();
if (scroll != null && GodotObject.IsInstanceValid(scroll))
{
scroll.GetHScrollBar().ValueChanged -= OnScrollChanged;
scroll.GetVScrollBar().ValueChanged -= OnScrollChanged;
scroll.Resized -= RefreshViewport;
}
}
private void BuildCards()
@@ -162,7 +317,7 @@ namespace Mods.ModManager
{
var rect = _layout.Groups[group.Key];
var card = new CollectionGroupCard();
AddChild(card);
_content.AddChild(card);
var summary = Summarize(group);
card.Init(group, summary.Label, summary.Color, string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase));
@@ -174,6 +329,7 @@ namespace Mods.ModManager
card.Resized += OnCardResized;
card.DragFinished += OnDragFinished;
_cards[group.Key] = card;
_visibleFrames[group.Key] = rect;
foreach (var mod in group.Mods)
{
@@ -183,11 +339,14 @@ namespace Mods.ModManager
card.ModsLayer.AddChild(chip);
var node = FindNode(mod.ModId);
var requirement = ModDependency.Parse(mod.Requirement);
chip.Init(
group.Key,
mod.ModId,
mod.Role,
node?.Title ?? mod.ModId,
VersionOf(node),
requirement.IsValid && requirement.VersionRaw != null ? requirement.Operator + requirement.VersionRaw : null,
StateLabel(node),
StateColor(node),
BuildModTooltip(mod, node));
@@ -195,6 +354,7 @@ namespace Mods.ModManager
chip.Selected += OnChipSelected;
chip.Moved += OnChipMoved;
chip.DragFinished += OnDragFinished;
_instances[key] = chip;
if (!_chips.ContainsKey(mod.ModId))
{
@@ -396,11 +556,11 @@ namespace Mods.ModManager
GroupSelected?.Invoke(groupKey);
}
private void OnChipSelected(string modId)
private void OnChipSelected(CollectionModChip chip)
{
SelectedModId = modId;
SelectedGroupKey = null;
ModSelected?.Invoke(modId);
SelectedModId = chip.ModId;
SelectedGroupKey = chip.GroupKey;
ModSelected?.Invoke(chip.ModId);
}
private void OnCardCommand(string groupKey, string command)
@@ -427,12 +587,9 @@ namespace Mods.ModManager
RebuildEdges();
}
private void OnChipMoved(string modId, Vector2 position)
private void OnChipMoved(CollectionModChip chip, Vector2 position)
{
if (!_chipGroups.TryGetValue(modId, out var groupKey))
return;
var key = groupKey + "|" + modId;
var key = chip.GroupKey + "|" + chip.ModId;
if (!_layout.Mods.TryGetValue(key, out var point) || point == null)
{
point = new ModCollectionLayoutPoint();
@@ -472,11 +629,15 @@ namespace Mods.ModManager
var size = new Vector2(
Mathf.Max(maxX + GapX + 160f, MinCanvasWidth),
Mathf.Max(maxY + GapY + 120f, MinCanvasHeight));
ContentBounds = new Vector2(Mathf.Max(460f, maxX + GapX), Mathf.Max(240f, maxY + GapY));
// 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;
_content.Size = size;
_content.Scale = Vector2.One * _zoom;
CustomMinimumSize = size * _zoom;
RefreshViewport();
}
private void RebuildEdges()
@@ -491,18 +652,60 @@ namespace Mods.ModManager
var fromGroup = GetGroupKeyOfMod(edge.FromModId);
var toGroup = GetGroupKeyOfMod(edge.ToModId);
var active = string.Equals(edge.FromModId, SelectedModId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(edge.ToModId, SelectedModId, StringComparison.OrdinalIgnoreCase);
if (_edgeMode == 0 && !active)
continue;
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),
Active = active,
Problem = HasProblem(edge.FromModId) || HasProblem(edge.ToModId),
});
}
}
_edges.SetEdges(edges, _chips, this);
_edges.SetEdges(edges, _chips, _content);
RefreshViewport();
}
private bool HasProblem(string modId)
{
var node = FindNode(modId);
return node == null || node.State == ModCollectionNodeState.Missing
|| node.State == ModCollectionNodeState.VersionMismatch;
}
private void SetZoom(float value)
{
var scroll = GetParent<ScrollContainer>();
var center = scroll == null ? Vector2.Zero : VisibleBounds.GetCenter();
_zoom = Mathf.Clamp(value, 0.3f, 1.5f);
UpdateCanvasSize();
CenterOn(center);
}
private void OnScrollChanged(double value) => RefreshViewport();
private void RefreshViewport()
{
_content?.QueueRedraw();
_miniMap?.QueueRedraw();
ViewChanged?.Invoke();
}
private void DrawGrid()
{
var visible = VisibleBounds;
var start = visible.Position.Snapped(new Vector2(20f, 20f));
var color = new Color(UiTokens.LineCard, 0.55f);
for (var x = start.X; x <= visible.End.X + 20f; x += 20f)
{
for (var y = start.Y; y <= visible.End.Y + 20f; y += 20f)
_content.DrawCircle(new Vector2(x, y), 0.8f, color);
}
}
/// <summary>
@@ -516,6 +719,17 @@ namespace Mods.ModManager
/// </summary>
private void OnCanvasGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton wheel && wheel.Pressed && wheel.CtrlPressed)
{
if (wheel.ButtonIndex == MouseButton.WheelUp)
ZoomIn();
else if (wheel.ButtonIndex == MouseButton.WheelDown)
ZoomOut();
else
return;
AcceptEvent();
return;
}
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
{
if (button.Pressed)
+4 -4
View File
@@ -151,7 +151,7 @@ namespace Mods.ModManager
row.AddChild(UiKit.Spacer());
var newGame = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_new_game"), ButtonKind.Primary);
newGame.TooltipText = newGame.Text;
ModHelpPanel.Attach(newGame, "collection_new");
newGame.Pressed += () => NewGameRequested?.Invoke();
row.AddChild(newGame);
_host.AddChild(row);
@@ -402,7 +402,7 @@ namespace Mods.ModManager
button.Disabled = true;
}
button.TooltipText = button.Text;
ModHelpPanel.Attach(button, "collection_apply");
if (action != null)
button.Pressed += action;
return button;
@@ -411,7 +411,7 @@ namespace Mods.ModManager
private Button BuildEditButton(ModCollectionGameSummary summary)
{
var button = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_edit_scheme"));
button.TooltipText = button.Text;
ModHelpPanel.Attach(button, "collection_edit");
button.Disabled = _installingId != null;
button.Pressed += () => EditSchemeRequested?.Invoke(summary.Collection.Id);
return button;
@@ -420,7 +420,7 @@ namespace Mods.ModManager
private Button BuildDeleteButton(ModCollectionGameSummary summary)
{
var button = UiKit.Button(TranslationServer.Translate("mod_manager/collection_delete"), ButtonKind.Danger);
button.TooltipText = button.Text;
ModHelpPanel.Attach(button, "collection_delete");
button.Disabled = _installingId != null;
button.Pressed += () => CollectionDeletion.Confirm(this, summary.Collection, () =>
{
+13 -9
View File
@@ -76,12 +76,18 @@ namespace Mods.ModManager
_source.MouseFilter = Control.MouseFilterEnum.Pass;
AddThemeStyleboxOverride("panel", ModManagerTheme.CreateCardStyle(data.IsConflict));
ModHelpPanel.Attach(this, "mod_card");
ModHelpPanel.Attach(_versions, "mod_version");
ModHelpPanel.Attach(_action, "mod_action");
ModHelpPanel.Attach(_enabled, "mod_enabled");
_versions.ItemSelected += OnVersionSelected;
_action.Pressed += OnActionPressed;
_enabled.Toggled += OnEnabledPressed;
GuiInput += OnGuiInput;
BuildCollectionsLink();
ModHelpPanel.Attach(_collectionsLink, "mod_collections");
PopulateVersions();
RefreshState();
LoadPreview();
@@ -138,7 +144,7 @@ namespace Mods.ModManager
{
_versions.AddItem("v" + (_data.LocalVersion ?? "—"));
_versions.Disabled = true;
_versions.TooltipText = _versions.GetItemText(0);
_versions.TooltipText = TranslationServer.Translate("mod_manager/hint_mod_version_body");
return;
}
@@ -164,9 +170,9 @@ namespace Mods.ModManager
}
_versions.Selected = selectedIndex;
// "v1.2.0 · latest · installed" does not fit the narrow combo: it ends in an
// ellipsis (clip_text in the scene), the full label is in the tooltip.
_versions.TooltipText = _versions.GetItemText(selectedIndex);
// The combo clips its long version label, so its tooltip explains
// the version markers instead of repeating the truncated caption.
_versions.TooltipText = TranslationServer.Translate("mod_manager/hint_mod_version_body");
}
/// <summary>
@@ -200,7 +206,6 @@ namespace Mods.ModManager
var text = ModCollectionLinks.BadgeText(_data.CollectionsCount);
_collectionsLink.Visible = text.Length > 0;
_collectionsLink.Text = text;
_collectionsLink.TooltipText = text;
}
private void UpdateActionButton()
@@ -214,7 +219,6 @@ namespace Mods.ModManager
_action.Visible = true;
_action.Text = ModActionNames.Translate(kind.Value);
_action.TooltipText = _action.Text;
if (ModActionNames.IsDanger(kind.Value))
CthangoverTheme.ApplyDanger(_action);
@@ -232,8 +236,8 @@ namespace Mods.ModManager
_enabled.ButtonPressed = !_data.IsDisabled;
_updatingEnabled = false;
_enabled.TooltipText = TranslationServer.Translate(_data.IsDisabled
? "mod_manager/action_enable"
: "mod_manager/action_disable");
? "mod_manager/enable_tooltip"
: "mod_manager/disable_tooltip");
}
private void LoadPreview()
@@ -291,7 +295,7 @@ namespace Mods.ModManager
return;
_data.SelectedDownload = _data.Downloads[(int)index];
_versions.TooltipText = _versions.GetItemText((int)index);
_versions.TooltipText = TranslationServer.Translate("mod_manager/hint_mod_version_body");
_data.RecomputeStatus();
RefreshState();
VersionChanged?.Invoke(_data);
+9 -7
View File
@@ -86,6 +86,8 @@ namespace Mods.ModManager
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
ModHelpPanel.Attach(_versions, "mod_version");
_versions.ItemSelected += OnVersionSelected;
_author.AddThemeColorOverride("font_color", UiTokens.Muted);
@@ -177,7 +179,7 @@ namespace Mods.ModManager
}
_versions.Selected = selectedIndex;
_versions.TooltipText = _versions.GetItemText(selectedIndex);
_versions.TooltipText = TranslationServer.Translate("mod_manager/hint_mod_version_body");
}
private void BuildActions()
@@ -191,19 +193,21 @@ namespace Mods.ModManager
var primary = ModActions.GetPrimary(_data);
if (primary != null)
{
var button = new Button { Text = ModActionNames.Translate(primary.Value), TooltipText = ModActionNames.Translate(primary.Value) };
var button = new Button { Text = ModActionNames.Translate(primary.Value) };
if (ModActionNames.IsDanger(primary.Value))
CthangoverTheme.ApplyDanger(button);
else
CthangoverTheme.ApplyPrimary(button);
ModHelpPanel.Attach(button, "mod_action");
button.Pressed += OnPrimaryPressed;
_actionsRow.AddChild(button);
}
if (primary != ModActionKind.Delete && _data.IsDeletable)
{
var delete = new Button { Text = ModActionNames.Translate(ModActionKind.Delete), TooltipText = ModActionNames.Translate(ModActionKind.Delete) };
var delete = new Button { Text = ModActionNames.Translate(ModActionKind.Delete) };
CthangoverTheme.ApplyDanger(delete);
ModHelpPanel.Attach(delete, "mod_delete");
delete.Pressed += OnDeletePressed;
_actionsRow.AddChild(delete);
}
@@ -215,14 +219,12 @@ namespace Mods.ModManager
Text = ModActionNames.Translate(_data.IsDisabled
? ModActionKind.Enable
: ModActionKind.Disable),
TooltipText = ModActionNames.Translate(_data.IsDisabled
? ModActionKind.Enable
: ModActionKind.Disable),
};
if (_data.IsDisabled)
CthangoverTheme.ApplyPrimary(toggle);
else
CthangoverTheme.ApplyDanger(toggle);
ModHelpPanel.Attach(toggle, "mod_enabled");
toggle.Pressed += OnTogglePressed;
_actionsRow.AddChild(toggle);
}
@@ -245,12 +247,12 @@ namespace Mods.ModManager
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);
ModHelpPanel.Attach(link, "mod_collections");
link.Pressed += () => CollectionsLinkRequested?.Invoke();
_actionsRow.AddChild(link);
}
+2 -3
View File
@@ -56,8 +56,9 @@ namespace Mods.ModManager
_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.TooltipText = _groupToggle.Text;
_groupToggle.ButtonPressed = _state.GroupByCategory;
ModHelpPanel.Attach(_reset, "filters_reset");
ModHelpPanel.Attach(_groupToggle, "group_categories");
_statusCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
_categoryCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
@@ -80,14 +81,12 @@ namespace Mods.ModManager
{
var count = statusCounts.GetValueOrDefault(pair.Key);
pair.Value.Text = $"{ModStatusNames.Translate(pair.Key)} ({count})";
pair.Value.TooltipText = pair.Value.Text;
}
foreach (var pair in _categoryBoxes)
{
var count = categoryCounts.GetValueOrDefault(pair.Key);
pair.Value.Text = $"{ModCategoryNames.Translate(pair.Key)} ({count})";
pair.Value.TooltipText = pair.Value.Text;
}
}
+200 -24
View File
@@ -1,54 +1,230 @@
namespace Mods.ModManager
{
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Full-screen help overlay of the mods hub: one page per hub level — the mods
/// grid, a mod's page, the games gallery and the scheme editor. The layout is the
/// <c>mod_help_panel.tscn</c> prefab (dim backdrop, centred frame, title, body,
/// close button); this class only binds the nodes and fills the texts, so a new
/// page is a locale pair <c>mod_manager/help_&lt;level&gt;_title/_body</c> — no code.
/// Opened by the hub header's help button, closed by the button, a click on the
/// dim or Escape (the hub closes the panel first in its own Escape chain).
/// Non-modal, contextual help for the mods hub. Controls carry a locale suffix in
/// <see cref="HintKeyMetadata"/>; the layer finds the annotated ancestor under the
/// pointer or keyboard focus, outlines it and places a compact card next to it.
/// The root ignores mouse input, so the inspected control still performs its normal
/// action. A click pins the current card without swallowing that click; Escape or
/// the header button leaves help mode through <see cref="ModHubScreen"/>.
/// </summary>
public partial class ModHelpPanel : Cthangover.Mods.Interface.Base.Lists.ModWidget
{
public const string HintKeyMetadata = "mod_manager_hint";
/// <summary>Optional locale-pair suffix for a contextual explanation without changing the target's stable help id.</summary>
public const string HintTranslationMetadata = "mod_manager_hint_translation";
private const float Gap = 10f;
private const float ScreenMargin = 12f;
private const float FooterClearance = 46f;
private Panel _highlight;
private PanelContainer _card;
private Label _title;
private RichTextLabel _body;
private Label _footer;
/// <summary>The annotated control currently explained by the card.</summary>
public Control CurrentTarget { get; private set; }
/// <summary>Whether the card stays on its target after the pointer moves away.</summary>
public bool IsPinned { get; private set; }
/// <summary>Associates a control with a mod_manager/hint_* locale pair.</summary>
public static void Attach(Control control, string hintKey)
{
control?.SetMeta(HintKeyMetadata, hintKey);
}
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Stop;
MouseFilter = MouseFilterEnum.Ignore;
_title = GetNode<Label>("Center/Frame/Margin/Column/Header/Title");
_body = GetNode<RichTextLabel>("Center/Frame/Margin/Column/Body");
_highlight = GetNode<Panel>("Highlight");
_card = GetNode<PanelContainer>("Card");
_title = GetNode<Label>("Card/Margin/Column/Title");
_body = GetNode<RichTextLabel>("Card/Margin/Column/Body");
_footer = GetNode<Label>("Footer/Margin/FooterText");
var close = GetNode<Button>("Center/Frame/Margin/Column/Header/Close");
close.Text = TranslationServer.Translate("mod_manager/help_close");
close.TooltipText = TranslationServer.Translate("mod_manager/help_close");
close.Pressed += Close;
_highlight.AddThemeStyleboxOverride("panel", UiKit.Frame(
UiTokens.RadiusSm, UiTokens.Alpha(UiTokens.Accent, 0.025f), UiTokens.AccentLineStrong));
_card.AddThemeStyleboxOverride("panel", UiKit.Frame(
UiTokens.RadiusSm, UiTokens.TooltipBg, UiTokens.AccentLine));
GetNode<PanelContainer>("Footer").AddThemeStyleboxOverride("panel", UiKit.Frame(
0, UiTokens.Surface0, UiTokens.Line));
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
_body.AddThemeColorOverride("default_color", UiTokens.Body);
_footer.AddThemeColorOverride("font_color", UiTokens.Faint);
var dim = GetNode<ColorRect>("Dim");
dim.GuiInput += OnDimGuiInput;
_footer.Text = TranslationServer.Translate("mod_manager/help_active");
_highlight.Visible = false;
_card.Visible = false;
}
/// <summary>Shows the page of one hub level; the level names match the test driver's: mods, mod_page, collections, scheme.</summary>
public void Open(string level)
/// <summary>Enables inspection without choosing a page or blocking the hub.</summary>
public void Open()
{
_title.Text = TranslationServer.Translate($"mod_manager/help_{level}_title");
_body.Text = TranslationServer.Translate($"mod_manager/help_{level}_body");
Visible = true;
IsPinned = false;
CurrentTarget = null;
_highlight.Visible = false;
_card.Visible = false;
UpdateHoveredTarget();
}
/// <summary>Hides the overlay; the hub calls this from its Escape chain and the driver checks it.</summary>
/// <summary>Leaves help mode and removes every help-only visual.</summary>
public void Close()
{
Visible = false;
IsPinned = false;
CurrentTarget = null;
if (GodotObject.IsInstanceValid(_highlight))
_highlight.Visible = false;
if (GodotObject.IsInstanceValid(_card))
_card.Visible = false;
}
private void OnDimGuiInput(InputEvent @event)
/// <summary>
/// Explains and pins one annotated control. Also used by F1 so keyboard users
/// get the same stable card as pointer users get after a click.
/// </summary>
public void ShowFor(Control target)
{
if (@event is InputEventMouseButton mouse && mouse.Pressed)
Close();
Control annotated = FindTarget(target);
if (annotated == null)
return;
Visible = true;
IsPinned = true;
SetTarget(annotated);
}
public override void _Process(double delta)
{
if (!Visible)
return;
if (IsPinned)
{
if (!GodotObject.IsInstanceValid(CurrentTarget) || !CurrentTarget.IsVisibleInTree())
{
IsPinned = false;
CurrentTarget = null;
_highlight.Visible = false;
_card.Visible = false;
return;
}
LayoutAroundTarget();
return;
}
UpdateHoveredTarget();
}
public override void _Input(InputEvent @event)
{
if (!Visible || @event is not InputEventMouseButton mouse
|| !mouse.Pressed || mouse.ButtonIndex != MouseButton.Left)
return;
Control target = FindTarget(GetViewport().GuiGetHoveredControl());
if (target != null)
{
IsPinned = true;
SetTarget(target);
}
else
{
IsPinned = false;
}
}
private void UpdateHoveredTarget()
{
Control target = FindTarget(GetViewport().GuiGetHoveredControl());
SetTarget(target);
}
private Control FindTarget(Control control)
{
Node node = control;
while (node != null && node != this)
{
if (node is Control candidate
&& candidate.IsVisibleInTree()
&& candidate.HasMeta(HintKeyMetadata))
{
return candidate;
}
node = node.GetParent();
}
return null;
}
private void SetTarget(Control target)
{
if (CurrentTarget == target)
{
if (target != null)
LayoutAroundTarget();
return;
}
CurrentTarget = target;
bool hasTarget = target != null;
_highlight.Visible = hasTarget;
_card.Visible = hasTarget;
if (!hasTarget)
return;
string key = target.GetMeta(HintKeyMetadata).AsString();
if (target.HasMeta(HintTranslationMetadata))
key = target.GetMeta(HintTranslationMetadata).AsString();
_title.Text = TranslationServer.Translate($"mod_manager/hint_{key}_title");
_body.Text = TranslationServer.Translate($"mod_manager/hint_{key}_body");
_card.ResetSize();
LayoutAroundTarget();
}
private void LayoutAroundTarget()
{
if (CurrentTarget == null || !CurrentTarget.IsVisibleInTree())
return;
_highlight.GlobalPosition = CurrentTarget.GlobalPosition - new Vector2(3, 3);
_highlight.Size = CurrentTarget.Size + new Vector2(6, 6);
Vector2 viewport = GetViewportRect().Size;
Vector2 cardSize = _card.GetCombinedMinimumSize();
_card.Size = cardSize;
Rect2 target = new(CurrentTarget.GlobalPosition, CurrentTarget.Size);
float x = Mathf.Clamp(target.GetCenter().X - cardSize.X / 2f,
ScreenMargin, Mathf.Max(ScreenMargin, viewport.X - cardSize.X - ScreenMargin));
float y = target.End.Y + Gap;
float bottomLimit = viewport.Y - FooterClearance;
if (y + cardSize.Y > bottomLimit)
y = target.Position.Y - cardSize.Y - Gap;
if (y < ScreenMargin)
{
y = Mathf.Clamp(target.GetCenter().Y - cardSize.Y / 2f,
ScreenMargin, Mathf.Max(ScreenMargin, bottomLimit - cardSize.Y));
x = target.End.X + Gap;
if (x + cardSize.X > viewport.X - ScreenMargin)
x = target.Position.X - cardSize.X - Gap;
}
x = Mathf.Clamp(x, ScreenMargin, Mathf.Max(ScreenMargin, viewport.X - cardSize.X - ScreenMargin));
_card.GlobalPosition = new Vector2(x, Mathf.Max(ScreenMargin, y));
}
}
}