diff --git a/Focus.Apps.EasyNpc/Build/BuildChecker.cs b/Focus.Apps.EasyNpc/Build/BuildChecker.cs index bb09afc..7a7b22b 100644 --- a/Focus.Apps.EasyNpc/Build/BuildChecker.cs +++ b/Focus.Apps.EasyNpc/Build/BuildChecker.cs @@ -1,5 +1,6 @@ using Focus.Apps.EasyNpc.Configuration; using Focus.Apps.EasyNpc.GameData.Files; +using Focus.Apps.EasyNpc.GameData.Records; using Focus.Apps.EasyNpc.Profile; using System; using System.Collections.Generic; @@ -80,6 +81,8 @@ private IEnumerable CheckMissingPlugins(IEnumerable .Select(x => npcConfigs.TryGetValue(Tuple.Create(x.BasePluginName, x.LocalFormIdHex), out var npc) ? new { + npc.BasePluginName, + npc.LocalFormIdHex, npc.EditorId, npc.Name, FieldName = x.Field == NpcProfileField.FacePlugin ? "face" : "default", @@ -87,6 +90,7 @@ private IEnumerable CheckMissingPlugins(IEnumerable } : null) .Where(x => x != null) .Select(x => new BuildWarning( + new RecordKey(x.BasePluginName, x.LocalFormIdHex), BuildWarningId.SelectedPluginRemoved, WarningMessages.SelectedPluginRemoved(x.EditorId, x.Name, x.FieldName, x.PluginName))); } @@ -111,6 +115,7 @@ private IEnumerable CheckModPluginConsistency( { if (npc.RequiresFacegenData()) yield return new BuildWarning( + new RecordKey(npc), BuildWarningId.FaceModNotSpecified, WarningMessages.FaceModNotSpecified(npc.EditorId, npc.Name)); yield break; @@ -120,6 +125,7 @@ private IEnumerable CheckModPluginConsistency( if (!modPluginMap.IsModInstalled(npc.FaceModName)) { yield return new BuildWarning( + new RecordKey(npc), BuildWarningId.FaceModNotInstalled, WarningMessages.FaceModNotInstalled(npc.EditorId, npc.Name, npc.FaceModName)); yield break; @@ -127,6 +133,7 @@ private IEnumerable CheckModPluginConsistency( if (!modsProvidingFacePlugin.Contains(npc.FaceModName)) yield return new BuildWarning( npc.FacePluginName, + new RecordKey(npc), BuildWarningId.FaceModPluginMismatch, WarningMessages.FaceModPluginMismatch(npc.EditorId, npc.Name, npc.FaceModName, npc.FacePluginName)); var faceMeshFileName = FileStructure.GetFaceMeshFileName(npc.BasePluginName, npc.LocalFormIdHex); @@ -142,16 +149,19 @@ private IEnumerable CheckModPluginConsistency( // include it isn't loaded, i.e. due to the mod or plugin being disabled. yield return new BuildWarning( npc.FacePluginName, + new RecordKey(npc), BuildWarningId.FaceModMissingFaceGen, WarningMessages.FaceModMissingFaceGen(npc.EditorId, npc.Name, npc.FaceModName)); else if (!npc.RequiresFacegenData() && (hasLooseFacegen || hasArchiveFacegen)) yield return new BuildWarning( npc.FacePluginName, + new RecordKey(npc), BuildWarningId.FaceModExtraFaceGen, WarningMessages.FaceModExtraFaceGen(npc.EditorId, npc.Name, npc.FaceModName)); else if (hasLooseFacegen && hasArchiveFacegen) yield return new BuildWarning( npc.FacePluginName, + new RecordKey(npc), BuildWarningId.FaceModMultipleFaceGen, WarningMessages.FaceModMultipleFaceGen(npc.EditorId, npc.Name, npc.FaceModName)); } @@ -196,6 +206,7 @@ private static IEnumerable CheckWigs( .Where(x => x.Wig != null && (!enableDewiggify || !matchedWigKeys.Contains(x.Wig.Key))) .Select(x => enableDewiggify ? new BuildWarning( + new RecordKey(x.Npc), x.Wig.IsBald ? BuildWarningId.FaceModWigNotMatchedBald : BuildWarningId.FaceModWigNotMatched, x.Wig.IsBald ? WarningMessages.FaceModWigNotMatchedBald( diff --git a/Focus.Apps.EasyNpc/Build/BuildPage.xaml b/Focus.Apps.EasyNpc/Build/BuildPage.xaml index acff991..d67ac3d 100644 --- a/Focus.Apps.EasyNpc/Build/BuildPage.xaml +++ b/Focus.Apps.EasyNpc/Build/BuildPage.xaml @@ -113,7 +113,13 @@ - + diff --git a/Focus.Apps.EasyNpc/Build/BuildPage.xaml.cs b/Focus.Apps.EasyNpc/Build/BuildPage.xaml.cs index 8a71611..104ca5c 100644 --- a/Focus.Apps.EasyNpc/Build/BuildPage.xaml.cs +++ b/Focus.Apps.EasyNpc/Build/BuildPage.xaml.cs @@ -1,6 +1,6 @@ using System; using System.Windows; - +using System.Windows.Input; using TKey = Mutagen.Bethesda.Plugins.FormKey; namespace Focus.Apps.EasyNpc.Build @@ -56,5 +56,13 @@ private void SkipProblemsButton_Click(object sender, RoutedEventArgs e) { Model.IsProblemCheckerVisible = false; } + + private void WarningsListBox_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) + return; + if (WarningsListBox.SelectedItem is BuildWarning buildWarning && buildWarning.RecordKey != null) + Model.ExpandWarning(buildWarning); + } } } diff --git a/Focus.Apps.EasyNpc/Build/BuildViewModel.cs b/Focus.Apps.EasyNpc/Build/BuildViewModel.cs index 6fef15f..e9c9aa6 100644 --- a/Focus.Apps.EasyNpc/Build/BuildViewModel.cs +++ b/Focus.Apps.EasyNpc/Build/BuildViewModel.cs @@ -10,8 +10,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; namespace Focus.Apps.EasyNpc.Build @@ -20,6 +18,7 @@ public class BuildViewModel : INotifyPropertyChanged where TKey : struct { public event PropertyChangedEventHandler PropertyChanged; + public event EventHandler WarningExpanded; public bool EnableDewiggify { get; set; } = true; [DependsOn("Problems")] @@ -126,6 +125,13 @@ public void DismissProblems() IsReadyToBuild = true; } + public void ExpandWarning(BuildWarning warning) + { + // There isn't actually any notion of expansion in this context, aside from the info panel which is more of + // a "select" action. This just serves as a signal for an external component to handle the request. + WarningExpanded?.Invoke(this, warning); + } + public void OpenBuildOutput() { if (!Directory.Exists(OutputDirectory)) // In case user moved/deleted after the build diff --git a/Focus.Apps.EasyNpc/Build/BuildWarnings.cs b/Focus.Apps.EasyNpc/Build/BuildWarnings.cs index cee5bcf..d75802a 100644 --- a/Focus.Apps.EasyNpc/Build/BuildWarnings.cs +++ b/Focus.Apps.EasyNpc/Build/BuildWarnings.cs @@ -1,4 +1,5 @@ -using System; +using Focus.Apps.EasyNpc.GameData.Records; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -29,6 +30,7 @@ public class BuildWarning // Used for help links, if provided. public BuildWarningId? Id { get; init; } public string Message { get; init; } + public RecordKey RecordKey { get; init; } public string PluginName { get; init; } public BuildWarning() { } @@ -43,11 +45,22 @@ public BuildWarning(BuildWarningId id, string message) : this(message) Id = id; } + public BuildWarning(RecordKey key, BuildWarningId id, string message) : this(id, message) + { + RecordKey = key; + } + public BuildWarning(string pluginName, BuildWarningId id, string message) : this(id, message) { PluginName = pluginName; } + + public BuildWarning(string pluginName, RecordKey key, BuildWarningId id, string message) + : this(pluginName, id, message) + { + RecordKey = key; + } } public class BuildWarningIdsToTextConverter : IValueConverter diff --git a/Focus.Apps.EasyNpc/GameData/Records/Npc.cs b/Focus.Apps.EasyNpc/GameData/Records/Npc.cs index 0a2acb1..9e6988a 100644 --- a/Focus.Apps.EasyNpc/GameData/Records/Npc.cs +++ b/Focus.Apps.EasyNpc/GameData/Records/Npc.cs @@ -4,14 +4,12 @@ namespace Focus.Apps.EasyNpc.GameData.Records { - public interface INpc + public interface INpc : IRecordKey where TKey : struct { - string BasePluginName { get; } string EditorId { get; } bool IsFemale { get; } TKey Key { get; } - string LocalFormIdHex { get; } string Name { get; } IReadOnlyList> Overrides { get; } } diff --git a/Focus.Apps.EasyNpc/GameData/Records/RecordKey.cs b/Focus.Apps.EasyNpc/GameData/Records/RecordKey.cs new file mode 100644 index 0000000..0185036 --- /dev/null +++ b/Focus.Apps.EasyNpc/GameData/Records/RecordKey.cs @@ -0,0 +1,20 @@ +using System; + +namespace Focus.Apps.EasyNpc.GameData.Records +{ + public interface IRecordKey + { + string BasePluginName { get; } + string LocalFormIdHex { get; } + } + + public record RecordKey(string BasePluginName, string LocalFormIdHex) : IRecordKey + { + public RecordKey(IRecordKey key) : this(key.BasePluginName, key.LocalFormIdHex) { } + + public bool Equals(IRecordKey key) + { + return key.BasePluginName == BasePluginName && key.LocalFormIdHex == LocalFormIdHex; + } + } +} \ No newline at end of file diff --git a/Focus.Apps.EasyNpc/Main/MainViewModel.cs b/Focus.Apps.EasyNpc/Main/MainViewModel.cs index ecd0113..cbe36ce 100644 --- a/Focus.Apps.EasyNpc/Main/MainViewModel.cs +++ b/Focus.Apps.EasyNpc/Main/MainViewModel.cs @@ -21,6 +21,7 @@ public abstract class MainViewModel : IProfileContainer, ISettingsContainer where TKey : struct { + public event EventHandler PageNavigationRequested; public event PropertyChangedEventHandler PropertyChanged; public BuildViewModel Build { get; private set; } @@ -88,11 +89,19 @@ public MainViewModel(bool isFirstLaunch, bool debugMode) Build = new BuildViewModel( gameDataEditor.ArchiveProvider, buildChecker, gameDataEditor.MergedPluginBuilder, Loader.ModPluginMapFactory, Profile.GetAllNpcConfigurations(), wigResolver, faceGenEditor, Logger); + Build.WarningExpanded += BuildViewModel_WarningExpanded; IsLoaded = true; }; } protected abstract IGameDataEditor CreateEditor(); + + private void BuildViewModel_WarningExpanded(object sender, BuildWarning e) + { + var found = Profile.SelectNpc(e.RecordKey); + if (found) + PageNavigationRequested?.Invoke(this, "profile"); + } } public class MainViewModel : MainViewModel diff --git a/Focus.Apps.EasyNpc/Main/MainWindow.xaml.cs b/Focus.Apps.EasyNpc/Main/MainWindow.xaml.cs index c9c146f..f401fcf 100644 --- a/Focus.Apps.EasyNpc/Main/MainWindow.xaml.cs +++ b/Focus.Apps.EasyNpc/Main/MainWindow.xaml.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Windows; namespace Focus.Apps.EasyNpc.Main @@ -55,6 +56,7 @@ public MainWindow(MainViewModel model) model.Settings.WelcomeAcked += (sender, e) => (MainNavigationView.MenuItems[0] as NavigationViewItem).IsSelected = true; } + model.PageNavigationRequested += (sender, pageName) => SelectPage(pageName); } private void Navigate(string pageName) @@ -73,6 +75,16 @@ private void NavigationView_SelectionChanged(NavigationView sender, NavigationVi else Navigate((string)args.SelectedItemContainer.Tag); } + + private void SelectPage(string pageName) + { + var matchingItem = MainNavigationView.MenuItems + .OfType() + .Where(x => string.Equals(x.Tag?.ToString(), pageName, StringComparison.OrdinalIgnoreCase)) + .SingleOrDefault(); + if (matchingItem != null) + MainNavigationView.SelectedItem = matchingItem; + } } record NavLink(string Title, Type PageType, Func ViewModelSelector); diff --git a/Focus.Apps.EasyNpc/Profile/NpcConfiguration.cs b/Focus.Apps.EasyNpc/Profile/NpcConfiguration.cs index 9aecdef..8fac160 100644 --- a/Focus.Apps.EasyNpc/Profile/NpcConfiguration.cs +++ b/Focus.Apps.EasyNpc/Profile/NpcConfiguration.cs @@ -7,7 +7,7 @@ namespace Focus.Apps.EasyNpc.Profile { - public class NpcConfiguration : INotifyPropertyChanged + public class NpcConfiguration : INotifyPropertyChanged, IRecordKey where TKey : struct { private static readonly HashSet DlcPluginNames = new HashSet( diff --git a/Focus.Apps.EasyNpc/Profile/ProfileViewModel.cs b/Focus.Apps.EasyNpc/Profile/ProfileViewModel.cs index 7c0942a..983c761 100644 --- a/Focus.Apps.EasyNpc/Profile/ProfileViewModel.cs +++ b/Focus.Apps.EasyNpc/Profile/ProfileViewModel.cs @@ -24,7 +24,13 @@ public class ProfileViewModel : INotifyPropertyChanged [DependsOn("NpcConfigurations", "OnlyFaceOverrides", "ShowSinglePluginOverrides", "DisplayedNpcsSentinel")] public IEnumerable> DisplayedNpcs { - get { return ApplyFilters(GetAllNpcConfigurations()); } + get + { + var filteredNpcs = ApplyFilters(GetAllNpcConfigurations()); + return filterBypassNpc != null ? + new[] { filterBypassNpc }.Union(filteredNpcs) : + filteredNpcs; + } } public NpcFilters Filters { get; private init; } = new NpcFilters(); @@ -44,11 +50,12 @@ public IEnumerable> DisplayedNpcs // internal handler to force the DisplayedNpcs to update (just invert the value). protected bool DisplayedNpcsSentinel { get; private set; } + private NpcConfiguration filterBypassNpc; // One-time "ignore filter", used when jumping to an NPC private readonly IReadOnlySet loadedPluginNamesSet; - private readonly ProfileEventLog profileEventLog; private readonly IModPluginMapFactory modPluginMapFactory; private readonly Dictionary> npcConfigurations = new(); private readonly IReadOnlyList npcOrder; + private readonly ProfileEventLog profileEventLog; public ProfileViewModel( IEnumerable> npcs, IModPluginMapFactory modPluginMapFactory, @@ -158,12 +165,32 @@ public void SaveToFile(Window dialogOwner) savedProfile.SaveToFile(dialog.FileName); } + public bool SelectNpc(RecordKey key) + { + // At the moment, this doesn't get called often enough to justify having another dictionary with "our" key + // instead of the game editor's (e.g. Mutagen's) key. + var npcConfig = npcConfigurations.Values.SingleOrDefault(x => key.Equals(x)); + if (npcConfig != null) + { + SelectedNpc = npcConfig; + RefreshDisplayedNpcs(); + // Have to do this AFTER the refresh, so the refresh doesn't reset it. + filterBypassNpc = npcConfig; + } + return npcConfig != null; + } + public void SetFaceOverride(Mugshot mugshot, bool detectPlugin = false) { SelectedNpc?.SetFaceMod(mugshot?.ProvidingMod, detectPlugin); SyncMugshotMod(); } + protected void OnDisplayedNpcsSentinelChanged() + { + ClearFilterBypass(); + } + protected void OnFocusedMugshotChanged() { foreach (var overrideConfig in SelectedNpcOverrides ?? Enumerable.Empty>()) @@ -193,6 +220,16 @@ protected void OnFocusedNpcOverrideChanged() } } + protected void OnNpcConfigurationsChanged() + { + ClearFilterBypass(); + } + + protected void OnOnlyFaceOverridesChanged() + { + ClearFilterBypass(); + } + protected void OnSelectedNpcChanged(object before, object after) { var next = after as NpcConfiguration; @@ -211,6 +248,11 @@ protected void OnSelectedNpcChanged(object before, object after) next.FaceModChanged += OnNpcFaceModChanged; } + protected void OnShowSinglePluginOverridesChanged() + { + ClearFilterBypass(); + } + private static void ApplyColumnFilter( ref IEnumerable> npcs, DataGridColumn column, Func, string> propertySelector) @@ -262,8 +304,17 @@ private IEnumerable> ApplyFilters(IEnumerable x.GetOverrideCount(!Filters.NonDlc, !OnlyFaceOverrides) >= minOverrideCount); + return filterBypassNpc != null ? displayedNpcs.Union(new[] { filterBypassNpc }) : displayedNpcs; + } + + private void ClearFilterBypass() + { + if (filterBypassNpc == null) + return; + filterBypassNpc = null; + DisplayedNpcsSentinel = !DisplayedNpcsSentinel; } private void ClearNpcHighlights() @@ -292,6 +343,7 @@ private void OnNpcProfilePropertyChanged(object sender, ProfileEvent e) private void RefreshDisplayedNpcs() { + filterBypassNpc = null; DisplayedNpcsSentinel = !DisplayedNpcsSentinel; }