diff --git a/VintageStoryModManager/Converters/Converters.cs b/VintageStoryModManager/Converters/Converters.cs
index e4ed8a15..a93013de 100644
--- a/VintageStoryModManager/Converters/Converters.cs
+++ b/VintageStoryModManager/Converters/Converters.cs
@@ -484,3 +484,28 @@ public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
throw new NotSupportedException();
}
}
+
+///
+/// Extracts the category name from a CategoryGroupKey.
+/// The CategoryGroupKey format is "{Order:D10}|{CategoryName}".
+///
+public class CategoryNameConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is not string groupKey || string.IsNullOrEmpty(groupKey))
+ return string.Empty;
+
+ // The format is "{Order:D10}|{CategoryName}"
+ var pipeIndex = groupKey.IndexOf('|');
+ if (pipeIndex >= 0 && pipeIndex < groupKey.Length - 1)
+ return groupKey.Substring(pipeIndex + 1);
+
+ return groupKey;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/VintageStoryModManager/Models/ModCategory.cs b/VintageStoryModManager/Models/ModCategory.cs
new file mode 100644
index 00000000..db583d36
--- /dev/null
+++ b/VintageStoryModManager/Models/ModCategory.cs
@@ -0,0 +1,103 @@
+namespace VintageStoryModManager.Models;
+
+///
+/// Represents a category for organizing mods in the mod list.
+///
+public sealed class ModCategory
+{
+ ///
+ /// The ID used for the default "Uncategorized" category.
+ ///
+ public const string UncategorizedId = "uncategorized";
+
+ ///
+ /// The display name for the default "Uncategorized" category.
+ ///
+ public const string UncategorizedName = "Uncategorized";
+
+ ///
+ /// Creates a new category with a unique ID.
+ ///
+ public ModCategory(string name, int order = 0)
+ {
+ Id = Guid.NewGuid().ToString("N");
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ Order = order;
+ }
+
+ ///
+ /// Creates a category with a specific ID (used for deserialization or default category).
+ ///
+ public ModCategory(string id, string name, int order, bool isProfileSpecific = false)
+ {
+ Id = id ?? throw new ArgumentNullException(nameof(id));
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ Order = order;
+ IsProfileSpecific = isProfileSpecific;
+ }
+
+ ///
+ /// Parameterless constructor for JSON deserialization.
+ ///
+ public ModCategory()
+ {
+ Id = string.Empty;
+ Name = string.Empty;
+ }
+
+ ///
+ /// Unique identifier for this category.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// Display name of the category.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Sort order for the category. Lower values appear first.
+ ///
+ public int Order { get; set; }
+
+ ///
+ /// Whether this is the default "Uncategorized" category that cannot be deleted.
+ ///
+ public bool IsDefault => string.Equals(Id, UncategorizedId, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Whether this category is specific to the current profile (vs global).
+ ///
+ public bool IsProfileSpecific { get; set; }
+
+ ///
+ /// Creates the default "Uncategorized" category.
+ ///
+ public static ModCategory CreateDefault()
+ {
+ return new ModCategory(UncategorizedId, UncategorizedName, int.MaxValue);
+ }
+
+ ///
+ /// Creates a copy of this category.
+ ///
+ public ModCategory Clone()
+ {
+ return new ModCategory(Id, Name, Order, IsProfileSpecific);
+ }
+
+ public override string ToString()
+ {
+ return $"{Name} ({Id})";
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is ModCategory other && string.Equals(Id, other.Id, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override int GetHashCode()
+ {
+ return StringComparer.OrdinalIgnoreCase.GetHashCode(Id);
+ }
+}
diff --git a/VintageStoryModManager/Resources/MainWindowStyles.xaml b/VintageStoryModManager/Resources/MainWindowStyles.xaml
index e39a1685..7138f377 100644
--- a/VintageStoryModManager/Resources/MainWindowStyles.xaml
+++ b/VintageStoryModManager/Resources/MainWindowStyles.xaml
@@ -10,6 +10,7 @@
+
0,0,0,0
10
-12
@@ -381,31 +382,33 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -429,7 +432,7 @@
-
+
diff --git a/VintageStoryModManager/Resources/Themes/DarkVsTheme.xaml b/VintageStoryModManager/Resources/Themes/DarkVsTheme.xaml
index 34cbf8e1..4da2ffb3 100644
--- a/VintageStoryModManager/Resources/Themes/DarkVsTheme.xaml
+++ b/VintageStoryModManager/Resources/Themes/DarkVsTheme.xaml
@@ -507,17 +507,20 @@
HorizontalOffset="0"
IsOpen="{TemplateBinding IsSubmenuOpen}"
Placement="Bottom"
- VerticalOffset="20">
-
-
-
-
-
+ VerticalOffset="-8">
+
+
+
+
+
+
+
@@ -599,24 +602,28 @@
x:Name="PART_Popup"
AllowsTransparency="True"
Focusable="False"
- HorizontalOffset="2"
+ HorizontalOffset="-10"
IsOpen="{TemplateBinding IsSubmenuOpen}"
Placement="Right"
PopupAnimation="Fade"
VerticalOffset="-3">
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/VintageStoryModManager/Services/UserConfigurationService.cs b/VintageStoryModManager/Services/UserConfigurationService.cs
index a5597da5..ca033805 100644
--- a/VintageStoryModManager/Services/UserConfigurationService.cs
+++ b/VintageStoryModManager/Services/UserConfigurationService.cs
@@ -4,6 +4,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
+using VintageStoryModManager.Models;
using VintageStoryModManager.ViewModels;
namespace VintageStoryModManager.Services;
@@ -143,6 +144,8 @@ public sealed class UserConfigurationService
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _themePaletteColors = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _globalModCategories = new();
+ private bool _isGroupedByCategory;
private bool _hasPendingModConfigPathSave;
private bool _hasPendingSave;
private bool _isModUsageTrackingDisabled;
@@ -340,6 +343,274 @@ private bool ActiveHasPendingModUsagePrompt
public bool DisableHoverEffects { get; private set; }
+ ///
+ /// Whether the mod list should be grouped by category.
+ ///
+ public bool IsGroupedByCategory
+ {
+ get => _isGroupedByCategory;
+ set
+ {
+ if (_isGroupedByCategory == value) return;
+ _isGroupedByCategory = value;
+ Save();
+ }
+ }
+
+ #region Mod Categories
+
+ ///
+ /// Gets the global mod categories (shared across all profiles).
+ ///
+ public IReadOnlyList GetGlobalModCategories()
+ {
+ if (_globalModCategories.Count == 0)
+ return new[] { ModCategory.CreateDefault() };
+
+ return _globalModCategories.OrderBy(c => c.Order).ToArray();
+ }
+
+ ///
+ /// Gets all effective categories for the current profile (global + profile overrides).
+ ///
+ public IReadOnlyList GetEffectiveModCategories()
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Add global categories
+ foreach (var category in _globalModCategories)
+ result[category.Id] = category.Clone();
+
+ // Add/override with profile-specific categories
+ foreach (var category in ActiveProfile.ProfileCategoryOverrides)
+ {
+ var clone = category.Clone();
+ clone.IsProfileSpecific = true;
+ result[category.Id] = clone;
+ }
+
+ // Ensure Uncategorized always exists
+ if (!result.ContainsKey(ModCategory.UncategorizedId))
+ result[ModCategory.UncategorizedId] = ModCategory.CreateDefault();
+
+ return result.Values.OrderBy(c => c.Order).ToArray();
+ }
+
+ ///
+ /// Adds a new global category.
+ ///
+ public ModCategory AddGlobalCategory(string name)
+ {
+ var maxOrder = _globalModCategories.Count > 0
+ ? _globalModCategories.Max(c => c.Order)
+ : 0;
+
+ var category = new ModCategory(name, maxOrder + 1);
+ _globalModCategories.Add(category);
+ Save();
+ return category;
+ }
+
+ ///
+ /// Adds a profile-specific category.
+ ///
+ public ModCategory AddProfileCategory(string name)
+ {
+ var allCategories = GetEffectiveModCategories();
+ var maxOrder = allCategories.Count > 0
+ ? allCategories.Max(c => c.Order)
+ : 0;
+
+ var category = new ModCategory(name, maxOrder + 1) { IsProfileSpecific = true };
+ ActiveProfile.ProfileCategoryOverrides.Add(category);
+ Save();
+ return category;
+ }
+
+ ///
+ /// Renames a category (global or profile-specific).
+ ///
+ public bool RenameCategory(string categoryId, string newName)
+ {
+ if (string.IsNullOrWhiteSpace(newName)) return false;
+ if (string.Equals(categoryId, ModCategory.UncategorizedId, StringComparison.OrdinalIgnoreCase))
+ return false; // Cannot rename default category
+
+ // Try profile overrides first
+ var profileCategory = ActiveProfile.ProfileCategoryOverrides
+ .FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+
+ if (profileCategory != null)
+ {
+ profileCategory.Name = newName;
+ Save();
+ return true;
+ }
+
+ // Try global categories
+ var globalCategory = _globalModCategories
+ .FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+
+ if (globalCategory != null)
+ {
+ globalCategory.Name = newName;
+ Save();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Deletes a category and moves its mods to Uncategorized.
+ ///
+ public bool DeleteCategory(string categoryId)
+ {
+ if (string.Equals(categoryId, ModCategory.UncategorizedId, StringComparison.OrdinalIgnoreCase))
+ return false; // Cannot delete default category
+
+ var removed = false;
+
+ // Remove from profile overrides
+ removed |= ActiveProfile.ProfileCategoryOverrides
+ .RemoveAll(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)) > 0;
+
+ // Remove from global categories
+ removed |= _globalModCategories
+ .RemoveAll(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)) > 0;
+
+ if (removed)
+ {
+ // Move all mods in this category to Uncategorized
+ var modsToReassign = ActiveProfile.ModCategoryAssignments
+ .Where(kvp => string.Equals(kvp.Value, categoryId, StringComparison.OrdinalIgnoreCase))
+ .Select(kvp => kvp.Key)
+ .ToArray();
+
+ foreach (var modId in modsToReassign)
+ ActiveProfile.ModCategoryAssignments[modId] = ModCategory.UncategorizedId;
+
+ Save();
+ }
+
+ return removed;
+ }
+
+ ///
+ /// Reorders categories by setting their Order values.
+ ///
+ public void ReorderCategories(IReadOnlyList orderedCategoryIds)
+ {
+ var allCategories = GetEffectiveModCategories().ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 0; i < orderedCategoryIds.Count; i++)
+ {
+ var categoryId = orderedCategoryIds[i];
+ if (!allCategories.TryGetValue(categoryId, out var category)) continue;
+
+ // Update order in the source collection
+ if (category.IsProfileSpecific)
+ {
+ var profileCategory = ActiveProfile.ProfileCategoryOverrides
+ .FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+ if (profileCategory != null) profileCategory.Order = i;
+ }
+ else
+ {
+ var globalCategory = _globalModCategories
+ .FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase));
+ if (globalCategory != null) globalCategory.Order = i;
+ }
+ }
+
+ Save();
+ }
+
+ ///
+ /// Gets the category ID assigned to a mod.
+ ///
+ public string GetModCategoryAssignment(string modId)
+ {
+ if (string.IsNullOrWhiteSpace(modId)) return ModCategory.UncategorizedId;
+
+ return ActiveProfile.ModCategoryAssignments.TryGetValue(modId, out var categoryId)
+ ? categoryId
+ : ModCategory.UncategorizedId;
+ }
+
+ ///
+ /// Assigns a mod to a category.
+ ///
+ public void SetModCategoryAssignment(string modId, string categoryId)
+ {
+ if (string.IsNullOrWhiteSpace(modId)) return;
+
+ var normalizedCategoryId = string.IsNullOrWhiteSpace(categoryId)
+ ? ModCategory.UncategorizedId
+ : categoryId;
+
+ if (string.Equals(normalizedCategoryId, ModCategory.UncategorizedId, StringComparison.OrdinalIgnoreCase))
+ {
+ // Remove assignment for uncategorized (it's the default)
+ if (ActiveProfile.ModCategoryAssignments.Remove(modId))
+ Save();
+ }
+ else
+ {
+ ActiveProfile.ModCategoryAssignments[modId] = normalizedCategoryId;
+ Save();
+ }
+ }
+
+ ///
+ /// Assigns multiple mods to a category.
+ ///
+ public void SetModsCategoryAssignment(IEnumerable modIds, string categoryId)
+ {
+ var normalizedCategoryId = string.IsNullOrWhiteSpace(categoryId)
+ ? ModCategory.UncategorizedId
+ : categoryId;
+
+ var isUncategorized = string.Equals(normalizedCategoryId, ModCategory.UncategorizedId,
+ StringComparison.OrdinalIgnoreCase);
+
+ var changed = false;
+ foreach (var modId in modIds)
+ {
+ if (string.IsNullOrWhiteSpace(modId)) continue;
+
+ if (isUncategorized)
+ {
+ changed |= ActiveProfile.ModCategoryAssignments.Remove(modId);
+ }
+ else
+ {
+ ActiveProfile.ModCategoryAssignments[modId] = normalizedCategoryId;
+ changed = true;
+ }
+ }
+
+ if (changed) Save();
+ }
+
+ ///
+ /// Gets all mod category assignments for the current profile.
+ ///
+ public IReadOnlyDictionary GetAllModCategoryAssignments()
+ {
+ return new Dictionary(ActiveProfile.ModCategoryAssignments, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Gets profile-specific category overrides.
+ ///
+ public IReadOnlyList GetProfileCategoryOverrides()
+ {
+ return ActiveProfile.ProfileCategoryOverrides.ToArray();
+ }
+
+ #endregion
+
public IReadOnlyList GetGameProfileNames()
{
return _gameProfiles.Keys
@@ -561,6 +832,8 @@ private void LoadGameProfile(GameProfileState profile, JsonObject obj)
LoadBulkUpdateModExclusions(obj["bulkUpdateModExclusions"], profile.BulkUpdateModExclusions);
LoadSkippedModVersions(obj["skippedModVersions"], profile.SkippedModVersions);
LoadModUsageTracking(obj["modUsageTracking"], profile);
+ LoadModCategoryAssignments(obj["modCategoryAssignments"], profile.ModCategoryAssignments);
+ LoadProfileCategoryOverrides(obj["profileCategoryOverrides"], profile.ProfileCategoryOverrides);
}
private void ApplyLegacyProfileData(JsonObject root, GameProfileState profile)
@@ -1834,6 +2107,8 @@ private void Load()
UseFasterThumbnails = obj["useFasterThumbnails"]?.GetValue() ??
!(obj["useCorrectThumbnails"]?.GetValue() ?? false);
DisableHoverEffects = obj["disableHoverEffects"]?.GetValue() ?? false;
+ _isGroupedByCategory = obj["isGroupedByCategory"]?.GetValue() ?? false;
+ LoadGlobalModCategories(obj["modCategories"]);
var profilesFound = false;
if (obj["gameProfiles"] is JsonObject profilesObj)
@@ -2061,6 +2336,8 @@ private void PersistConfiguration()
["rebuiltModlistMigrationCompleted"] = RebuiltModlistMigrationCompleted,
["useFasterThumbnails"] = UseFasterThumbnails,
["disableHoverEffects"] = DisableHoverEffects,
+ ["isGroupedByCategory"] = _isGroupedByCategory,
+ ["modCategories"] = BuildCategoriesJson(_globalModCategories),
["gameProfiles"] = BuildGameProfilesJson()
};
@@ -2253,6 +2530,39 @@ private static JsonObject BuildSkippedModVersionsJson(IReadOnlyDictionary source)
+ {
+ var result = new JsonObject();
+
+ foreach (var pair in source.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) continue;
+
+ result[pair.Key] = pair.Value;
+ }
+
+ return result;
+ }
+
+ private static JsonArray BuildCategoriesJson(IEnumerable categories)
+ {
+ var result = new JsonArray();
+
+ foreach (var category in categories.OrderBy(c => c.Order))
+ {
+ if (string.IsNullOrWhiteSpace(category.Id) || string.IsNullOrWhiteSpace(category.Name)) continue;
+
+ result.Add(new JsonObject
+ {
+ ["id"] = category.Id,
+ ["name"] = category.Name,
+ ["order"] = category.Order
+ });
+ }
+
+ return result;
+ }
+
private static JsonObject BuildModUsageTrackingJson(GameProfileState profile)
{
var counts = new JsonArray();
@@ -2345,7 +2655,9 @@ private JsonObject BuildGameProfilesJson()
["customShortcutPath"] = profile.CustomShortcutPath,
["bulkUpdateModExclusions"] = BuildBulkUpdateModExclusionsJson(profile.BulkUpdateModExclusions),
["skippedModVersions"] = BuildSkippedModVersionsJson(profile.SkippedModVersions),
- ["modUsageTracking"] = BuildModUsageTrackingJson(profile)
+ ["modUsageTracking"] = BuildModUsageTrackingJson(profile),
+ ["modCategoryAssignments"] = BuildModCategoryAssignmentsJson(profile.ModCategoryAssignments),
+ ["profileCategoryOverrides"] = BuildCategoriesJson(profile.ProfileCategoryOverrides)
};
if (profile.RequiresDataDirectorySelection) profileObject["requiresDataDirectorySelection"] = true;
@@ -2445,6 +2757,68 @@ private void LoadSkippedModVersions(JsonNode? node, Dictionary t
if (requiresSave) _hasPendingSave = true;
}
+ private void LoadModCategoryAssignments(JsonNode? node, Dictionary target)
+ {
+ target.Clear();
+
+ if (node is not JsonObject obj) return;
+
+ foreach (var (key, value) in obj)
+ {
+ var normalizedModId = NormalizeModId(key);
+ if (normalizedModId is null) continue;
+
+ var categoryId = GetOptionalString(value);
+ if (string.IsNullOrWhiteSpace(categoryId)) continue;
+
+ if (target.ContainsKey(normalizedModId)) continue;
+
+ target[normalizedModId] = categoryId;
+ }
+ }
+
+ private void LoadProfileCategoryOverrides(JsonNode? node, List target)
+ {
+ target.Clear();
+
+ if (node is not JsonArray array) return;
+
+ foreach (var item in array)
+ {
+ if (item is not JsonObject categoryObj) continue;
+
+ var id = GetOptionalString(categoryObj["id"]);
+ var name = GetOptionalString(categoryObj["name"]);
+
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) continue;
+
+ var order = categoryObj["order"]?.GetValue() ?? 0;
+
+ target.Add(new ModCategory(id, name, order, true));
+ }
+ }
+
+ private void LoadGlobalModCategories(JsonNode? node)
+ {
+ _globalModCategories.Clear();
+
+ if (node is not JsonArray array) return;
+
+ foreach (var item in array)
+ {
+ if (item is not JsonObject categoryObj) continue;
+
+ var id = GetOptionalString(categoryObj["id"]);
+ var name = GetOptionalString(categoryObj["name"]);
+
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) continue;
+
+ var order = categoryObj["order"]?.GetValue() ?? 0;
+
+ _globalModCategories.Add(new ModCategory(id, name, order));
+ }
+ }
+
private void LoadInstalledColumnVisibility(JsonNode? node)
{
_installedColumnVisibility.Clear();
@@ -3608,6 +3982,16 @@ public GameProfileState(string name)
public int LongRunningSessionCount { get; set; }
public bool HasPendingModUsagePrompt { get; set; }
+
+ ///
+ /// Per-profile mod-to-category assignments (mod ID -> category ID).
+ ///
+ public Dictionary ModCategoryAssignments { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Profile-specific category definitions that override or extend global categories.
+ ///
+ public List ProfileCategoryOverrides { get; } = new();
}
private sealed class ModConfigPathEntry
diff --git a/VintageStoryModManager/ViewModels/MainViewModel.cs b/VintageStoryModManager/ViewModels/MainViewModel.cs
index 7b1c1a25..eda8013f 100644
--- a/VintageStoryModManager/ViewModels/MainViewModel.cs
+++ b/VintageStoryModManager/ViewModels/MainViewModel.cs
@@ -329,6 +329,187 @@ public bool IsCompactView
set => SetProperty(ref _isCompactView, value);
}
+ #region Category Grouping
+
+ ///
+ /// Whether the mod list is grouped by category.
+ ///
+ public bool IsGroupedByCategory
+ {
+ get => _configuration.IsGroupedByCategory;
+ set
+ {
+ if (_configuration.IsGroupedByCategory == value) return;
+ _configuration.IsGroupedByCategory = value;
+ OnPropertyChanged();
+ ApplyCategoryGrouping();
+ }
+ }
+
+ ///
+ /// Gets the effective categories for the current profile.
+ ///
+ public IReadOnlyList GetEffectiveCategories()
+ {
+ return _configuration.GetEffectiveModCategories();
+ }
+
+ ///
+ /// Assigns a mod to a category.
+ ///
+ public void AssignModToCategory(ModListItemViewModel mod, string categoryId)
+ {
+ if (mod == null) return;
+
+ _configuration.SetModCategoryAssignment(mod.ModId, categoryId);
+ UpdateModCategoryFromConfiguration(mod);
+
+ if (IsGroupedByCategory)
+ ModsView.Refresh();
+ }
+
+ ///
+ /// Assigns multiple mods to a category.
+ ///
+ public void AssignModsToCategory(IEnumerable mods, string categoryId)
+ {
+ if (mods == null) return;
+
+ var modList = mods.ToList();
+ _configuration.SetModsCategoryAssignment(modList.Select(m => m.ModId), categoryId);
+
+ foreach (var mod in modList)
+ UpdateModCategoryFromConfiguration(mod);
+
+ if (IsGroupedByCategory)
+ ModsView.Refresh();
+ }
+
+ ///
+ /// Creates a new global category.
+ ///
+ public ModCategory CreateCategory(string name)
+ {
+ var category = _configuration.AddGlobalCategory(name);
+ RefreshAllModCategories();
+ return category;
+ }
+
+ ///
+ /// Creates a profile-specific category.
+ ///
+ public ModCategory CreateProfileCategory(string name)
+ {
+ var category = _configuration.AddProfileCategory(name);
+ RefreshAllModCategories();
+ return category;
+ }
+
+ ///
+ /// Deletes a category and moves its mods to Uncategorized.
+ ///
+ public bool DeleteCategory(string categoryId)
+ {
+ var result = _configuration.DeleteCategory(categoryId);
+ if (result)
+ RefreshAllModCategories();
+ return result;
+ }
+
+ ///
+ /// Renames a category.
+ ///
+ public bool RenameCategory(string categoryId, string newName)
+ {
+ var result = _configuration.RenameCategory(categoryId, newName);
+ if (result)
+ RefreshAllModCategories();
+ return result;
+ }
+
+ ///
+ /// Gets the global mod categories (shared across all profiles).
+ ///
+ public IReadOnlyList GetGlobalCategories()
+ {
+ return _configuration.GetGlobalModCategories();
+ }
+
+ ///
+ /// Gets the profile-specific category overrides.
+ ///
+ public IReadOnlyList GetProfileCategories()
+ {
+ return _configuration.GetProfileCategoryOverrides();
+ }
+
+ ///
+ /// Applies category grouping to the collection view.
+ ///
+ private void ApplyCategoryGrouping()
+ {
+ using (ModsView.DeferRefresh())
+ {
+ ModsView.GroupDescriptions.Clear();
+
+ if (IsGroupedByCategory)
+ {
+ ModsView.GroupDescriptions.Add(
+ new PropertyGroupDescription(nameof(ModListItemViewModel.CategoryGroupKey)));
+ }
+ }
+ }
+
+ ///
+ /// Updates all mod category assignments from configuration.
+ ///
+ public void RefreshAllModCategories()
+ {
+ var categories = _configuration.GetEffectiveModCategories()
+ .ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var mod in _mods)
+ UpdateModCategoryFromConfiguration(mod, categories);
+
+ if (IsGroupedByCategory)
+ ModsView.Refresh();
+ }
+
+ ///
+ /// Updates a single mod's category from configuration.
+ ///
+ private void UpdateModCategoryFromConfiguration(ModListItemViewModel mod)
+ {
+ var categories = _configuration.GetEffectiveModCategories()
+ .ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
+ UpdateModCategoryFromConfiguration(mod, categories);
+ }
+
+ ///
+ /// Updates a single mod's category from configuration using cached categories.
+ ///
+ private void UpdateModCategoryFromConfiguration(
+ ModListItemViewModel mod,
+ IReadOnlyDictionary categories)
+ {
+ var categoryId = _configuration.GetModCategoryAssignment(mod.ModId);
+
+ if (categories.TryGetValue(categoryId, out var category))
+ {
+ mod.UpdateCategory(category.Id, category.Name, category.Order);
+ }
+ else
+ {
+ // Category not found, use Uncategorized
+ mod.UpdateCategory(
+ ModCategory.UncategorizedId,
+ ModCategory.UncategorizedName,
+ int.MaxValue);
+ }
+ }
+
+ #endregion
+
public bool HasSelectedTags
{
get => _hasSelectedTags;
@@ -1752,6 +1933,16 @@ await InvokeOnDispatcherAsync(() =>
TotalMods = _mods.Count;
+ // Initialize category assignments for all mods (but don't refresh the view yet)
+ var categories = _configuration.GetEffectiveModCategories()
+ .ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
+ foreach (var mod in _mods)
+ UpdateModCategoryFromConfiguration(mod, categories);
+
+ // Apply category grouping if enabled (this will refresh the view)
+ if (IsGroupedByCategory)
+ ApplyCategoryGrouping();
+
if (!string.IsNullOrWhiteSpace(previousSelection)
&& _modViewModelsBySourcePath.TryGetValue(previousSelection, out var selected))
SelectedMod = selected;
diff --git a/VintageStoryModManager/ViewModels/ModListItemViewModel.cs b/VintageStoryModManager/ViewModels/ModListItemViewModel.cs
index 939166aa..54346664 100644
--- a/VintageStoryModManager/ViewModels/ModListItemViewModel.cs
+++ b/VintageStoryModManager/ViewModels/ModListItemViewModel.cs
@@ -78,6 +78,11 @@ public sealed class ModListItemViewModel : ObservableObject
// Cached tag display string to avoid repeated string.Join allocations
private string? _cachedDatabaseTagsDisplay;
+ // Category grouping support
+ private string _categoryId = Models.ModCategory.UncategorizedId;
+ private string _categoryName = Models.ModCategory.UncategorizedName;
+ private int _categorySortOrder = int.MaxValue;
+
// Property change batching support
private int _propertyChangeSuspendCount;
private readonly HashSet _pendingPropertyChanges = new();
@@ -660,6 +665,68 @@ public bool IsSelected
set => SetProperty(ref _isSelected, value);
}
+ #region Category Properties
+
+ ///
+ /// The ID of the category this mod is assigned to.
+ ///
+ public string CategoryId
+ {
+ get => _categoryId;
+ private set
+ {
+ if (SetProperty(ref _categoryId, value))
+ OnPropertyChanged(nameof(CategoryGroupKey));
+ }
+ }
+
+ ///
+ /// The display name of the category this mod is assigned to.
+ ///
+ public string CategoryName
+ {
+ get => _categoryName;
+ private set
+ {
+ if (SetProperty(ref _categoryName, value))
+ OnPropertyChanged(nameof(CategoryGroupKey));
+ }
+ }
+
+ ///
+ /// The sort order of the category (lower values appear first).
+ ///
+ public int CategorySortOrder
+ {
+ get => _categorySortOrder;
+ private set
+ {
+ if (SetProperty(ref _categorySortOrder, value))
+ OnPropertyChanged(nameof(CategoryGroupKey));
+ }
+ }
+
+ ///
+ /// Composite key for grouping by category. Format: "{Order:D10}|{Name}"
+ /// This ensures categories are sorted by order, then by name for display.
+ ///
+ public string CategoryGroupKey => $"{_categorySortOrder:D10}|{_categoryName}";
+
+ ///
+ /// Updates the category assignment for this mod.
+ ///
+ public void UpdateCategory(string categoryId, string categoryName, int categorySortOrder)
+ {
+ using (SuspendPropertyChangeNotifications())
+ {
+ CategoryId = categoryId ?? Models.ModCategory.UncategorizedId;
+ CategoryName = categoryName ?? Models.ModCategory.UncategorizedName;
+ CategorySortOrder = categorySortOrder;
+ }
+ }
+
+ #endregion
+
public void RefreshInternetAccessDependentState()
{
OnPropertyChanged(nameof(LatestDatabaseVersionDisplay));
diff --git a/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml b/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml
new file mode 100644
index 00000000..029b10b5
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml.cs b/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml.cs
new file mode 100644
index 00000000..5acf4618
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/CategorySelectionDialog.xaml.cs
@@ -0,0 +1,90 @@
+using System.Windows;
+using System.Windows.Input;
+using VintageStoryModManager.Models;
+
+namespace VintageStoryModManager.Views.Dialogs;
+
+public partial class CategorySelectionDialog : Window
+{
+ private readonly Action _onCreateCategory;
+
+ public CategorySelectionDialog(
+ IReadOnlyList categories,
+ string currentCategoryId,
+ Action onCreateCategory)
+ {
+ InitializeComponent();
+
+ _onCreateCategory = onCreateCategory;
+
+ // Create view models for the categories
+ var items = categories.Select(c => new CategoryItemViewModel
+ {
+ Id = c.Id,
+ Name = c.Name,
+ IsCurrentCategory = string.Equals(c.Id, currentCategoryId, StringComparison.OrdinalIgnoreCase),
+ IsProfileSpecific = c.IsProfileSpecific
+ }).ToList();
+
+ CategoryListBox.ItemsSource = items;
+
+ // Select the current category
+ var currentItem = items.FirstOrDefault(i => i.IsCurrentCategory);
+ if (currentItem != null)
+ {
+ CategoryListBox.SelectedItem = currentItem;
+ }
+ }
+
+ public string? SelectedCategoryId { get; private set; }
+
+ private void OkButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (CategoryListBox.SelectedItem is CategoryItemViewModel selected)
+ {
+ SelectedCategoryId = selected.Id;
+ DialogResult = true;
+ }
+ }
+
+ private void CancelButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ private void CategoryListBox_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (CategoryListBox.SelectedItem is CategoryItemViewModel selected)
+ {
+ SelectedCategoryId = selected.Id;
+ DialogResult = true;
+ }
+ }
+
+ private void NewCategoryButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ var inputDialog = new TextInputDialog("New Category", "Enter category name:")
+ {
+ Owner = this,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ if (inputDialog.ShowDialog() == true && !string.IsNullOrWhiteSpace(inputDialog.InputText))
+ {
+ _onCreateCategory(inputDialog.InputText.Trim());
+
+ // Close this dialog so it can be reopened with the new category
+ DialogResult = false;
+ }
+ }
+
+ private class CategoryItemViewModel
+ {
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public bool IsCurrentCategory { get; set; }
+ public bool IsProfileSpecific { get; set; }
+
+ public string DisplayName => IsCurrentCategory ? $"✓ {Name}" : $" {Name}";
+ }
+}
diff --git a/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml b/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml
new file mode 100644
index 00000000..76889808
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml.cs b/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml.cs
new file mode 100644
index 00000000..d3e65fe7
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/ManageCategoriesDialog.xaml.cs
@@ -0,0 +1,158 @@
+using System.Windows;
+using VintageStoryModManager.Models;
+using VintageStoryModManager.ViewModels;
+using MessageBox = System.Windows.MessageBox;
+
+namespace VintageStoryModManager.Views.Dialogs;
+
+public partial class ManageCategoriesDialog : Window
+{
+ private readonly MainViewModel _viewModel;
+
+ public ManageCategoriesDialog(MainViewModel viewModel)
+ {
+ InitializeComponent();
+ _viewModel = viewModel;
+ RefreshLists();
+ }
+
+ private void RefreshLists()
+ {
+ GlobalCategoriesListBox.ItemsSource = _viewModel.GetGlobalCategories();
+ ProfileCategoriesListBox.ItemsSource = _viewModel.GetProfileCategories();
+ }
+
+ private void AddGlobalButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ var dialog = new TextInputDialog("New Global Category", "Enter category name:")
+ {
+ Owner = this,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ if (dialog.ShowDialog() == true && !string.IsNullOrWhiteSpace(dialog.InputText))
+ {
+ _viewModel.CreateCategory(dialog.InputText.Trim());
+ RefreshLists();
+ }
+ }
+
+ private void RenameGlobalButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (GlobalCategoriesListBox.SelectedItem is not ModCategory category)
+ {
+ MessageBox.Show(this, "Please select a category to rename.", "No Selection", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ if (category.IsDefault)
+ {
+ MessageBox.Show(this, "The default 'Uncategorized' category cannot be renamed.", "Cannot Rename", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var dialog = new TextInputDialog("Rename Category", "Enter new name:", category.Name)
+ {
+ Owner = this,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ if (dialog.ShowDialog() == true && !string.IsNullOrWhiteSpace(dialog.InputText))
+ {
+ _viewModel.RenameCategory(category.Id, dialog.InputText.Trim());
+ RefreshLists();
+ }
+ }
+
+ private void DeleteGlobalButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (GlobalCategoriesListBox.SelectedItem is not ModCategory category)
+ {
+ MessageBox.Show(this, "Please select a category to delete.", "No Selection", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ if (category.IsDefault)
+ {
+ MessageBox.Show(this, "The default 'Uncategorized' category cannot be deleted.", "Cannot Delete", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var result = MessageBox.Show(
+ this,
+ $"Are you sure you want to delete the category '{category.Name}'?\n\nAll mods in this category will be moved to 'Uncategorized'.",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ _viewModel.DeleteCategory(category.Id);
+ RefreshLists();
+ }
+ }
+
+ private void AddProfileButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ var dialog = new TextInputDialog("New Profile Category", "Enter category name:")
+ {
+ Owner = this,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ if (dialog.ShowDialog() == true && !string.IsNullOrWhiteSpace(dialog.InputText))
+ {
+ _viewModel.CreateProfileCategory(dialog.InputText.Trim());
+ RefreshLists();
+ }
+ }
+
+ private void RenameProfileButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (ProfileCategoriesListBox.SelectedItem is not ModCategory category)
+ {
+ MessageBox.Show(this, "Please select a category to rename.", "No Selection", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var dialog = new TextInputDialog("Rename Category", "Enter new name:", category.Name)
+ {
+ Owner = this,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ if (dialog.ShowDialog() == true && !string.IsNullOrWhiteSpace(dialog.InputText))
+ {
+ _viewModel.RenameCategory(category.Id, dialog.InputText.Trim());
+ RefreshLists();
+ }
+ }
+
+ private void DeleteProfileButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (ProfileCategoriesListBox.SelectedItem is not ModCategory category)
+ {
+ MessageBox.Show(this, "Please select a category to delete.", "No Selection", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var result = MessageBox.Show(
+ this,
+ $"Are you sure you want to delete the category '{category.Name}'?\n\nAll mods in this category will be moved to 'Uncategorized'.",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ _viewModel.DeleteCategory(category.Id);
+ RefreshLists();
+ }
+ }
+
+ private void CloseButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ DialogResult = true;
+ Close();
+ }
+}
diff --git a/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml b/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml
new file mode 100644
index 00000000..190e830a
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml.cs b/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml.cs
new file mode 100644
index 00000000..e0fb4321
--- /dev/null
+++ b/VintageStoryModManager/Views/Dialogs/TextInputDialog.xaml.cs
@@ -0,0 +1,31 @@
+using System.Windows;
+
+namespace VintageStoryModManager.Views.Dialogs;
+
+public partial class TextInputDialog : Window
+{
+ public TextInputDialog(string title, string prompt, string defaultValue = "")
+ {
+ InitializeComponent();
+
+ Title = title;
+ PromptTextBlock.Text = prompt;
+ InputTextBox.Text = defaultValue;
+ InputTextBox.SelectAll();
+ }
+
+ public string InputText => InputTextBox.Text;
+
+ private void OkButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrWhiteSpace(InputTextBox.Text))
+ {
+ DialogResult = true;
+ }
+ }
+
+ private void CancelButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+}
diff --git a/VintageStoryModManager/Views/MainWindow.xaml b/VintageStoryModManager/Views/MainWindow.xaml
index 95dd0f03..0c0b850e 100644
--- a/VintageStoryModManager/Views/MainWindow.xaml
+++ b/VintageStoryModManager/Views/MainWindow.xaml
@@ -308,6 +308,19 @@
IsCheckable="True"
IsChecked="{Binding IsCompactView, Mode=TwoWay}"
Style="{StaticResource ToggleMenuItemStyle}" />
+
+
+