Merge restoration block 3 (sidebar UI) into v1.8.x track

This commit is contained in:
2026-06-10 17:00:51 +02:00
14 changed files with 888 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.8.4</Version>
<Version>1.8.5</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions -->
+51 -19
View File
@@ -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<Tab> 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<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds
) => SelectNotificationSound(tabs, currentTab, probe, playSounds);
internal class NameFormatting
{
internal string Before { get; private set; } = string.Empty;
+16
View File
@@ -91,6 +91,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
public static Configuration Config = null!;
public static FileDialogManager FileDialogManager { get; private set; } = null!;
// Single static handle to the live Plugin instance. Lets statically-accessed
// UI helpers (TabContextMenu) reach instance-only members — SaveConfig(),
// AutoTellTabsService, CustomAudioPlayer — without ctor-injection. A per-member
// static accessor is impossible: it would collide by name with the instance
// property (CS0102). Filled in the post-resolve bridge block below.
internal static Plugin Instance = null!;
public readonly WindowSystem WindowSystem = new(PluginName);
// Phase-2 services are constructed in LoadAsync; null! shape is kept
@@ -289,6 +296,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
);
_host = PluginHostFactory.Build(this, dependencies);
// Bridge the static handle before the instance members below are read.
Instance = this;
_lifecycle = _host.Services.GetRequiredService<PluginLifecycle>();
_lifecycle.Host = _host;
@@ -389,6 +400,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.DisclosureArmStep(this),
new SelfTests.TellRoutingBuildStep(this),
new SelfTests.TellPillTransparencyStep(this),
new SelfTests.TabRenamePersistStep(this),
new SelfTests.NotificationSoundSelectStep(),
new SelfTests.SidebarGreetedGlyphStep(this),
new SelfTests.SidebarSectionHeaderStep(this),
new SelfTests.ScrollSnapDecisionStep(this),
]);
// Re-surface the wizard for existing users when a major UX
@@ -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, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
EnableNotificationSound = enableSound,
NotificationSoundId = soundId,
};
public void CleanUp() { }
}
@@ -0,0 +1,59 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// B3-5: only the snap decision is headless-testable. Scroll detection + bar +
// hit-test are smoke-only (the scroll child exists only in-game; GetScrollY is
// garbage headless). Drives ResolveSnapToBottom via the SelfTest accessor and
// asserts the OR + the request reset invariant.
// Uses the mandatory RequestScrollToBottomForSelfTest() setter (added in Step 1)
// to flip _scrollToBottomRequested without a real click — REQUIRED for the reset
// invariant assert; without it only the OR branch is testable.
internal sealed class ScrollSnapDecisionStep : ISelfTestStep
{
private readonly Plugin plugin;
public ScrollSnapDecisionStep(Plugin plugin) => this.plugin = plugin;
public string Name => "Hellion Chat - Scroll snap decision";
public SelfTestStepResult RunStep()
{
var messages = plugin.MainWindow.GetMessageListForSelfTest();
if (messages is null)
{
ImGui.Text("MessageList null");
return SelfTestStepResult.Fail;
}
// Start-state hygiene: a real click this frame could leave a pending
// request behind. Drain it so the asserts below are order-independent.
// Acceptable side effect: the drained click is swallowed and its snap
// never happens — losing one click mid-selftest is irrelevant.
messages.ResolveSnapToBottom(false);
if (!messages.ResolveSnapToBottom(true))
{
ImGui.Text("pinnedToBottom=true must snap");
return SelfTestStepResult.Fail;
}
messages.RequestScrollToBottomForSelfTest();
if (!messages.ResolveSnapToBottom(false))
{
ImGui.Text("pending request must snap even when not pinned");
return SelfTestStepResult.Fail;
}
if (messages.ResolveSnapToBottom(false))
{
ImGui.Text("request must be consumed by one snap (reset invariant)");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -0,0 +1,103 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
namespace HellionChat.SelfTests;
// B3-2: greeted glyph renders only for temp tabs when the toggle is on. Drives
// the REAL Sidebar.Draw (render precedent: HonorificHeaderRenderStep, the only
// real .Draw in this pool — NOT SidebarModeAutoSwitchStep which only calls
// IsExpanded/GetWidth) inside the /xlperf window frame and reads the render
// observability counter, then drives the real toggle hook both ways. Injects
// a temp tab and restores config in finally.
internal sealed class SidebarGreetedGlyphStep : ISelfTestStep
{
private readonly Plugin plugin;
public SidebarGreetedGlyphStep(Plugin plugin) => this.plugin = plugin;
public string Name => "Hellion Chat - Sidebar greeted glyph";
public SelfTestStepResult RunStep()
{
var sidebar = plugin.MainWindow.GetSidebarForSelfTest();
if (sidebar is null)
{
ImGui.Text("Sidebar null");
return SelfTestStepResult.Fail;
}
var savedFlag = Plugin.Config.AutoTellTabsShowGreetedToggle;
var savedSidebarWidth = Plugin.Config.SidebarWidth;
// Mirror of AutoTellTabsService.BuildTempTab (the real builder is
// private); only the sheet-based tab name is replaced with a literal.
var injected = new Tab
{
Name = "Greeted Probe@SelfTest",
IsTempTab = true,
AllSenderMessages = true,
TellTarget = new TellTarget("Greeted Probe", 0, 0, TellReason.Direct),
Channel = InputChannel.Tell,
DisplayTimestamp = true,
UnreadMode = UnreadMode.Unseen,
HideWhenInactive = false,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
Plugin.Config.Tabs.Add(injected);
Tab? active = null;
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
try
{
Plugin.Config.AutoTellTabsShowGreetedToggle = true;
// Default SidebarWidth (44px) has no room for the third hit area;
// pin a wide value so the glyph branch is reachable, restore after.
Plugin.Config.SidebarWidth = 220;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastRenderedGreetedGlyphCount == 0)
{
ImGui.Text("No greeted glyph drawn with flag ON");
return SelfTestStepResult.Fail;
}
Plugin.Config.AutoTellTabsShowGreetedToggle = false;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastRenderedGreetedGlyphCount != 0)
{
ImGui.Text("Greeted glyph drawn with flag OFF");
return SelfTestStepResult.Fail;
}
// Drive the same hook DrawRow's click handler uses (the real toggle
// path, not a direct MarkGreeted call) and assert the flip both ways.
sidebar.ToggleGreetedForSelfTest(injected);
if (!plugin.AutoTellTabsService.IsGreeted(injected))
{
ImGui.Text("Toggle did not mark the tab greeted");
return SelfTestStepResult.Fail;
}
sidebar.ToggleGreetedForSelfTest(injected);
if (plugin.AutoTellTabsService.IsGreeted(injected))
{
ImGui.Text("Toggle did not unmark the tab greeted");
return SelfTestStepResult.Fail;
}
}
finally
{
Plugin.Config.Tabs.Remove(injected);
Plugin.Config.AutoTellTabsShowGreetedToggle = savedFlag;
Plugin.Config.SidebarWidth = savedSidebarWidth;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -0,0 +1,118 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
namespace HellionChat.SelfTests;
// B3-4: section headers render once per non-empty temp-tab pool, and compact
// mode suppresses the header text (separators stay). Drives the REAL
// Sidebar.Draw inside the /xlperf window frame (same render precedent as
// SidebarGreetedGlyphStep) and reads the render observability counter.
// Injects a mixed tab set (persistent + unpinned temp + pinned temp) and
// restores config in finally.
internal sealed class SidebarSectionHeaderStep : ISelfTestStep
{
private readonly Plugin plugin;
public SidebarSectionHeaderStep(Plugin plugin) => this.plugin = plugin;
public string Name => "Hellion Chat - Sidebar section header";
public SelfTestStepResult RunStep()
{
var sidebar = plugin.MainWindow.GetSidebarForSelfTest();
if (sidebar is null)
{
ImGui.Text("Sidebar null");
return SelfTestStepResult.Fail;
}
var savedCompact = Plugin.Config.AutoTellTabsCompactDisplay;
var savedSidebarWidth = Plugin.Config.SidebarWidth;
// Both headers need a populated pool behind them. Persistent tabs
// normally already exist — inject a probe only when the live config
// has none, so the section order has a real first section.
var injected = new List<Tab>();
if (Plugin.Config.Tabs.All(t => t.IsTempTab))
{
injected.Add(
new Tab
{
Name = "Persistent Probe@SelfTest",
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
}
);
}
injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false));
injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true));
foreach (var tab in injected)
Plugin.Config.Tabs.Add(tab);
Tab? active = null;
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
try
{
// Headers are not width-gated, but the pinned width keeps the step
// uniform with SidebarGreetedGlyphStep (expanded rows, no min-drag
// row drops while the probes render).
Plugin.Config.SidebarWidth = 220;
Plugin.Config.AutoTellTabsCompactDisplay = false;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastDrawnSectionHeaderCount != 2)
{
ImGui.Text(
$"Expected 2 section headers with compact OFF, got {sidebar.LastDrawnSectionHeaderCount}"
);
return SelfTestStepResult.Fail;
}
Plugin.Config.AutoTellTabsCompactDisplay = true;
sidebar.Draw(width, Plugin.Config.Tabs, ref active);
if (sidebar.LastDrawnSectionHeaderCount != 0)
{
ImGui.Text(
$"Compact ON must suppress header text, got {sidebar.LastDrawnSectionHeaderCount}"
);
return SelfTestStepResult.Fail;
}
}
finally
{
foreach (var tab in injected)
Plugin.Config.Tabs.Remove(tab);
Plugin.Config.AutoTellTabsCompactDisplay = savedCompact;
Plugin.Config.SidebarWidth = savedSidebarWidth;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
// Mirror of AutoTellTabsService.BuildTempTab (the real builder is
// private); only the sheet-based tab name is replaced with a literal.
private static Tab BuildTempProbe(string name, bool pinned) =>
new()
{
Name = name,
IsTempTab = true,
IsPinned = pinned,
AllSenderMessages = true,
TellTarget = new TellTarget(name, 0, 0, TellReason.Direct),
Channel = InputChannel.Tell,
DisplayTimestamp = true,
UnreadMode = UnreadMode.Unseen,
HideWhenInactive = false,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}
@@ -0,0 +1,59 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Ui.Components;
namespace HellionChat.SelfTests;
// B3-1: rename must persist. Drives the real ApplyTabRename (the InputText
// callback path), then SaveConfig + reload from disk and asserts the new name
// survived — a fresh-from-config tab, not the same reference (a reference check
// would pass on a dead roundtrip). Uses a persistent (non-temp) tab: unpinned
// temp tabs are stripped on save (ShouldStripOnSave) and would not survive.
internal sealed class TabRenamePersistStep : ISelfTestStep
{
private readonly Plugin plugin;
public TabRenamePersistStep(Plugin plugin) => this.plugin = plugin;
public string Name => "Hellion Chat - Tab rename persists";
public SelfTestStepResult RunStep()
{
var tab = Plugin.Config.Tabs.FirstOrDefault(t => !t.IsTempTab);
if (tab is null)
{
ImGui.Text("No persistent tab to rename");
return SelfTestStepResult.Fail;
}
var original = tab.Name;
var probe = original + "##selftest";
try
{
if (!TabContextMenu.ApplyTabRename(tab, probe))
{
ImGui.Text("ApplyTabRename reported no change");
return SelfTestStepResult.Fail;
}
plugin.SaveConfig();
// Reload from disk into a throwaway config; assert the new name landed.
var reloaded = Plugin.Interface.GetPluginConfig() as Configuration;
var match = reloaded?.Tabs.Any(t => t.Name == probe) ?? false;
if (!match)
{
ImGui.Text("Renamed tab not found after reload");
return SelfTestStepResult.Fail;
}
}
finally
{
tab.Name = original;
plugin.SaveConfig();
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
+81 -2
View File
@@ -1,7 +1,8 @@
using System.Globalization;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Utility;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
@@ -20,6 +21,12 @@ internal sealed class MessageList
private PayloadHandler? _handler;
// B3-5: scroll-to-bottom state. Per-instance, so pop-out windows (own
// MessageList instance, PluginHostFactory.cs:263-266) isolate automatically —
// the old 1.5.6 updateScrollState flag is NOT needed here.
private bool _scrolledUp;
private bool _scrollToBottomRequested;
// §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle.
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
internal void AttachPayloadHandler(PayloadHandler handler)
@@ -33,6 +40,20 @@ internal sealed class MessageList
_chunkRenderer = chunkRenderer;
}
// Deterministic and ImGui-free: encapsulates the snap decision AND the
// request reset, so the reset invariant is covered. Called by the real Draw.
internal bool ResolveSnapToBottom(bool pinnedToBottom)
{
var snap = pinnedToBottom || _scrollToBottomRequested;
_scrollToBottomRequested = false;
return snap;
}
// SelfTest hook (B3-5 reset-invariant, REQUIRED — not optional). Lets
// ScrollSnapDecisionStep flip the request flag without a real click, so the
// post-snap reset can be asserted; without it only the OR branch is testable.
internal void RequestScrollToBottomForSelfTest() => _scrollToBottomRequested = true;
public void Draw(Tab tab)
{
if (!_fonts.FontsReady)
@@ -57,13 +78,71 @@ internal sealed class MessageList
else
DrawCard(tab, messages);
if (pinnedToBottom)
// B3-5: scroll values are frame-constant inside the child, so this
// reflects the current frame's state wherever it runs; kept after the
// render to mirror the 1.5.6 end-of-DrawMessageLog placement.
_scrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f;
if (ResolveSnapToBottom(pinnedToBottom))
ImGui.SetScrollHereY(1f);
DrawScrollToBottomBar();
// OpenPopup in Click() and BeginPopup here share the ##hellion-main-area scope -> Popup-ID matches.
_handler?.Draw();
}
// B3-5: Discord-style full-width bar pinned to the bottom edge of the
// visible region while the user is scrolled up. Geometry comes from window
// pos + size (visible region), never from the content flow: when scrolled
// up the visible bottom sits above the content bottom, so the
// InvisibleButton stays inside the existing content rect and cannot grow
// GetScrollMaxY(). Drawn on the WINDOW drawlist so the enclosing child
// clips it; submitted after every payload chunk so the button wins the
// hit-test and PostPayload clicks underneath do not double-fire.
private void DrawScrollToBottomBar()
{
if (!_scrolledUp)
return;
var winPos = ImGui.GetWindowPos();
var winSize = ImGui.GetWindowSize();
var barHeight = ImGui.GetFrameHeight();
// The bar only renders while content overflows, so the vertical
// scrollbar is always up — keep the bar clear of it.
var barWidth = winSize.X - ImGui.GetStyle().ScrollbarSize;
var barTopLeft = new Vector2(winPos.X, winPos.Y + winSize.Y - barHeight);
var barBottomRight = barTopLeft + new Vector2(barWidth, barHeight);
var theme = Plugin.Instance.ThemeRegistry.Active;
var hovered = ImGui.IsMouseHoveringRect(barTopLeft, barBottomRight);
var fill = ColourUtil.RgbaToAbgr(
hovered ? theme.Colors.SurfaceHover : theme.Colors.Surface
);
var rounding = 4f * ImGuiHelpers.GlobalScale;
var dl = ImGui.GetWindowDrawList();
dl.AddRectFilled(barTopLeft, barBottomRight, fill, rounding);
dl.AddRect(
barTopLeft,
barBottomRight,
ColourUtil.RgbaToAbgr(theme.Colors.Border),
rounding
);
var label = HellionStrings.ChatLog_ScrollToBottom_Tooltip;
var textSize = ImGui.CalcTextSize(label);
var textPos =
barTopLeft + new Vector2((barWidth - textSize.X) / 2f, (barHeight - textSize.Y) / 2f);
dl.AddText(textPos, ColourUtil.RgbaToAbgr(theme.Colors.Accent), label);
// Click target after the visuals; nothing advances the cursor past the
// button, so content height is identical with and without the bar.
ImGui.SetCursorScreenPos(barTopLeft);
ImGui.InvisibleButton("##scroll-to-bottom-bar", new Vector2(barWidth, barHeight));
if (ImGui.IsItemClicked())
_scrollToBottomRequested = true;
}
private void DrawCompact(IReadOnlyList<Message> messages)
{
unsafe
+145 -9
View File
@@ -3,6 +3,7 @@ using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
@@ -28,6 +29,16 @@ internal sealed class Sidebar
private const float RowHeight = 32f;
private const float PopOutHitWidth = 22f;
private const float GreetedHitWidth = 22f;
// B3-2 render observability: counts greeted glyphs actually drawn this frame.
// Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start.
// The SelfTest reads it after driving the real Draw — no dead service roundtrip.
internal int LastRenderedGreetedGlyphCount;
// B3-4 render observability: section headers actually drawn this frame.
// Incremented only in the real header branch; reset at Draw start.
internal int LastDrawnSectionHeaderCount;
// Inline mirror of the old TabIconMapping table so the Ui layer carries
// its own glyph lookup once the standalone file is removed.
@@ -81,8 +92,21 @@ internal sealed class Sidebar
? Math.Clamp((float)Plugin.Config.SidebarWidth, MinSidebarWidth, MaxSidebarWidth)
: IconOnlyWidth;
// Factored click logic so the SelfTest exercises the real toggle, not a direct
// MarkGreeted call (which would be a dead path the render never takes).
internal void ToggleGreetedForSelfTest(Tab tab)
{
if (Plugin.Instance.AutoTellTabsService.IsGreeted(tab))
Plugin.Instance.AutoTellTabsService.UnmarkGreeted(tab);
else
Plugin.Instance.AutoTellTabsService.MarkGreeted(tab);
}
public void Draw(float windowWidth, IList<Tab> tabs, ref Tab? activeTab)
{
LastRenderedGreetedGlyphCount = 0;
LastDrawnSectionHeaderCount = 0;
if (!_fonts.FontsReady)
{
ImGui.Dummy(new Vector2(IconOnlyWidth, 0));
@@ -99,10 +123,77 @@ internal sealed class Sidebar
var accentRgba = _resolver.Resolve(Token.AccentPrimary, theme.Colors);
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim);
var dl = ImGui.GetWindowDrawList();
// B3-4 sectioned render order (1.5.6 parity): persistent → pinned
// TempTabs → unpinned TempTabs. Only the display sequence regroups;
// the tab list itself stays untouched and every row keeps its
// ORIGINAL list index for PushID, so an open context-menu popup
// stays bound to its tab when sectioning moves it visually.
var renderOrder = BuildRenderOrder(tabs);
var pinnedHeaderRendered = false;
var unpinnedHeaderRendered = false;
foreach (var i in renderOrder)
{
var tab = tabs[i];
if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.PinTab_SectionHeader,
Plugin.Instance.AutoTellTabsService.PinnedTempTabCount
);
pinnedHeaderRendered = true;
}
else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !unpinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.AutoTellTabs_SectionHeader,
Plugin.Instance.AutoTellTabsService.ActiveTempTabCount
);
unpinnedHeaderRendered = true;
}
DrawRow(tab, i, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab);
}
}
// Section transition marker (1.5.6 parity): the separator always renders,
// compact mode suppresses only the header text. Real cursor-advancing
// widgets on purpose — rows advance the cursor via InvisibleButton, so a
// drawlist-only header would overlap the next row.
private void DrawSectionHeader(string header, int count)
{
ImGui.Separator();
if (Plugin.Config.AutoTellTabsCompactDisplay)
return;
ImGui.TextDisabled($"{header} ({count})");
LastDrawnSectionHeaderCount++;
}
// Mirror of 1.5.6's BuildSidebarRenderOrder: returns indices into the
// live tab list grouped by section, so the list order itself is never
// mutated and headers gate on the first tab actually reached per pool
// (an empty pool draws neither separator nor header).
private static List<int> BuildRenderOrder(IList<Tab> tabs)
{
var persistent = new List<int>(tabs.Count);
var pinned = new List<int>();
var unpinned = new List<int>();
for (var i = 0; i < tabs.Count; i++)
DrawRow(tabs[i], i, expanded, accentRgba, textAbgr, mutedAbgr, dl, ref activeTab);
{
if (TabLifecycleHelpers.IsInPinnedPool(tabs[i]))
pinned.Add(i);
else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i]))
unpinned.Add(i);
else
persistent.Add(i);
}
persistent.AddRange(pinned);
persistent.AddRange(unpinned);
return persistent;
}
private void DrawRow(
@@ -112,6 +203,7 @@ internal sealed class Sidebar
uint accentRgba,
uint textAbgr,
uint mutedAbgr,
uint dimAbgr,
ImDrawListPtr dl,
ref Tab? activeTab
)
@@ -130,11 +222,26 @@ internal sealed class Sidebar
return;
}
// 1.5.6 parity: greeted state dims the tab icon whenever the toggle is
// configured on. The clickable affordance additionally needs an expanded
// sidebar with room for a third hit area beside the pop-out slot — in
// icon-only or min-drag mode it is skipped entirely.
var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle;
var showGreeted =
greetedConfigured && expanded && avail > GreetedHitWidth + PopOutHitWidth + 4f;
// Only split off a separate pop-out hit area when there's room for
// both buttons. Below that, the whole row stays as a single
// selectable strip without the pop-out affordance.
var hasPopOut = avail > PopOutHitWidth + 4f;
var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail;
if (showGreeted)
{
// Greeted slot sits at the left edge (1.5.6 placement); the row
// button starts after it so the three hit areas never overlap.
tabHitWidth -= GreetedHitWidth;
ImGui.SetCursorScreenPos(origin + new Vector2(GreetedHitWidth, 0f));
}
ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight));
var rowHovered = ImGui.IsItemHovered();
@@ -153,18 +260,27 @@ internal sealed class Sidebar
);
var icon = ResolveTabIcon(tab);
// Dim precedence (1.5.6): the active tab always keeps its regular
// color; only greeted, non-active tabs drop to TextDim.
var isCurrentTab = tab == activeTab;
var iconColor = textAbgr;
if (
!isCurrentTab
&& greetedConfigured
&& Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
)
iconColor = dimAbgr;
// Icon and label shift right by the greeted slot when it is shown.
var contentX = showGreeted ? GreetedHitWidth : 0f;
using (_fonts.FontAwesome.Push())
dl.AddText(origin + new Vector2(10f, 8f), textAbgr, icon.ToIconString());
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, icon.ToIconString());
if (expanded)
dl.AddText(origin + new Vector2(32f, 8f), textAbgr, tab.Name);
dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name);
if (ImGui.BeginPopupContextItem("ctx"))
{
if (ImGui.MenuItem("Pop Out"))
_pool.TryOpen(tab);
ImGui.EndPopup();
}
TabContextMenu.Draw(tab, "ctx", _pool);
var popHovered = false;
if (hasPopOut)
@@ -185,6 +301,26 @@ internal sealed class Sidebar
}
}
if (showGreeted)
{
// The hit area sits at the LEFT edge of the row, but the item must
// be submitted AFTER TabContextMenu.Draw — any interactive item
// between the row button and the popup call would steal the
// right-click trigger (B3-1 ordering constraint).
ImGui.SetCursorScreenPos(origin);
ImGui.InvisibleButton("greeted", new Vector2(GreetedHitWidth, RowHeight));
if (ImGui.IsItemClicked())
ToggleGreetedForSelfTest(tab);
// CheckCircle = greeted, plain Check = still pending (1.5.6 mapping).
var greetedGlyph = Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
? FontAwesomeIcon.CheckCircle
: FontAwesomeIcon.Check;
using (_fonts.FontAwesome.Push())
dl.AddText(origin + new Vector2(4f, 8f), mutedAbgr, greetedGlyph.ToIconString());
LastRenderedGreetedGlyphCount++;
}
ImGui.PopID();
}
+144
View File
@@ -0,0 +1,144 @@
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;
// Shared right-click menu for both tab layouts (Sidebar rows + TopTabBar). One
// source of truth instead of two divergent inline blocks. Static: it has no own
// state and reaches the live Config/Plugin through Plugin.Instance/Plugin.Config.
internal static class TabContextMenu
{
// MUST be called immediately after the row-carrying ImGui item (Sidebar
// "row" InvisibleButton / TopTabBar Selectable). popupId only names the
// popup; the open trigger is a right-click on the LAST submitted item
// (g.LastItemData via IsItemHovered) — any interactive item in between
// would steal the trigger. Only DrawList ops may sit between.
public static void Draw(Tab tab, string popupId, Windows.ChannelPopoutPool pool)
{
if (!ImGui.BeginPopupContextItem(popupId))
return;
// Rename: focus the field the first frame the popup appears.
if (ImGui.IsWindowAppearing())
ImGui.SetKeyboardFocusHere();
ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
var name = tab.Name;
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)
{
if (string.IsNullOrEmpty(newName) || newName == tab.Name)
return false;
tab.Name = newName;
return true;
}
}
+1 -6
View File
@@ -38,12 +38,7 @@ internal sealed class TopTabBar
TabLifecycleHelpers.EnsureCurrentChannel(tab);
}
if (ImGui.BeginPopupContextItem($"toptab_ctx_{i}"))
{
if (ImGui.MenuItem("Pop Out"))
_pool.TryOpen(tab);
ImGui.EndPopup();
}
TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool);
}
ImGui.Separator();
+2
View File
@@ -125,6 +125,8 @@ internal sealed class MainWindow : Window
internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific;
internal Components.MessageList GetMessageListForSelfTest() => _messages;
// new-shadow on Window.Toggle so the open path also writes Config —
// OnClose already covers the close path through the base behaviour.
public new void Toggle()
+2 -2
View File
@@ -3,7 +3,7 @@
"Author": "Jon Kazama (Hellion Forge)",
"Name": "Hellion Chat",
"InternalName": "HellionChat",
"AssemblyVersion": "1.8.4.0",
"AssemblyVersion": "1.8.5.0",
"Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR",
"ApplicableVersion": "any",
"RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat",
@@ -25,7 +25,7 @@
"DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
"TestingAssemblyVersion": "1.8.4.0",
"TestingAssemblyVersion": "1.8.5.0",
"IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png",
"ImageUrls": [
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",