diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index 965d556..3838632 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -332,7 +332,6 @@ internal class MessageManager : IAsyncDisposable Store.UpsertMessage(message); var currentMatches = Plugin.CurrentTab.Matches(message); - uint? notificationSound = null; foreach (var tab in Plugin.Config.Tabs) { var unread = !( @@ -340,27 +339,19 @@ internal class MessageManager : IAsyncDisposable ); if (tab.Matches(message)) - { tab.AddMessage(message, unread); - - // Per-tab notification sound. Fire once for the first inactive - // tab that wants it, keeping a message matching several - // background tabs from stacking sounds. - // TEST-MIRROR: ../_Helpers/TabSoundDecision.cs - if ( - notificationSound is null - && TabSoundDecision.ShouldPlay( - Plugin.CurrentTab == tab, - tab.EnableNotificationSound, - Plugin.Config.PlaySounds - ) - ) - { - notificationSound = tab.NotificationSoundId; - } - } } + // Deliberate O(2n): the sound pick re-walks the tab list so the selection + // stays pure and SelfTest-able; AddMessage above and playback below keep + // the side effects. + var notificationSound = SelectNotificationSound( + Plugin.Config.Tabs, + Plugin.CurrentTab, + message, + Plugin.Config.PlaySounds + ); + if (notificationSound is { } soundId) { if (soundId is >= 1 and <= 16) @@ -388,6 +379,47 @@ internal class MessageManager : IAsyncDisposable MessageProcessed?.Invoke(message); } + // Pure: picks the sound id for the first inactive tab that wants one, or null. + // No AddMessage, no store write — those stay in the ProcessMessage loop so this + // is exercisable from the SelfTest without polluting tab state. The "first + // match wins" semantics live here via the running 'picked is null' guard, + // keeping a message matching several background tabs from stacking sounds. + // TEST-MIRROR: ../_Helpers/TabSoundDecision.cs + internal static uint? SelectNotificationSound( + IEnumerable tabs, + Tab currentTab, + Message probe, + bool playSounds + ) + { + uint? picked = null; + foreach (var tab in tabs) + { + if (!tab.Matches(probe)) + continue; + if ( + picked is null + && TabSoundDecision.ShouldPlay( + currentTab == tab, + tab.EnableNotificationSound, + playSounds + ) + ) + { + picked = tab.NotificationSoundId; + } + } + return picked; + } + + // SelfTest hook — same name discipline as InputBar.TestBuildOutgoingForSelfTest. + internal static uint? TestSelectNotificationSoundForSelfTest( + IEnumerable tabs, + Tab currentTab, + Message probe, + bool playSounds + ) => SelectNotificationSound(tabs, currentTab, probe, playSounds); + internal class NameFormatting { internal string Before { get; private set; } = string.Empty; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index b4f4bfd..c26c306 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -401,6 +401,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.TellRoutingBuildStep(this), new SelfTests.TellPillTransparencyStep(this), new SelfTests.TabRenamePersistStep(this), + new SelfTests.NotificationSoundSelectStep(), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/NotificationSoundSelectStep.cs b/HellionChat/SelfTests/NotificationSoundSelectStep.cs new file mode 100644 index 0000000..7009fdf --- /dev/null +++ b/HellionChat/SelfTests/NotificationSoundSelectStep.cs @@ -0,0 +1,106 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// B3-3: notification-sound selection. Drives the pure SelectNotificationSound +// (the exact pick logic ProcessMessage runs per message) through its SelfTest +// wrapper with local synthetic tabs — Plugin.Config.Tabs is never touched, so +// no real tab gains messages or unread state. The audible preview button is +// smoke-only and deliberately not exercised here. +internal sealed class NotificationSoundSelectStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Notification sound selection"; + + public SelfTestStepResult RunStep() + { + // Probe: a plain Say line, built the FakeMessage way (InputPreview / + // AutoTellTabsService pattern). Source 0 short-circuits the source + // filter in Message.Matches, so only the ChatType key decides a match. + var ss = new SeStringBuilder().AddText("probe").Build(); + var chunks = ChunkUtil.ToChunks(ss, ChunkSource.Content, ChatType.Say).ToList(); + var probe = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0)); + + // The current tab wants a sound too — it must lose ONLY because it is + // current, so a broken is-active exclusion yields 1 instead of 7 here. + var currentTab = MakeSayTab(enableSound: true, soundId: 1); + var inactiveWanting = MakeSayTab(enableSound: true, soundId: 7); + + // (a) the inactive tab that wants a sound wins. + var picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting], + currentTab, + probe, + playSounds: true + ); + if (picked != 7) + { + ImGui.Text($"Expected sound 7 from inactive tab, got {picked?.ToString() ?? "null"}"); + return SelfTestStepResult.Fail; + } + + // (b) first match wins: a later qualifying tab must not override. + var second = MakeSayTab(enableSound: true, soundId: 9); + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting, second], + currentTab, + probe, + playSounds: true + ); + if (picked != 7) + { + ImGui.Text($"First-match guard broken: expected 7, got {picked?.ToString() ?? "null"}"); + return SelfTestStepResult.Fail; + } + + // (c) the global sound master mutes everything. + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting], + currentTab, + probe, + playSounds: false + ); + if (picked is not null) + { + ImGui.Text($"PlaySounds=false must return null, got {picked}"); + return SelfTestStepResult.Fail; + } + + // (d) negative: a tab without the Say channel never matches the probe. + var nonMatching = new Tab { EnableNotificationSound = true, NotificationSoundId = 7 }; + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, nonMatching], + currentTab, + probe, + playSounds: true + ); + if (picked is not null) + { + ImGui.Text($"Non-matching tab must not pick a sound, got {picked}"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + // Local synthetic tab matching Say, the way TabsUtil presets build their + // channel maps. Non-temp and without TellTarget, so Tab.Matches stays on + // the pure channel path instead of routing through MatchesSender. + private static Tab MakeSayTab(bool enableSound, uint soundId) => + new() + { + Name = "selftest-sound", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + EnableNotificationSound = enableSound, + NotificationSoundId = soundId, + }; + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 70939c3..3acb9e8 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -1,5 +1,9 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface; using Dalamud.Interface.Utility; +using FFXIVClientStructs.FFXIV.Client.UI; +using HellionChat.Resources; +using HellionChat.Util; namespace HellionChat.Ui.Components; @@ -26,12 +30,108 @@ internal static class TabContextMenu if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name)) Plugin.Instance.SaveConfig(); + // Per-tab notification sound (B3-3). The checkbox gates the picker so + // tabs that never want a sound keep the popup short. + if ( + ImGui.Checkbox( + HellionStrings.Tabs_NotificationSound_Enable_Name, + ref tab.EnableNotificationSound + ) + ) + Plugin.Instance.SaveConfig(); + ImGuiUtil.HelpMarker(HellionStrings.Tabs_NotificationSound_Description); + if (tab.EnableNotificationSound) + DrawSoundPicker(tab); + if (ImGui.MenuItem("Pop Out")) pool.TryOpen(tab); ImGui.EndPopup(); } + // Sound picker: 16 numbered game sounds, a separator, then the 3 bundled + // Hellion clips stored as ids 17-19 (1.5.6 parity order). The collapsed + // preview reuses the entry label scheme so the current pick reads the same + // open or closed. + private static void DrawSoundPicker(Tab tab) + { + var preview = + tab.NotificationSoundId <= 16 + ? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}" + : $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}"; + using ( + var combo = ImGuiUtil.BeginComboVertical( + HellionStrings.Tabs_NotificationSound_Option, + preview + ) + ) + { + if (combo.Success) + { + for (uint s = 1; s <= 16; s++) + { + if ( + ImGui.Selectable( + $"{HellionStrings.Tabs_NotificationSound_Option} {s}", + tab.NotificationSoundId == s + ) + ) + { + tab.NotificationSoundId = s; + Plugin.Instance.SaveConfig(); + } + } + + ImGui.Separator(); + + for (uint n = 1; n <= 3; n++) + { + var customId = 16 + n; + if ( + ImGui.Selectable( + $"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}", + tab.NotificationSoundId == customId + ) + ) + { + tab.NotificationSoundId = customId; + Plugin.Instance.SaveConfig(); + } + } + } + } + + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.Play, + "tab-sound-preview", + HellionStrings.Tabs_NotificationSound_Preview + ) + ) + PreviewSound(tab.NotificationSoundId); + } + + // Preview: 1-16 are game UI sounds (must hit the framework thread); 17+ are + // custom NAudio clips (own playback thread). Open range >= 17 (not 17-19); the + // 3-clip ceiling is guarded inside CustomAudioPlayer. + private static void PreviewSound(uint id) + { + if (id is >= 1 and <= 16) + { + Plugin.Framework.RunOnFrameworkThread(() => + { + unsafe + { + UIGlobals.PlaySoundEffect(id); + } + }); + } + else if (id >= 17) + { + Plugin.Instance.CustomAudioPlayer.Play((int)id - 16, Plugin.Config.CustomSoundVolume); + } + } + // Factored out so the SelfTest drives the real rename path, not a field poke. // Returns true when the name actually changed (gates the SaveConfig write). internal static bool ApplyTabRename(Tab tab, string newName)