diff --git a/.github/forge-posts/v1.2.0.md b/.github/forge-posts/v1.2.0.md new file mode 100644 index 0000000..eded536 --- /dev/null +++ b/.github/forge-posts/v1.2.0.md @@ -0,0 +1,17 @@ +--- +subtitle: "Layout Refresh" +versionsnatur: "Major-UI-Cycle" +--- +- Sidebar im modernisiertem Layout: nur noch Icons in fixer 44 px Breite, Tab-Name als Tooltip beim Hover, vertikale Akzent-Pill markiert den aktiven Tab +- Top-Tabs bekommen eine 2 px Akzent-Underline am unteren Rand statt Background-Fill für den aktiven Tab +- Pro Tab eigenes Icon zuweisbar via Settings → Tabs (15 FontAwesome-Glyphen-Pool) +- Bottom-Status-Bar (22 px) zeigt fünf Live-Signale: aktiver Channel mit Color-Dot, Privacy-Badge, Tab- und Message-Counter, Auto-Tell-Counter, Plugin-Version. Update einmal pro Sekunde, gecached +- Card-Rows als Default-Layout für Messages: Sender-Header in Channel-Farbe, Body auf eigener Zeile, dezenter Trenner zwischen den Karten +- `Compact Density`-Toggle in Aussehen schaltet zurück auf den klassischen Einzeiler `[HH:mm] Sender: Text` +- Auto-Tell-Tabs unterscheiden sich jetzt visuell: jeder Tell-Partner bekommt ein eigenes Icon (envelope/star/heart/bell/bookmark/flag/fire) plus eigene Farbe aus 12-Farb-Palette — 84 Icon-Farb-Kombinationen, gleicher Partner ergibt konsistent dieselbe Kombination +- Pulsierender roter Dot oben rechts am Sidebar-Icon wenn ein Tab ungelesene Nachrichten hat. Sanft, 2-Sekunden-Cycle, lässt sich über `Configuration.ReduceMotion` deaktivieren (UI-Toggle kommt in v1.3.0) +- Migration v14 → v15: alte `HellionThemeEnabled` und `HellionThemeWindowOpacity` Konfigurationsfelder entfernt, alle anderen Settings bleiben erhalten +- Bug-Fix: Settings speichern zerstört nicht mehr den Chat-Verlauf. Der schwere Refilter-Cycle läuft jetzt nur noch wenn sich Filter-relevante Settings tatsächlich geändert haben (Privacy-Filter, gemerkte Channels, Tab-Channel-Auswahl) — Cosmetic-Änderungen wie Theme oder Tab-Icons lassen den Chat unverändert. Persistente Tabs und Auto-Tell-Tabs überleben beide +- Bug-Fix: Sidebar-Buttons sitzen jetzt vertikal in einer Linie mit der ersten Message-Zeile, Status-Bar-Versionsname wird vollständig angezeigt + +Animation-Polish (Lerps, Theme-Crossfade, Header-Quick-Picker) folgt in v1.3.0. v1.2.0 ist bewusst Hard-Switch — sauberes Layout zuerst, Bewegung später. diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index da3b4d6..091e94f 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -34,7 +34,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - private const int LatestVersion = 14; + private const int LatestVersion = 15; public int Version { get; set; } = LatestVersion; @@ -80,19 +80,6 @@ public class Configuration : IPluginConfiguration // ChatTwo users skip it because the v6→v7 migration sets the flag. public bool FirstRunCompleted; - // Hellion Chat global ImGui theme — applied to every plugin window in - // Plugin.Draw. Default ON; users who prefer the upstream Dalamud look - // can flip this off in the Privacy tab. - [Obsolete("Replaced by Theme slug + WindowOpacity in v14")] - public bool HellionThemeEnabled = true; - - // Window background opacity, 0.5–1.0. Lower values make the plugin - // panes more glass-like so the game shines through. Default 0.5 - // matches the maintainer's daily-driver preference; users who want - // a less translucent look bump it up in Aussehen → Theme. - [Obsolete("Replaced by WindowOpacity in v14")] - public float HellionThemeWindowOpacity = 0.5f; - // Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font // instead of whatever GlobalFontV2.FontId points at. Default ON so a // fresh install gets the Hellion typography out-of-the-box; flip OFF @@ -315,10 +302,33 @@ public class Configuration : IPluginConfiguration // never present in a disk-loaded copy. Keep the live temp tabs of // *this* configuration alive across an UpdateFrom so a settings // save (or sidebar-mode toggle) does not silently destroy the - // user's open tell conversations. Persistent tabs from `other` - // still get the regular clone-replace treatment. + // user's open tell conversations. + // + // For persistent tabs we go through Tab.Clone() which intentionally + // does NOT copy the NonSerialized Messages list (avoids shared + // mutable state on disk-load). On a settings save that means the + // chat history for every persistent tab would be wiped — bug + // reported by Flo 2026-05-05. We work around it by capturing the + // live MessageList (and LastSendUnread counter) by Identifier + // before the replace, then restoring it onto the freshly cloned + // tabs whose Identifier survives Tab.Clone(). New tabs added in + // settings get a fresh empty MessageList; deleted tabs lose their + // history (intended). var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList(); - Tabs = other.Tabs.Where(t => !t.IsTempTab).Select(t => t.Clone()).ToList(); + var livePersistentSession = Tabs + .Where(t => !t.IsTempTab) + .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); + + Tabs = other.Tabs.Where(t => !t.IsTempTab).Select(t => + { + var clone = t.Clone(); + if (livePersistentSession.TryGetValue(clone.Identifier, out var live)) + { + clone.Messages = live.Messages; + clone.LastSendUnread = live.LastSendUnread; + } + return clone; + }).ToList(); Tabs.AddRange(liveTempTabs); OverrideStyle = other.OverrideStyle; @@ -336,10 +346,6 @@ public class Configuration : IPluginConfiguration RetentionLastRunAt = other.RetentionLastRunAt; FirstRunCompleted = other.FirstRunCompleted; -#pragma warning disable CS0612, CS0618 // Obsolete-Felder bleiben bis v1.2.0 als JSON-Safety-Net erhalten - HellionThemeEnabled = other.HellionThemeEnabled; - HellionThemeWindowOpacity = other.HellionThemeWindowOpacity; -#pragma warning restore CS0612, CS0618 UseHellionFont = other.UseHellionFont; // v1.1.0 theme engine fields @@ -394,6 +400,11 @@ public class Tab { public string Name = Language.Tab_DefaultName; + // v1.2.0 — optionaler FontAwesome-Glyph-Name. Null bedeutet: + // Default-Mapping aus TabIconMapping greift (basiert auf Tab-Name). + // User können hier per Settings → Tabs einen eigenen Glyph setzen. + public string? Icon = null; + [Obsolete("Removed in favor of SelectedChannels")] public Dictionary ChatCodes = new(); @@ -598,6 +609,20 @@ public class Tab } } + /// + /// Aktuelle Anzahl der gespeicherten Messages. Lock-acquire pro Read + /// ist OK für 1×/sec Status-Bar-Polling (v1.2.0). + /// + public int Count + { + get + { + LockSlim.Wait(-1); + try { return Messages.Count; } + finally { LockSlim.Release(); } + } + } + /// /// Returns an array copy of the message list for usage outside of main thread /// diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index f8bac37..c9e11b4 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -120,7 +120,16 @@ public class FontManager e => e.OnPreBuild( tk => { - var config = new SafeFontConfig {SizePt = Plugin.Config.GlobalFontV2.SizePt, GlyphRanges = Ranges}; + // v1.2.0 — Bei aktiver Hellion-Schrift (Exo 2 ist Variable-Font) + // wird die User-Schriftgröße aus FontSizeV2 als SizePt angewendet. + // Der Bestand-Pfad nutzt weiter GlobalFontV2.SizePt aus dem + // Custom-Font-Stack. Ohne diese Verzweigung war FontSizeV2 bei + // UseHellionFont=true wirkungslos, was 4K-User mit größerer + // Skalierung blockierte (Settings → Erscheinungsbild → Schriftarten). + var basePt = Plugin.Config.UseHellionFont + ? Plugin.Config.FontSizeV2 + : Plugin.Config.GlobalFontV2.SizePt; + var config = new SafeFontConfig {SizePt = basePt, GlyphRanges = Ranges}; config.MergeFont = Plugin.Config.UseHellionFont ? tk.AddFontFromMemory(GetHellionFontBytes(), config, "Hellion-Exo2") : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global"); diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 1771783..96cfba1 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -4,7 +4,7 @@ 0.1.0 is our bootstrap release; the underlying Chat 2 base is called out in the yaml changelog so users can see what it derives from. --> - 1.1.0 + 1.2.0 enable enable + + Tab-Icon + + + FontAwesome-Glyph für die Sidebar. Default greift auf Tab-Name oder Channel-Typ. + + + (Default-Mapping) + Klassik (Chat 2 Default) @@ -705,4 +716,16 @@ Behalten + + Privacy-First + + + Offen + + + Kompakte Dichte + + + Schaltet das Message-Layout vom Card-Row-Default zurück auf einzeilige `[HH:mm] Sender: Text` Zeilen. + diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index edba1d7..7616d54 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -561,6 +561,17 @@ If you use multiple linkshells, the maintainer recommends one tab per shell for cleaner readability. Duplicate this tab and narrow the channel selection per copy. + + + + Tab-Icon + + + FontAwesome-Glyph für die Sidebar. Default greift auf Tab-Name oder Channel-Typ. + + + (Default-Mapping) + Klassik (Chat 2 Default) @@ -705,4 +716,16 @@ Keep current + + Privacy-First + + + Open + + + Compact Density + + + Schaltet das Message-Layout vom Card-Row-Default zurück auf einzeilige `[HH:mm] Sender: Text` Zeilen. + diff --git a/HellionChat/Ui/AutoTellTabTint.cs b/HellionChat/Ui/AutoTellTabTint.cs new file mode 100644 index 0000000..2ea733a --- /dev/null +++ b/HellionChat/Ui/AutoTellTabTint.cs @@ -0,0 +1,107 @@ +namespace HellionChat.Ui; + +/// +/// Hash-Color-Tinting für Auto-Tell-Tabs in der Sidebar (v1.2.0). +/// Differenziert Tells visuell ohne dass User pro Tab manuell ein +/// Custom-Icon setzen muss. Gleicher Tell-Partner (Name+World) liefert +/// konsistent dieselbe Farbe über Sessions hinweg. +/// +/// Kuratierte 12-Farb-Palette aus dem Hellion-Theme-Pool: alle saturiert +/// mid-bright, lesbar gegen Dark-Theme-Backgrounds. Bei realistischen +/// 1-5 parallelen Tells ist Kollisions-Wahrscheinlichkeit gering. +/// +/// Reine String-Logik (kein Dalamud-Dep) — testbar im HellionChat.Tests- +/// Projekt das ohne Dalamud-Reference baut. +/// +internal static class AutoTellTabTint +{ + /// + /// Fallback bei ungültigem Input (leerer Name, World=0). Standard- + /// Text-Color (weiß) — passt mit existierendem TextPrimary-Default + /// zusammen, sodass die Sidebar visuell konsistent bleibt. + /// + public const uint Fallback = 0xFFFFFFFFu; + + /// + /// 12 saturierte mid-bright Farben aus den 5 Built-In-Themes + /// (Hellion-Arctic, Chat2-Klassik, Event-Horizon, Moonlit-Bloom, + /// Mint-Grove). Reihenfolge ist deterministisch — Hash-Index wählt + /// Farbe per Modulo. RGBA-Format (passt zu ColourUtil.RgbaToAbgr- + /// Konvention im restlichen Code). + /// + public static readonly IReadOnlyList Palette = new uint[] + { + 0x00BED2FFu, // Arctic Cyan + 0xF97316FFu, // Ember Orange + 0xB585FFFFu, // Light Cosmic Purple + 0xE374E8FFu, // Bloom Magenta + 0x5DD39EFFu, // Mint Green + 0xF0AD4EFFu, // Warning Yellow + 0xE85C6AFFu, // Coral + 0x5CB85CFFu, // Status Green + 0x6278FFFFu, // Bloom Blue + 0xC9982EFFu, // Warm Gold + 0x9CCB7CFFu, // Soft Sage + 0xE85D04FFu, // Deep Ember + }; + + /// + /// Liefert eine konsistente Tint-Color für einen Tell-Partner. + /// Hash basiert auf "Name@World" — Cross-World-Namen kollidieren + /// nur bei Hash-Bucket-Kollision, nicht durch Identitäts-Annahme. + /// + public static uint For(string name, uint world) + { + if (string.IsNullOrEmpty(name) || world == 0) + return Fallback; + + // GetHashCode kann negativ sein; Bitmaske auf positive Range + // damit Modulo-Division immer einen validen Index liefert. + var key = $"{name}@{world}"; + var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); + return Palette[(int)(hash % Palette.Count)]; + } + + /// + /// Tell-spezifischer Icon-Pool. 7 visuell distinkte FontAwesome-Glyphen + /// die im Tell-Kontext sinnvoll wirken (envelope = Tell-Default, star/ + /// heart/bell = personalisiert, bookmark/flag/fire = markiert/wichtig). + /// Bewusst kein cog/comment/users — die wären für System-/Group-Tabs + /// reserviert und würden im Tell-Bereich verwirrend wirken. + /// + public static readonly IReadOnlyList IconPool = new[] + { + "envelope", + "star", + "heart", + "bell", + "bookmark", + "flag", + "fire", + }; + + /// + /// Fallback-Icon bei ungültigem Input. "envelope" passt semantisch zum + /// Tell-Kontext besser als das alte hardcoded "clock". + /// + public const string IconFallback = "envelope"; + + /// + /// Liefert ein konsistentes Icon-Glyph für einen Tell-Partner. + /// Nutzt einen anderen Hash-Bias als For() (Color), damit Icon und + /// Color unabhängig variieren — gibt 7 × 12 = 84 distinct Combinations. + /// + public static string IconFor(string name, uint world) + { + if (string.IsNullOrEmpty(name) || world == 0) + return IconFallback; + + // Anderer Hash-Bias als For() (verschiedene Modulo-Basis): wir + // nutzen "world@name" statt "name@world" damit Icon und Color + // nicht synchron variieren. Ohne Bias-Trennung würden alle Tells + // mit derselben Color auch dasselbe Icon haben. + var key = $"{world}@{name}"; + var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); + return IconPool[(int)(hash % IconPool.Count)]; + } +} diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index 5949104..cd2dc52 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -375,6 +375,9 @@ public sealed class ChatLogWindow : Window // weil der Cursor schon weiter unten steht — kein eigener Abzug. height -= ImGui.GetFrameHeightWithSpacing(); + // v1.2.0 — Status-Bar am Window-Boden reserviert 22 px + 2 px Spacing. + height -= StatusBar.Height + 2; + return height; } @@ -790,13 +793,17 @@ public sealed class ChatLogWindow : Window if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows)) LastActivityTime = FrameTime; - if (!showNovice) - return; + if (showNovice) + { + ImGui.SameLine(); - ImGui.SameLine(); + if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf)) + GameFunctions.GameFunctions.ClickNoviceNetworkButton(); + } - if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf)) - GameFunctions.GameFunctions.ClickNoviceNetworkButton(); + // v1.2.0 — Bottom-Status-Bar. Letzter Render-Step in DrawChatLog, + // damit alle Zeilen-Operationen davor keine Layout-Sprünge auslösen. + Plugin.StatusBar.Draw(Plugin); } internal Dictionary GetValidChannels() @@ -1316,17 +1323,63 @@ public sealed class ChatLogWindow : Window ImGui.TableNextColumn(); var lineWidth = ImGui.GetContentRegionAvail().X; - if (message.Sender.Count > 0) - { - DrawChunks(message.Sender, true, handler, lineWidth); - ImGui.SameLine(); - } - // We need to draw something otherwise the item visibility check below won't work. - if (message.Content.Count == 0) - DrawChunks([new TextChunk(ChunkSource.Content, null, " ")], true, handler, lineWidth); + // v1.2.0 — Card-Rows als Default, Compact-Density als Opt-Out. + // Card-Mode: Sender-Header in Channel-Color auf eigener Zeile, + // dann Body, dann subtile Border-Bottom als Card-Trenner. + // Compact-Mode: bisheriges Verhalten — Sender + Space + Content + // auf einer Zeile via SameLine. + var useCard = !Plugin.Config.UseCompactDensity; + if (useCard) + { + if (message.Sender.Count > 0) + { + var theme = Plugin.ThemeRegistry.Active; + var senderColor = Plugin.Functions.Chat.GetChannelColor(message.Code.Type) + ?? theme.Colors.TextPrimary; + using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(senderColor))) + { + DrawChunks(message.Sender, true, handler, lineWidth); + } + // KEIN SameLine — Body landet auf eigener Zeile. + } + + // We need to draw something otherwise the item visibility check below won't work. + if (message.Content.Count == 0) + DrawChunks([new TextChunk(ChunkSource.Content, null, " ")], true, handler, lineWidth); + else + DrawChunks(message.Content, true, handler, lineWidth); + + // Subtile Border-Bottom als Card-Trenner. Border-Farbe mit + // reduzierter Alpha (RGBA → 0x33) für dezente Trennung. + { + var theme = Plugin.ThemeRegistry.Active; + var rowEndY = ImGui.GetCursorScreenPos().Y; + var winLeft = ImGui.GetWindowPos().X; + var winRight = winLeft + ImGui.GetWindowSize().X; + var borderRgba = (theme.Colors.Border & 0xFFFFFF00u) | 0x33u; + ImGui.GetWindowDrawList().AddLine( + new Vector2(winLeft + 4, rowEndY - 1), + new Vector2(winRight - 4, rowEndY - 1), + ColourUtil.RgbaToAbgr(borderRgba), + 1f); + ImGui.Dummy(new Vector2(0, 2)); + } + } else - DrawChunks(message.Content, true, handler, lineWidth); + { + if (message.Sender.Count > 0) + { + DrawChunks(message.Sender, true, handler, lineWidth); + ImGui.SameLine(); + } + + // We need to draw something otherwise the item visibility check below won't work. + if (message.Content.Count == 0) + DrawChunks([new TextChunk(ChunkSource.Content, null, " ")], true, handler, lineWidth); + else + DrawChunks(message.Content, true, handler, lineWidth); + } message.IsVisible[tab.Identifier] = ImGui.IsItemVisible(); } @@ -1366,6 +1419,20 @@ public sealed class ChatLogWindow : Window if (!tabItem.Success) continue; + // v1.2.0 — Active-Tab-Underline-Pill (2 px Akzent statt Background-Fill). + // Bewusst direkt nach TabItem-Setup; GetItemRectMin/Max referenziert noch + // das Tab. ImGui hat keine native Underline-API, daher direkter DrawList-Pass. + { + var theme = Plugin.ThemeRegistry.Active; + var min = ImGui.GetItemRectMin(); + var max = ImGui.GetItemRectMax(); + const float pillHeight = 2f; + ImGui.GetWindowDrawList().AddRectFilled( + new Vector2(min.X, max.Y - pillHeight), + new Vector2(max.X, max.Y), + ColourUtil.RgbaToAbgr(theme.Colors.Accent)); + } + var hasTabSwitched = Plugin.LastTab != tabI; Plugin.LastTab = tabI; @@ -1383,21 +1450,36 @@ public sealed class ChatLogWindow : Window private void DrawTabSidebar() { var currentTab = -1; - using var tabTable = ImRaii.Table("tabs-table", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.Resizable); + // v1.2.0 — Sidebar fix 44 px, kein Resize. Mehr Platz fürs Chat-Log. + using var tabTable = ImRaii.Table("tabs-table", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit); if (!tabTable.Success) return; - ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthStretch, 1); - ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 4); + ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, 44f); + ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1); ImGui.TableNextColumn(); var hasTabSwitched = false; var childHeight = GetRemainingHeightForMessageLog(); + // v1.2.0 — Sidebar-Child ohne Theme-ChildBg, sonst füllt das + // bläuliche Frame-Rect auch den oberen HeaderToolbar-Padding-Bereich + // aus (sieht aus wie ein angeschnittener Block oberhalb der Buttons). + // Vertikale Trennung zur Message-Spalte bleibt durch BordersInnerV + // der Tab-Table erhalten. + using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u)) using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight))) { if (child) { + // v1.2.0 — Top-Padding spiegelt die HeaderToolbar-Höhe der + // rechten Spalte (DrawChatHeaderToolbar wird dort als erstes + // gerendert, eine Frame-Zeile + ItemSpacing). Ohne diesen + // Padding würden die Sidebar-Buttons oben am Window-Top + // kleben, während die Messages erst unter der Toolbar + // beginnen — vertikales Mismatch. + ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing())); + var previousTab = Plugin.CurrentTab; // Hellion Chat — auto-tell-tabs section divider rendered // exactly once before the first temp tab, with a live unit @@ -1422,7 +1504,6 @@ public sealed class ChatLogWindow : Window } var unread = tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})"; - var selectableLabel = $"{tab.Name}{unread}###log-tab-{tabI}"; var isCurrentTab = Plugin.LastTab == tabI || Plugin.WantedTab == tabI; var showGreetedAffordance = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; @@ -1457,34 +1538,107 @@ public sealed class ChatLogWindow : Window ImGui.SameLine(); } - bool clicked; - if (showGreetedAffordance && tab.IsGreeted) + // v1.2.0 — Icon-only Sidebar mit Tooltip beim Hover. + // Active-Tab kriegt Akzent-Color am Icon, Greeted-Tabs + // werden auf TextDim gedimmt (löst den alten Header- + // Dim-Trick ab, da wir keine Selectable mehr nutzen). + var theme = Plugin.ThemeRegistry.Active; + var icon = TabIconMapping.Resolve(tab); + uint iconColor; + if (isCurrentTab) { - // Dim the tab name once the user marked the partner - // as greeted, so a glance at the sidebar tells them - // who still needs attention. Selectable has no idle - // background slot in ImGui, so the dim only applies - // to the selected and hovered states — the text dim - // alone signals greeted in the idle state. - var headerBase = ImGui.GetColorU32(ImGuiCol.Header); - var hoverBase = ImGui.GetColorU32(ImGuiCol.HeaderHovered); - var dimHeader = (headerBase & 0xFF000000u) | ((headerBase & 0x00FEFEFEu) >> 1); - var dimHover = (hoverBase & 0xFF000000u) | ((hoverBase & 0x00FEFEFEu) >> 1); - - using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled))) - using (ImRaii.PushColor(ImGuiCol.Header, dimHeader)) - using (ImRaii.PushColor(ImGuiCol.HeaderHovered, dimHover)) - { - clicked = ImGui.Selectable(selectableLabel, isCurrentTab); - } + iconColor = theme.Colors.Accent; + } + else if (showGreetedAffordance && tab.IsGreeted) + { + iconColor = theme.Colors.TextDim; + } + else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) + { + // v1.2.0 — Hash-Color-Tint differenziert parallele Auto-Tell-Tabs + // visuell ohne dass User pro Tab manuell ein Custom-Icon setzen muss. + iconColor = AutoTellTabTint.For(tab.TellTarget.Name, tab.TellTarget.World); } else { - clicked = ImGui.Selectable(selectableLabel, isCurrentTab); + iconColor = theme.Colors.TextPrimary; + } + + bool clicked; + using (ImRaii.PushColor(ImGuiCol.Button, 0u)) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.RgbaToAbgr(theme.Colors.Surface))) + using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor))) + using (Plugin.FontManager.FontAwesome.Push()) + { + clicked = ImGui.Button($"{icon.ToIconString()}##sidebar-tab-{tabI}", new Vector2(36f, ImGui.GetFrameHeight())); + } + + if (isCurrentTab) + { + // v1.2.0 — Vertikale Akzent-Pill an der linken Window-Kante. + // 3 px breit, halbe Tab-Höhe, vertikal zentriert. ImGui hat keine + // native Pill-API, daher direkter DrawList-Pass. + var min = ImGui.GetItemRectMin(); + var max = ImGui.GetItemRectMax(); + const float pillWidth = 3f; + var pillHeight = (max.Y - min.Y) * 0.5f; + var pillCenterY = (min.Y + max.Y) * 0.5f; + ImGui.GetWindowDrawList().AddRectFilled( + new Vector2(min.X, pillCenterY - pillHeight * 0.5f), + new Vector2(min.X + pillWidth, pillCenterY + pillHeight * 0.5f), + ColourUtil.RgbaToAbgr(theme.Colors.Accent), + 1.5f); // leichter Rounding + } + + // v1.2.0 — Unread-Dot oben rechts am Icon. Sichtbar ohne Hover, damit + // User Tabs mit ungelesenen Messages sofort erkennt. Aktive Tabs haben + // per Konvention Unread = 0 (LastTab-Branch in ChatLogWindow), daher + // kollidiert der Dot nicht mit der Active-Pill. + if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) + { + var min = ImGui.GetItemRectMin(); + var max = ImGui.GetItemRectMax(); + const float dotRadius = 4f; + const float dotPadding = 3f; + var dotCenter = new Vector2( + max.X - dotRadius - dotPadding, + min.Y + dotRadius + dotPadding); + + // v1.2.0 — Sanfter Pulse-Effekt: Alpha schwankt zwischen 60% und + // 100% mit ~2-Sekunden-Cycle (subtil, nicht hektisch). + // Plugin.Config.ReduceMotion (Field seit v1.1.0) skipt den Pulse + // und rendert statisch — Default ist Animation an. + var dotColor = theme.Colors.StatusDanger; + if (!Plugin.Config.ReduceMotion) + { + // Sin-basierter 2s-Cycle: -1..1 → 0..1 → 0.6..1.0 Alpha-Skala. + var phase = (float)((Math.Sin(Environment.TickCount64 / 1000.0 * Math.PI) + 1.0) * 0.5); + var alphaScale = 0.6f + 0.4f * phase; + var origAlpha = dotColor & 0xFFu; + var pulsedAlpha = (uint)(origAlpha * alphaScale); + dotColor = (dotColor & 0xFFFFFF00u) | pulsedAlpha; + } + + ImGui.GetWindowDrawList().AddCircleFilled( + dotCenter, + dotRadius, + ColourUtil.RgbaToAbgr(dotColor), + 12); + } + + // Tooltip mit Tab-Name + Unread-Counter beim Hover. + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + ImGui.TextUnformatted($"{tab.Name}{unread}"); } DrawTabContextMenu(tab, tabI); + if (clicked) + Plugin.WantedTab = tabI; + if (!clicked && Plugin.WantedTab != tabI) continue; diff --git a/HellionChat/Ui/Settings.cs b/HellionChat/Ui/Settings.cs index 2a61094..8bafce8 100755 --- a/HellionChat/Ui/Settings.cs +++ b/HellionChat/Ui/Settings.cs @@ -210,14 +210,24 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window var fontSizeChanged = Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001 || Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001; var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled; + // v1.2.0 — Refilter only if a filter-relevant setting actually + // changed. The Clear+Refilter cycle reloads messages from the DB, + // which silently wipes any in-session message that wasn't + // persisted (Privacy-First config blocks most channels from DB). + // Cosmetic changes (theme, tab icons, layout flags) trigger no + // refilter — chat history stays intact. + var filtersChanged = HasFilterRelevantChanges(); Plugin.Config.UpdateFrom(Mutable, true); // save after 60 frames have passed, which should hopefully not // commit any changes that cause a crash Plugin.DeferredSaveFrames = 60; - Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabsAsync(); + if (filtersChanged) + { + Plugin.MessageManager.ClearAllTabs(); + Plugin.MessageManager.FilterAllTabsAsync(); + } if (fontChanged || fontSizeChanged || italicStateChanged) Plugin.FontManager.BuildFonts(); @@ -233,4 +243,59 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window Initialise(); } + + /// + /// v1.2.0 — Detects whether any setting that influences message + /// filtering changed between Plugin.Config and the Mutable working + /// copy. Used to gate the heavy ClearAllTabs+FilterAllTabsAsync cycle + /// in Save: cosmetic changes (theme, tab icons, layout flags) do not + /// touch the chat log, only filter-relevant changes do. Without this + /// gate, every settings save wipes the chat history of any channel + /// the Privacy filter blocks from being persisted to the DB — + /// reported by Flo from in-game testing 2026-05-05/06. + /// + private bool HasFilterRelevantChanges() + { + // Top-level privacy controls. + if (Mutable.PrivacyFilterEnabled != Plugin.Config.PrivacyFilterEnabled) return true; + if (Mutable.PrivacyPersistUnknownChannels != Plugin.Config.PrivacyPersistUnknownChannels) return true; + if (!Mutable.PrivacyPersistChannels.SetEquals(Plugin.Config.PrivacyPersistChannels)) return true; + + // FilterIncludePreviousSessions changes the GetMostRecentMessages + // window in MessageManager.FilterAllTabs and is therefore filter- + // relevant even though it lives outside the Privacy block. + if (Mutable.FilterIncludePreviousSessions != Plugin.Config.FilterIncludePreviousSessions) return true; + + // Per-tab channel selection. Compare persistent tabs only — + // TempTabs are session-only and never refiltered anyway. + var origPersistent = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList(); + var newPersistent = Mutable.Tabs.Where(t => !t.IsTempTab).ToList(); + + if (origPersistent.Count != newPersistent.Count) return true; // add or delete + + for (var i = 0; i < origPersistent.Count; i++) + { + var orig = origPersistent[i]; + var neu = newPersistent[i]; + + // Identifier mismatch at the same index means reorder or + // a slot got swapped — treat as filter-relevant so the new + // channel-selection layout actually applies. + if (orig.Identifier != neu.Identifier) return true; + + if (orig.ExtraChatAll != neu.ExtraChatAll) return true; + if (!orig.ExtraChatChannels.SetEquals(neu.ExtraChatChannels)) return true; + + // SelectedChannels is a Dictionary + // — value-tuple equality already does the right thing per-pair. + if (orig.SelectedChannels.Count != neu.SelectedChannels.Count) return true; + foreach (var pair in orig.SelectedChannels) + { + if (!neu.SelectedChannels.TryGetValue(pair.Key, out var nv)) return true; + if (!pair.Value.Equals(nv)) return true; + } + } + + return false; + } } diff --git a/HellionChat/Ui/SettingsTabs/Appearance.cs b/HellionChat/Ui/SettingsTabs/Appearance.cs index ce09f4c..031ab0b 100644 --- a/HellionChat/Ui/SettingsTabs/Appearance.cs +++ b/HellionChat/Ui/SettingsTabs/Appearance.cs @@ -45,32 +45,11 @@ internal sealed class Appearance : ISettingsTab using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) { - // v1.1.0 — Diese Settings-UI wird in Phase J durch den dedizierten - // Themes-Tab ersetzt. Bis dahin bleiben die alten Toggles erhalten, - // damit die Settings-Seite kompiliert; sie schreiben in die mit - // [Obsolete] markierten Felder, die bis v1.2.0 als JSON-Safety-Net - // bestehen bleiben. Das pragma unterdrückt die CS0612-Warnungen - // gezielt für diesen Übergangs-Block. -#pragma warning disable CS0612, CS0618 - ImGui.Checkbox(HellionStrings.Theme_Enabled_Name, ref Mutable.HellionThemeEnabled); - ImGuiUtil.HelpMarker(HellionStrings.Theme_Enabled_Description); - - // Clamp 0.5–1.0 stays consistent with Privacy.cs which already - // shipped this slider; lower values would let chat windows - // disappear behind game UI. - using (ImRaii.Disabled(!Mutable.HellionThemeEnabled)) - { - ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); - var opacity = Mutable.HellionThemeWindowOpacity; - if (ImGui.SliderFloat($"{HellionStrings.Theme_WindowOpacity_Label}##theme-opacity", ref opacity, 0.5f, 1.0f, "%.2f")) - { - Mutable.HellionThemeWindowOpacity = Math.Clamp(opacity, 0.5f, 1.0f); - } - ImGuiUtil.HelpMarker(HellionStrings.Theme_WindowOpacity_Help); - } - - ImGui.Spacing(); - + // v1.2.0 — Legacy HellionThemeEnabled/HellionThemeWindowOpacity-Bindings + // entfernt. Theme-Auswahl + globale Window-Opacity leben jetzt in + // Settings → Themes (eingeführt mit v1.1.0). Hier verbleibt nur der + // klassische OverrideStyle-Toggle plus der Bestand-WindowAlpha-Slider + // für das Chat-Log-Fenster. ImGui.Checkbox(Language.Options_OverrideStyle_Name, ref Mutable.OverrideStyle); ImGuiUtil.HelpMarker(Language.Options_OverrideStyle_Name_Desc); @@ -79,16 +58,7 @@ internal sealed class Appearance : ISettingsTab DrawStyleCombo(); } - // The Bestand-Slider WindowAlpha targets the chat log window's - // background only. The Hellion theme opacity above already covers - // every plugin window globally, so the two sliders fight each - // other when the theme is active. Disable the legacy slider in - // that case to make Hellion theme the single source of truth. - using (ImRaii.Disabled(Mutable.HellionThemeEnabled)) - { - ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp); - } -#pragma warning restore CS0612, CS0618 + ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp); } } @@ -139,7 +109,22 @@ internal sealed class Appearance : ISettingsTab ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description); ImGui.Spacing(); - using var fontDisabled = ImRaii.Disabled(Mutable.UseHellionFont); + // v1.2.0 — Schriftgröße muss auch bei aktiver Hellion-Schrift + // editierbar sein (Exo 2 ist Variable-Font, FontSizeV2 wird in + // FontManager als SizePt angewendet). Disabled-Wrap nur noch + // um den Bestand-Custom-Font-Stack (FontsEnabled-Toggle und + // die Font-Chooser) — der ist weiter exclusive zu HellionFont. + if (Mutable.UseHellionFont) + { + ImGuiUtil.FontSizeCombo(Language.Options_FontSize_Name, ref Mutable.FontSizeV2); + ImGui.Spacing(); + + ImGuiUtil.FontSizeCombo(Language.Options_SymbolsFontSize_Name, ref Mutable.SymbolsFontSizeV2); + ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description); + + ImGui.Spacing(); + return; + } ImGui.Checkbox(Language.Options_FontsEnabled, ref Mutable.FontsEnabled); ImGui.Spacing(); @@ -356,6 +341,11 @@ internal sealed class Appearance : ISettingsTab ImGui.Checkbox(Language.Options_MoreCompactPretty_Name, ref Mutable.MoreCompactPretty); ImGuiUtil.HelpMarker(Language.Options_MoreCompactPretty_Description); + // v1.2.0 — Card-Rows als Default. Compact-Density schaltet auf den + // klassischen Single-Line-Mode `[HH:mm] Sender: Text` zurück. + ImGui.Checkbox(HellionStrings.Appearance_UseCompactDensity_Name, ref Mutable.UseCompactDensity); + ImGuiUtil.HelpMarker(HellionStrings.Appearance_UseCompactDensity_Description); + ImGui.Checkbox(Language.Options_HideSameTimestamps_Name, ref Mutable.HideSameTimestamps); ImGuiUtil.HelpMarker(Language.Options_HideSameTimestamps_Description); } diff --git a/HellionChat/Ui/SettingsTabs/Tabs.cs b/HellionChat/Ui/SettingsTabs/Tabs.cs index 8ad2bb0..75eea88 100755 --- a/HellionChat/Ui/SettingsTabs/Tabs.cs +++ b/HellionChat/Ui/SettingsTabs/Tabs.cs @@ -91,6 +91,40 @@ internal sealed class Tabs : ISettingsTab } ImGui.InputText(Language.Options_Tabs_Name, ref tab.Name, 512, ImGuiInputTextFlags.EnterReturnsTrue); + + // v1.2.0 — Per-Tab Icon-Override. Default-Mapping greift falls nichts gesetzt. + ImGui.TextUnformatted(HellionStrings.Tabs_Icon_Label); + ImGui.SameLine(); + ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker); + + var iconCurrent = string.IsNullOrEmpty(tab.Icon) ? "" : tab.Icon; + var iconPreview = iconCurrent.Length == 0 + ? HellionStrings.Tabs_Icon_DefaultOption + : iconCurrent; + using (var combo = ImRaii.Combo($"##icon-{i}", iconPreview)) + { + if (combo.Success) + { + // Erste Option: Default (löscht Icon, lässt Mapping greifen). + if (ImGui.Selectable(HellionStrings.Tabs_Icon_DefaultOption, iconCurrent.Length == 0)) + { + tab.Icon = null; + } + + ImGui.Separator(); + + // Pool-Optionen aus TabIconGlyphResolver.PickerOptions (Single-Source-of-Truth). + foreach (var option in TabIconGlyphResolver.PickerOptions) + { + var isSelected = string.Equals(iconCurrent, option, StringComparison.OrdinalIgnoreCase); + if (ImGui.Selectable(option, isSelected)) + { + tab.Icon = option; + } + } + } + } + ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp); ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut); if (tab.PopOut) diff --git a/HellionChat/Ui/StatusBar.cs b/HellionChat/Ui/StatusBar.cs new file mode 100644 index 0000000..595d030 --- /dev/null +++ b/HellionChat/Ui/StatusBar.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui; + +/// +/// Bottom-Status-Bar (v1.2.0). Fix 22 px hoch, BorderTop als Trenner. +/// Slots links → rechts: Channel-Indicator (Color-Dot + Channel-Name), +/// Privacy-Badge (Lock-Icon + Privacy-Label), Counts (Tabs + Msgs), +/// Tells (Auto-Tell-Counter, hidden bei 0), Version (rechtsbündig, muted). +/// +/// Update-Frequenz: 1×/Sekunde. Format-Strings werden zwischen Updates +/// gecached, damit kein Per-Frame-Format-Allocation entsteht. +/// +internal sealed class StatusBar +{ + public const float Height = 22f; + private const long UpdateIntervalMs = 1000; + + // Cache-State — initial outdated, damit der erste Frame frisch berechnet. + private long _lastUpdateMs = -UpdateIntervalMs; + private string _cachedCountsText = string.Empty; + private string _cachedTellsText = string.Empty; + + /// + /// Reine String-Logik — testbar ohne ImGui-Init. + /// + public static string FormatCounts(int tabs, int messages) + { + // InvariantCulture: User-System-Locale darf das Format nicht + // verändern (de_DE würde sonst "1,2k" statt "1.2k" liefern). + var msgPart = messages >= 1000 + ? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0) + : $"{messages} msg"; + var tabsPart = $"{tabs} {(tabs == 1 ? "tab" : "tabs")}"; + return $"{tabsPart} · {msgPart}"; + } + + /// + /// Reine String-Logik — testbar ohne ImGui-Init. + /// 0 Tells → Leerstring (Slot wird ausgeblendet). + /// + public static string FormatTells(int count) + { + if (count <= 0) return string.Empty; + return $"{count} {(count == 1 ? "tell" : "tells")}"; + } + + /// + /// Test-Hook: Cache-Logic ohne reale Time-Source verifizieren. + /// Nicht für Production-Render. + /// + internal (string counts, string tells) SnapshotForTest(long now, int tabs, int messages, int tells) + { + UpdateCacheIfDue(now, tabs, messages, tells); + return (_cachedCountsText, _cachedTellsText); + } + + private void UpdateCacheIfDue(long now, int tabs, int messages, int tells) + { + if (now - _lastUpdateMs < UpdateIntervalMs) + return; + _cachedCountsText = FormatCounts(tabs, messages); + _cachedTellsText = FormatTells(tells); + _lastUpdateMs = now; + } + + /// + /// Render-Pfad. Aufrufer pusht bereits den HellionStyle/Theme; + /// wir lesen nur die aktiven Theme-Farben und zeichnen. + /// + public void Draw(Plugin plugin) + { + var theme = plugin.ThemeRegistry.Active; + var now = Environment.TickCount64; + + // Counts pro Frame berechnen ist günstig (List<>.Count, kleine + // Sums); Format-String wird gecached. + var tabs = Plugin.Config.Tabs.Count; + var messages = Plugin.Config.Tabs.Sum(t => t.Messages.Count); + var tells = Plugin.Config.Tabs.Count(t => t.IsTempTab); + UpdateCacheIfDue(now, tabs, messages, tells); + + // BorderTop als Trenner — DrawList-Line, ImGui-Separator hat zu viel Padding. + var cursorY = ImGui.GetCursorScreenPos().Y; + var winLeft = ImGui.GetWindowPos().X; + var winRight = winLeft + ImGui.GetWindowSize().X; + ImGui.GetWindowDrawList().AddLine( + new Vector2(winLeft, cursorY), + new Vector2(winRight, cursorY), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f); + + ImGui.Dummy(new Vector2(0, 2)); // BorderTop-Spacing + + // Slot 1: Active-Channel-Indicator + var inputCh = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; + var hasChannel = inputCh != InputChannel.Invalid; + var chatType = inputCh.ToChatType(); + var channelName = hasChannel ? chatType.Name() : "—"; + var channelColor = hasChannel + ? (plugin.Functions.Chat.GetChannelColor(chatType) ?? theme.Colors.TextMuted) + : theme.Colors.TextMuted; + DrawDot(channelColor); + ImGui.SameLine(); + ImGui.TextUnformatted(channelName); + + // Slot 2: Privacy-Badge — abgeleitet aus PrivacyFilterEnabled. + ImGui.SameLine(); + DrawSeparator(); + ImGui.SameLine(); + using (plugin.FontManager.FontAwesome.Push()) + { + ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString()); + } + ImGui.SameLine(); + var privacyLabel = Plugin.Config.PrivacyFilterEnabled + ? HellionStrings.StatusBar_Privacy_Enabled + : HellionStrings.StatusBar_Privacy_Open; + ImGui.TextUnformatted(privacyLabel); + + // Slot 3: Counts + ImGui.SameLine(); + DrawSeparator(); + ImGui.SameLine(); + ImGui.TextUnformatted(_cachedCountsText); + + // Slot 4: Tells (nur wenn > 0) + if (!string.IsNullOrEmpty(_cachedTellsText)) + { + ImGui.SameLine(); + DrawSeparator(); + ImGui.SameLine(); + ImGui.TextUnformatted(_cachedTellsText); + } + + // Slot 5: Version (rechtsbündig, muted) + var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; + var versionWidth = ImGui.CalcTextSize(versionText).X; + var contentRegionMax = ImGui.GetContentRegionMax().X; + ImGui.SameLine(contentRegionMax - versionWidth); + using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) + { + ImGui.TextUnformatted(versionText); + } + } + + private static void DrawDot(uint rgba) + { + var pos = ImGui.GetCursorScreenPos(); + const float radius = 4f; + ImGui.GetWindowDrawList().AddCircleFilled( + new Vector2(pos.X + radius, pos.Y + ImGui.GetTextLineHeight() / 2f), + radius, + ColourUtil.RgbaToAbgr(rgba)); + ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight())); + } + + private static void DrawSeparator() + { + ImGui.TextDisabled("·"); + } +} diff --git a/HellionChat/Ui/TabIconGlyphResolver.cs b/HellionChat/Ui/TabIconGlyphResolver.cs new file mode 100644 index 0000000..7986236 --- /dev/null +++ b/HellionChat/Ui/TabIconGlyphResolver.cs @@ -0,0 +1,79 @@ +namespace HellionChat.Ui; + +/// +/// Reine String-Resolver-Logik ohne Dalamud-Dependency. Bewusst in +/// eigener Datei (Dependency-Boundary auf File-Level sichtbar), damit +/// Tests (HellionChat.Tests, Microsoft.NET.Sdk ohne Dalamud-Reference) +/// sie aufrufen können, ohne dass die JIT beim Methodenaufruf die +/// Dalamud-Assembly laden muss. +/// +/// Wird im Settings-UI (T7) für die Glyph-Picker-Combobox und im +/// Render-Code indirekt über +/// verwendet. +/// +internal static class TabIconGlyphResolver +{ + /// + /// Picker-Options-Pool — Single Source of Truth für das Glyph-Set. + /// Reihenfolge ist die UI-Reihenfolge im Settings-Tab Icon-Combobox. + /// + public static readonly IReadOnlyList PickerOptions = + ["comment", "comments", "cog", "users", "user-friends", "link", + "envelope", "clock", "hashtag", "star", "heart", "bell", + "bookmark", "flag", "fire"]; + + /// + /// Glyph-Set, das überhaupt als Override akzeptiert wird. Aus + /// abgeleitet — KnownGlyphs nie + /// manuell pflegen. + /// + private static readonly HashSet KnownGlyphs = + new(PickerOptions, StringComparer.OrdinalIgnoreCase); + + /// + /// Tab-Name → Default-Glyph-Name. Tab.Name wird per Lokalisierung + /// gesetzt; wir matchen daher gegen einen Pool aus DE/EN-Synonymen. + /// + private static readonly Dictionary NameDefaults = new(StringComparer.OrdinalIgnoreCase) + { + ["allgemein"] = "comment", + ["general"] = "comment", + ["system"] = "cog", + ["free company"] = "users", + ["fc"] = "users", + ["gruppe"] = "user-friends", + ["group"] = "user-friends", + ["party"] = "user-friends", + ["linkshell"] = "link", + ["ls"] = "link", + ["cwls"] = "link", + ["tells"] = "envelope", + ["tell"] = "envelope", + }; + + /// + /// Test-Surface: Glyph-Name-Resolver ohne Dalamud-Dependency. + /// Reihenfolge: + /// 1. Tab.Icon-Override (falls gesetzt und nicht nur Whitespace): + /// a) bekannter Glyph → diesen Glyph + /// b) unbekannter Glyph → harter Fallback "hashtag" (User hat + /// bewusst etwas gesetzt, also überstimmt das die Defaults) + /// 2. Auto-Tell-Tab → falls + /// übergeben, sonst "clock". + /// 3. Tab-Name-Default (-Lookup) + /// 4. Fallback "hashtag" + /// + public static string ResolveGlyphName(Tab tab, string? autoTellGlyph = null) + { + if (!string.IsNullOrWhiteSpace(tab.Icon)) + return KnownGlyphs.Contains(tab.Icon) ? tab.Icon : "hashtag"; + + if (tab.IsTempTab) + return autoTellGlyph ?? "clock"; + + if (tab.Name is { } name && NameDefaults.TryGetValue(name, out var byName)) + return byName; + + return "hashtag"; + } +} diff --git a/HellionChat/Ui/TabIconMapping.cs b/HellionChat/Ui/TabIconMapping.cs new file mode 100644 index 0000000..c185231 --- /dev/null +++ b/HellionChat/Ui/TabIconMapping.cs @@ -0,0 +1,72 @@ +using Dalamud.Interface; + +namespace HellionChat.Ui; + +/// +/// Default-Icon-Mapping für Tabs. v1.2.0 Layout-Refresh nutzt das +/// in Top-Tabs (Icon-Prefix) und Sidebar (Icon-only mit Tooltip). +/// User können in Settings → Tabs per Tab.Icon-Override eigene +/// FontAwesome-Glyphen setzen. +/// +/// Diese Klasse ist Dalamud-abhängig (FontAwesomeIcon-Enum). Die +/// reine String-Resolver-Logik liegt bewusst in +/// (eigene Datei, ohne +/// Dalamud-Imports), damit Tests sie ohne Dalamud-Reference aufrufen +/// können. +/// +internal static class TabIconMapping +{ + /// + /// FontAwesome-Glyph-Name → Icon-Enum-Lookup. Wird für die + /// Production-Resolve-API benötigt. + /// + /// INVARIANTE: Jeder Key in muss auch in + /// stehen. Wird + /// ein Glyph zu PickerOptions hinzugefügt, aber nicht hier, fällt + /// die Override-Auflösung still auf + /// zurück (degraded, kein Crash). Build-Time-Enforcement ist nicht + /// möglich, weil PickerOptions ohne Dalamud-Reference auskommt. + /// + private static readonly Dictionary GlyphLookup = new(StringComparer.OrdinalIgnoreCase) + { + ["comment"] = FontAwesomeIcon.Comment, + ["comments"] = FontAwesomeIcon.Comments, + ["cog"] = FontAwesomeIcon.Cog, + ["users"] = FontAwesomeIcon.Users, + ["user-friends"] = FontAwesomeIcon.UserFriends, + ["link"] = FontAwesomeIcon.Link, + ["envelope"] = FontAwesomeIcon.Envelope, + ["clock"] = FontAwesomeIcon.Clock, + ["hashtag"] = FontAwesomeIcon.Hashtag, + ["star"] = FontAwesomeIcon.Star, + ["heart"] = FontAwesomeIcon.Heart, + ["bell"] = FontAwesomeIcon.Bell, + ["bookmark"] = FontAwesomeIcon.Bookmark, + ["flag"] = FontAwesomeIcon.Flag, + ["fire"] = FontAwesomeIcon.Fire, + }; + + /// + /// Production-Surface: liefert das Icon für einen Tab. Wrapper um + /// plus + /// Enum-Lookup. Wird von Render-Code (T3, T5) verwendet. + /// + public static FontAwesomeIcon Resolve(Tab tab) + { + // v1.2.0 — Auto-Tell-Tabs bekommen ein per-Partner gehashtes + // Icon aus dem Tell-Pool. Damit unterscheiden sich parallele + // Tells nicht nur über die Color (For), sondern auch über die + // Glyph-Form. Berechnung bleibt hier (Dalamud-bound), weil + // TellTarget Dalamud-Imports hat. + string? autoTellGlyph = null; + if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) + { + autoTellGlyph = AutoTellTabTint.IconFor(tab.TellTarget.Name, tab.TellTarget.World); + } + + var glyph = TabIconGlyphResolver.ResolveGlyphName(tab, autoTellGlyph); + return GlyphLookup.TryGetValue(glyph, out var icon) + ? icon + : FontAwesomeIcon.Hashtag; + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7bc1f6b..c00a694 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,33 @@ und verlinkt für Details auf die Release-Pages. --- +## v1.2.0 — Layout Refresh (2026-05-05) + +### Added +- Sidebar tab modernization: icon-only at fixed 44 px, tooltip on hover, vertical accent pill for active tab +- Top tabs: accent underline pill replaces background fill on active tab +- Per-tab custom icons in Settings → Tabs (15-glyph FontAwesome picker) +- Bottom status bar (22 px): channel indicator, privacy badge, counters, tells, version — updates 1×/sec +- Card rows as default message render: sender header in channel color, subtle border between cards +- Compact-Density toggle in Appearance: switches back to single-line `[HH:mm] Sender: Text` layout +- Auto-Tell tabs: per-partner hashed icon (7-glyph pool: envelope/star/heart/bell/bookmark/flag/fire) plus hashed color (12-color palette) — 84 distinct icon+color combinations +- Unread indicator: pulsing red dot in the top-right corner of any sidebar tab icon with unread messages, 2-second sine-wave pulse, respects `Configuration.ReduceMotion` + +### Changed +- Migration v14 → v15: deprecated Configuration fields `HellionThemeEnabled` and `HellionThemeWindowOpacity` removed +- Appearance settings cleaned: legacy theme-engine bindings replaced by Themes tab (introduced in v1.1.0) + +### Fixed +- Settings save no longer wipes chat history by default — the heavy `ClearAllTabs + FilterAllTabsAsync` cycle now only runs when a filter-relevant setting actually changed (Privacy filter, persisted channels, per-tab channel selection). Cosmetic changes keep the in-session chat intact +- Identifier-based `MessageList` restore in `Configuration.UpdateFrom` plus TempTab skip in `ClearAllTabs`/`FilterAllTabs` ensure persistent tabs and Auto-Tell tabs both survive the save +- Sidebar buttons now align vertically with the first message row (top padding mirrors the chat header toolbar height) +- Sidebar child window no longer paints the top padding area with its frame background +- Status bar version slot (`vX.Y.Z · Hellion`) no longer clips its rightmost character + +### Notes +- Polish phase (animations, theme crossfade, header quick-picker) follows in v1.3.0 +- Top-Tab icon prefixes were considered but dropped: Dalamud's default font atlas does not include FontAwesome codepoints, so mixed-font in a single TabItem label renders as tofu. Underline pill alone is the v1.2.0 visual treatment for top tabs. Resolution would require Font-Atlas merge at FontManager level — out of scope. + ## [1.1.0] — 2026-05-05 — Theme Foundation Erster großer UI-Cycle nach v1.0.0. Theme-Engine, fünf Built-In-Themes, diff --git a/repo.json b/repo.json index 8129da2..f58be5f 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "JonKazama-Hellion", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.1.0.0", + "AssemblyVersion": "1.2.0.0", "Description": "Hellion Chat is a privacy-focused chat replacement for FINAL FANTASY XIV based on the Chat 2 codebase (EUPL-1.2). One feature is intentionally removed (the optional webinterface) and a stack of privacy controls is added on top. Tabs, channel filters, RGB colours, emotes, screenshot mode, IPC integration and the chat replacement window itself work the same. The webinterface is intentionally not part of Hellion Chat because it serves a different use case from the smaller default footprint this plugin is built around.\n\nOn top of that, Hellion Chat adds privacy and data-handling controls designed to align with the modern data protection rules that apply across the EU, the United States and Japan. By default only your own conversations are stored; messages from strangers, NPCs and system spam stay out of the database. Retention windows are configurable per channel, history can be wiped retroactively, and stored data can be exported on demand.\n\nKey privacy and data-handling features:\n\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three preset profiles (Privacy-First, Casual, Full History)\n- Bilingual UI (English and German) with live language switching\n- Independent plugin state — own config file and database directory, so Hellion Chat does not share state with upstream Chat 2\n\nv1.1.0 — Theme engine with five built-in themes (Hellion Arctic, Chat 2 Klassik, Event Horizon, Moonlit Bloom, Mint Grove) plus JSON-based custom-theme authoring. Settings rebuilt around a card grid with section detail views. See docs/THEME-AUTHORING.md.\n\nBased on Chat 2 by Infi and Anna, licensed under EUPL-1.2.\n\nModding & support: join the Hellion Forge Discord at https://discord.gg/X9V7Kcv5gR — community for Hellion Chat and other Hellion Online Media plugins/tools.", "ApplicableVersion": "any", "RepoUrl": "https://github.com/JonKazama-Hellion/HellionChat", @@ -22,10 +22,10 @@ "Punchline": "Chat replacement with privacy controls aligned to EU, US and JP rules — based on Chat 2 (EUPL-1.2)", "Changelog": "**Hellion Chat 1.1.0 — Theme Foundation**\n\nFirst major UI cycle after the standalone v1.0.0 cut. Theme engine, five built-in themes, customisable JSON themes, modernised settings layout.\n\nNew themes (Settings → Themes):\n\n- **Hellion Arctic** — the brand default, Arctic Cyan + Ember Glow on industrial slate.\n- **Chat 2 Klassik** — Steel Blue on neutral grey, eckige Kanten. The upstream Chat 2 look on the new engine.\n- **Event Horizon** — Cosmic Purple on near-black. Deep-space mood.\n- **Moonlit Bloom** — Bloom Magenta + Soft Sage on deep-violet night.\n- **Mint Grove** — Mint Green + Honey Amber on deep forest. First member of the Grove family.\n\nTheme engine highlights:\n\n- Slug-based selection in Settings → Themes with mini-mockup previews per theme.\n- Click a theme card and the whole plugin (chat, settings, pop-outs, viewer) repaints instantly.\n- Custom themes via JSON in pluginConfigs/HellionChat/themes/. Example template seeded on first launch.\n- Optional per-theme chat-channel colours. When a theme proposes its own chat colours and yours differ, a dezent banner offers Apply / Keep — never auto-overwriting.\n- Migration v13 → v14: existing users land on Hellion Arctic. Pick Chat 2 Klassik to keep the upstream look.\n\nSettings layout:\n\n- New card-grid overview on Settings open. Click a card to drill into the section.\n- Breadcrumb back to overview, ESC also returns.\n- Detail view drops the redundant tab list — section content uses the full width.\n\nBranding:\n\n- Plugin icon swapped from the ChatTwo derivative to the Hellion Forge hammer.\n- New docs/THEME-AUTHORING.md walks you through writing your own themes with the Forge logo on top.\n\nTechnical:\n\n- HellionStyle.PushGlobal is now theme-driven. Configuration.HellionThemeEnabled is deprecated and will be removed in v1.2.0.\n- New ThemeRegistry singleton with LastWriteTime-cached custom-theme loader.\n- 51 local unit tests cover the data model, registry, JSON round-trip and built-in sanity checks.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 1.0.3 — Polish patch**\n\n- New: optionally hide chat (and every other plugin window) while the New Game+ menu is open. Toggle in Settings → Window → Frame, default off. Closing the menu restores all windows.\n- New: optionally tint the channel selector button next to the input field with the currently active channel's colour. Toggle in Settings → Appearance → Colours, default on. Matches the existing input-text tint and respects ExtraChat overrides.\n- Fix: status, item and other inline hover icons keep their original aspect ratio. Debuff icons with non-square dimensions are no longer visually squished into a 32×32 box.\n- Diagnostic: hide-state transitions (battle, cutscene, user-hide, cutscene override) are now logged on Verbose level for easier bug reports — off by default, enable with `/xllog set HellionChat verbose`.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 1.0.1 — Window Position Recovery**\n\n- Automatic bounds check on the first draw after plugin load. When the persisted window position has no overlap with the primary viewport, the window snaps to a safe top-left default. Helpful after a monitor disconnect, resolution change or multi-monitor layout switch between sessions.\n- New \"Reset Window Position\" button in Settings → Window → Frame as a manual escape hatch for edge cases the automatic check doesn't catch.\n\nTested on Linux/Wayland with a hard-cut three-monitor reduction; window recovers cleanly without manual JSON editing.\n\nHousekeeping carried over since v1.0.0:\n\n- Documentation restructured into docs/ folder. New CHANGELOG, CONTRIBUTORS, LEARNING-JOURNEY and ROADMAP added\n- Stale ChatTwo/* paths in repo configs updated to HellionChat/*\n- Pidgin parser library bumped from 3.3.0 to 3.5.1 (CIString Unicode fix relevant for non-ASCII channel/tab names)\n- GitHub Actions: actions/setup-dotnet bumped 4 → 5, github/codeql-action bumped 3 → 4\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 1.0.0 — Standalone Major Release**\n\nFirst fully standalone release. Internal cleanup plus a sweep of\npre-existing correctness, security, threading and resource-leak\nfixes carried over from the upstream codebase. No user action\nrequired — auto-update applies cleanly, configuration and database\npaths unchanged.\n\nStandalone identity:\n\n- Code namespace consolidated from ChatTwo.* to HellionChat.* across\n all source files\n- IPC channels migrated from ChatTwo.* to HellionChat.* (6 channels:\n Register, Available, Unregister, Invoke, GetChatInputState,\n ChatInputStateChanged) — third-party plugins that bound to the old\n channels need to be updated; none known at release time\n- ImGui popup ID renamed to hellionchat-context-popup\n- Repository folder restructured (ChatTwo/ → HellionChat/), all CI\n and build paths updated accordingly\n- Public-facing descriptions reworded from upstream-fork framing to\n standalone framing (Chat 2 attribution preserved per EUPL-1.2)\n- Colour preset 'ChatTwo Default' is now 'Klassik (Chat 2 Default)'\n\nSafety:\n\n- Plugin now refuses to load when upstream Chat 2 is also active —\n bilingual conflict message in EN/DE, throw before any subsystem\n initialization, prevents the runtime crash that previously occurred\n when both plugins replaced the same chat window in parallel\n- SQLite native binary bumped to 3.50.3 (CVE-2025-6965 memory\n corruption from aggregate-term overflow, CVE-2025-7709)\n- NuGet restore now honors packages.lock.json so transitive\n dependencies don't drift between machines or CI runs\n\nDefault tab layout sharpened (one-time tab reset on first start):\n\nThe first-run tab layout is reorganized into five thematic tabs\nbased on external tester feedback. General contains only Say,\nYell and Shout (immediate-surroundings public chat). System\nabsorbs the gameplay-event streams (NpcDialogue, Loot, Crafting,\nGathering, PF recruitment pings) and announcement noise\n(BattleSystem, FreeCompanyAnnouncement, PvpTeamAnnouncement)\nthat previously lived in General. FreeCompany, Group and\nLinkshell each own their channel set. The static Tell tab is\ngone — Auto-Tell-Tabs spawns per-conversation tabs on demand.\nThe Beginner / Novice-Network preset is no longer added by\ndefault but is still available via Settings, Tabs.\n\nThis is a one-time tab-layout reset for users on config version\n12 or older. Privacy, Retention, Theme and every other setting\nis preserved. Your previous tab configuration is written to\npluginConfigs/HellionChat.json.pre-v13-backup so you can restore\nit manually if you prefer the old layout.\n\nCrash-class fixes (formerly latent in upstream):\n\n- MathUtil.HasOverlap now uses a correct AABB test; identical or\n edge-touching rectangles are no longer reported as non-overlapping\n- ChatCode.Equals compares fields directly instead of GetHashCode;\n removes the hash-collision anti-pattern\n- IpcManager.Dispose uses UnregisterAction to match the matching\n RegisterAction call; previous mismatch leaked the action\n subscription on every plugin reload\n- ExtraChat.Dispose now unsubscribes all three IPC subscriptions\n (was only the first); leaks closed\n- TellTarget.FromTarget guards against a zero IPlayerCharacter.Address\n before dereferencing the unsafe Character* cast\n- GameFunctions ResolveTextCommandPlaceholderDetour null-checks the\n Hook reference instead of using the null-forgiving operator\n- Popout.cs and SettingsTabs/Tabs.cs bounds-check list indexing so\n a tab drop or empty-worlds list no longer crashes the UI\n- Debugger.cs now declares IDisposable so the existing Dispose runs\n\nCorrectness fixes:\n\n- GlobalParametersCache.GetValue captures Cache into a local before\n the bounds check, so a concurrent Refresh can't slip a different\n array between check and read\n- IconUtil binary search bounds initialized to entries.Length-1 and\n reset on redirect-restart; entries.Length==0 short-circuits\n- Sheets.WorldsOnDatacenter now compares DataCenter.RowId (was\n Region.RowId) so it actually returns same-DC worlds\n- Message.cs back-reference loop iterates the processed Sender/Content\n properties so chunks added by CheckMessageContent get Message set\n- Language.zh-Hans Webinterface_Start_Success corrected to\n \"网页界面已启动\" (was \"网页界面已停止\")\n\nThreading and async:\n\n- AutoTranslate Entries/ValidEntries are now serialized behind a\n single lock; the preload worker thread and main thread no longer\n race on the underlying dictionary/hash set\n- Privacy retention and cleanup workers bound their framework-refresh\n waits to 5 seconds with a logged timeout; a hung framework tick can\n no longer deadlock the background worker\n\nResource handling:\n\n- EmoteCache reuses the static HttpClient instead of allocating a new\n one per call (closed socket leak)\n- FontManager wraps HttpClient/HttpResponseMessage in using-blocks\n and adds EnsureSuccessStatusCode; failed downloads no longer\n silently produce a zero-byte font file\n- SearchSelector mixes the row index into the ImGui ID stack so\n selectables don't collapse to a single ambiguous ID\n- SettingsTabs/Chat blocked-emote add-button now opens its selector\n popup on left-click\n\nPerformance:\n\n- DbViewer text export caches filteredHistory.Count once instead of\n re-enumerating the IEnumerable on every batch (O(N) instead of\n O(N²) on large histories)\n\nLicense attribution (NOTICE.md, COPYRIGHT, THIRD_PARTY_NOTICES.md\nand the Credits section in README) is unchanged.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.6.1 — Pop-Out Discoverability & /tell Auto-Pop-Out**\n\n- Pop-out button now visible in the chat header (no more hunting through the right-click menu)\n- One-time hint banner explains pop-out tabs and the right-click shortcut\n- New setting: open new /tell tabs directly as pop-out windows (Settings → Chat → Auto-Tell-Tabs)\n- Pop-out input is now enabled by default — closing a pop-out still returns the tab to the sidebar\n- Bugfix: dropping or logging out with an LRU/popped auto-tell tab now also closes its pop-out window (no more ghost windows)\n- Bugfix: dead zone below the chat input bar when the v0.6.0 pop-out hint banner was visible (also fixed retroactively for the v0.6.0 banner inside pop-outs)\n\nModding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\nEarlier history: https://github.com/JonKazama-Hellion/HellionChat/releases", "AcceptsFeedback": true, - "DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.1.0/latest.zip", - "DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.1.0/latest.zip", - "DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.1.0/latest.zip", - "TestingAssemblyVersion": "1.1.0.0", + "DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.2.0/latest.zip", + "DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.2.0/latest.zip", + "DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v1.2.0/latest.zip", + "TestingAssemblyVersion": "1.2.0.0", "IconUrl": "https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/icon.png", "ImageUrls": [ "https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/chatWindow.png",