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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +