Compare commits

...

42 Commits

Author SHA1 Message Date
JonKazama-Hellion 0c26d1aa67 merge: v1.1.0 Theme Foundation
First major UI cycle after the standalone v1.0.0 cut. Theme engine
with five built-in themes (Hellion Arctic, Chat 2 Klassik, Event
Horizon, Moonlit Bloom, Mint Grove), customisable JSON themes,
modernised settings layout (card-grid overview + breadcrumb detail
view), opt-in per-theme chat-channel colours, and the plugin icon
swap to the Hellion Forge hammer.

Configuration v13 → v14: all users land on Hellion Arctic. Pick
Chat 2 Klassik in Settings → Themes for the upstream look.

See HellionChat/HellionChat.yaml changelog and docs/CHANGELOG.md for
the full release notes; docs/THEME-AUTHORING.md is the new guide for
writing custom themes.
2026-05-05 15:17:14 +02:00
JonKazama-Hellion 8b13ba1fdc release(v1.1.0): updated screenshots and forge announce note
Three new screenshots replace the v1.0.x set: chatWindow (in-game
sidebar with FreeCompany tab), settingsOverview (card grid with all
nine sections), themesPicker (built-in themes plus example custom).
ImageUrls in repo.json + yaml updated; old withSimpleTweaks.png
dropped.

.github/forge-posts/v1.1.0.md seeded for the eventual auto-announce
workflow (Discord changelog post on tag push). Format matches the
forge-announce spec — frontmatter (subtitle, versionsnatur) plus DE
bullet body.
2026-05-05 15:14:50 +02:00
JonKazama-Hellion 52da5d5e23 chore(release): bump to v1.1.0 — theme foundation 2026-05-05 15:04:52 +02:00
JonKazama-Hellion 916640fb60 feat(brand): swap plugin icon to hellion forge hammer
The chat plugin now ships under the Hellion Forge plugin-workshop
brand. Icon is the 512x512 hammer mark from the Forge logo set
(was 256x256 ChatTwo derivative).
2026-05-05 15:00:08 +02:00
JonKazama-Hellion feeb1df4eb docs(themes): theme authoring guide with hellion forge branding 2026-05-05 14:54:58 +02:00
JonKazama-Hellion f2086865ce feat(themes): opt-in chat color apply banner in themes tab
When a theme defines its own chat channel colours and the current
Configuration.ChatColours don't match, a dezent banner offers Apply /
Keep — opt-in, never auto-overwriting user picks. Switching themes
re-arms the banner so each theme can be evaluated separately.
2026-05-05 14:51:16 +02:00
JonKazama-Hellion 15a89dd6e7 feat(themes): chat channel color sets for four built-in themes
Hellion Arctic, Event Horizon, Moonlit Bloom and Mint Grove each
ship a distinct chat-channel palette tinted toward their brand
family while preserving the FFXIV channel identity (Say light, Yell
yellow, Shout orange, Tell pink-magenta, Party blue, FC cyan, NN
green). Chat 2 Klassik intentionally ships without — users picking
that theme keep their existing channel colours.
2026-05-05 14:48:34 +02:00
JonKazama-Hellion 53952717c0 feat(themes): optional chat channel colors in theme schema 2026-05-05 14:44:59 +02:00
JonKazama-Hellion fcbbd174b6 fix(themes): wrap theme cards in begin/end group so the grid wraps
Theme-card grid was stacking diagonally for the same reason the
settings overview did: SetCursorScreenPos plus SameLine in the
caller loop don't compose. Wrap each card in BeginGroup/EndGroup,
draw name and author via DrawList instead of cursor hops, and let
ImGui handle row wrapping naturally.
2026-05-05 14:31:35 +02:00
JonKazama-Hellion d41cea0031 fix(settings): card grid wraps correctly, detail view drops legacy tab list
SettingsOverview now wraps each card in BeginGroup/EndGroup so SameLine
in the loop can wrap rows. The card content is drawn directly into the
DrawList (icon, title, subtext) without cursor hopping that broke the
flow.

DrawDetail no longer renders the second-column tab list — the user has
already picked a section from the overview, the redundant column made
the detail view feel like the old vanilla settings layout. Section
content now uses the full width.
2026-05-05 14:28:24 +02:00
JonKazama-Hellion c943a2cff3 fix(themes): drop legacy StyleModel push from chat log and pop-out
The pre-engine StyleModel override in ChatLogWindow.PreDraw and
Popout.PreDraw was layering an extra Dalamud style on top of the
Hellion theme, locally tinting the chat window back to a non-Hellion
look while every other plugin window rendered correctly. Theme is
now the single source of truth — pick chat2-classic for the upstream
flavour.
2026-05-05 14:23:41 +02:00
JonKazama-Hellion abcd0847ef fix(settings): restore cursor after card draw to keep grid layout intact 2026-05-05 14:22:23 +02:00
JonKazama-Hellion 2f52cbb7d4 feat(themes): seed example custom theme on first start 2026-05-05 14:17:22 +02:00
JonKazama-Hellion 9103bbb892 feat(settings): breadcrumb header and esc to return to overview 2026-05-05 14:15:12 +02:00
JonKazama-Hellion 8f9c01d322 feat(themes): mini-mockup preview in theme cards 2026-05-05 14:13:19 +02:00
JonKazama-Hellion af4651b37e feat(themes): export active theme to json 2026-05-05 14:11:14 +02:00
JonKazama-Hellion 485dc4e1b4 i18n(themes): localize theme settings card grid (en/de) 2026-05-05 14:09:02 +02:00
JonKazama-Hellion c878d24d11 feat(themes): settings tab with built-in and custom theme grids 2026-05-05 14:05:59 +02:00
JonKazama-Hellion cb5c940a84 feat(settings): card-grid overview router 2026-05-05 14:02:13 +02:00
JonKazama-Hellion dd3a0ea069 feat(themes): wire theme engine into plugin draw pipeline + migrate v13→v14
HellionStyle.PushGlobal nimmt jetzt eine Theme-Instance + Window-Opacity
und liest alle Color- und Style-Slots aus dem aktiven Theme statt aus
einer fixen Konstanten-Tabelle. Plugin hält die ThemeRegistry und schaltet
beim Init auf das in Config.Theme gespeicherte Slug.

Configuration v13 → v14:
- Neue Felder Theme (slug), WindowOpacity, ReduceMotion, UseCompactDensity,
  ShowThemeQuickPicker
- HellionThemeEnabled und HellionThemeWindowOpacity sind ab v14 [Obsolete]
  und bleiben bis v1.2.0 als JSON-Safety-Net erhalten
- Migration setzt alle Bestandsuser auf hellion-arctic; chat2-classic
  bleibt im Themes-Tab als Upstream-Look wählbar
- WindowOpacity übernimmt den Wert von HellionThemeWindowOpacity, alte
  HellionThemeEnabled-Flag entfällt funktional (Theme-Engine ist immer aktiv)

Konsumenten der alten Felder (ChatLogWindow.BgAlpha, Popout.BgAlpha) lesen
jetzt das neue WindowOpacity. Die Settings-UI in Appearance.cs schreibt
übergangsweise weiter in die Obsolete-Felder; Phase J ersetzt diesen Block
durch den dedizierten Themes-Tab. CS0612/CS0618 sind dort gezielt mit
pragma gekapselt.
2026-05-05 13:51:31 +02:00
JonKazama-Hellion 4bf6c3ef1f feat(themes): custom theme loading with file-stamp cache 2026-05-05 13:44:15 +02:00
JonKazama-Hellion 2378ce6bf2 feat(themes): json loader with schema validation 2026-05-05 13:42:40 +02:00
JonKazama-Hellion b85db24601 test(themes): sanity tests for all built-in themes 2026-05-05 10:28:08 +02:00
JonKazama-Hellion cae7d76206 feat(themes): theme registry with built-in lookup and fallback 2026-05-05 10:27:50 +02:00
JonKazama-Hellion 4c6d52e652 feat(themes): mint-grove built-in theme 2026-05-05 10:25:22 +02:00
JonKazama-Hellion cbfdfe35be feat(themes): moonlit-bloom built-in theme 2026-05-05 10:25:00 +02:00
JonKazama-Hellion 537b96c79f feat(themes): event-horizon built-in theme 2026-05-05 10:24:39 +02:00
JonKazama-Hellion d3d28924e6 feat(themes): chat2-classic built-in theme 2026-05-05 10:24:17 +02:00
JonKazama-Hellion 48f1fb5ba1 feat(themes): hellion-arctic built-in theme 2026-05-05 10:23:51 +02:00
JonKazama-Hellion 0b13efd0b5 feat(util): add HexToRgba parser for theme JSON 2026-05-05 10:21:46 +02:00
JonKazama-Hellion 289fe2eb78 feat(themes): theme top-level record 2026-05-05 10:19:33 +02:00
JonKazama-Hellion fe9e66b0ff feat(themes): theme typography record 2026-05-05 10:19:18 +02:00
JonKazama-Hellion 990edd8300 feat(themes): theme layout record 2026-05-05 10:19:03 +02:00
JonKazama-Hellion db95ec7dff feat(themes): theme colors record 2026-05-05 10:18:49 +02:00
JonKazama-Hellion 7e036c1d00 chore(csproj): enable nullable reference types
Audit-Tooling hatte einen mehrstündigen Sweep mit 50–200 erwarteten
Warnings prognostiziert. Tatsächliches Resultat: eine Zeile. Genau
eine. Codebase war pro-File längst nullable-konform, wir hatten den
Project-Switch nur nie umgelegt. Reminder dass Audit-Output ein
Hinweis ist, kein Plan, und ein menschlicher Pass davor lohnt sich.
2026-05-05 08:48:04 +02:00
JonKazama-Hellion 1c511a147d fix(stringutil): use InvariantCulture for byte-size formatting
Locale-Bug: BytesToString rendert auf deutscher Locale "1,5GB" statt
"1.5GB". InvariantCulture pinnt den Dezimal-Separator. Plus
InternalsVisibleTo-Hook für ein lokales (gitignored) Test-Projekt.
2026-05-05 08:34:56 +02:00
JonKazama-Hellion f093d93761 perf(messagemanager): switch pending queue to linked list, quiet privacy log
PendingSync läuft jetzt als LinkedList (O(1) Last statt O(n) Linq-Last
im ContentIdResolverHook); Privacy-Filter-Drop-Log auf Verbose runter,
sodass der Default-xllog-Stream nicht mehr pro Nachricht spammt.
2026-05-05 08:25:13 +02:00
JonKazama-Hellion e7c8667497 fix(emotecache): cancel pending texture loads on plugin dispose
Plugin-scoped CancellationTokenSource fließt jetzt durch LoadAsync und
die Texture-Calls; Dispose cancelt in-flight downloads. Smoke (System-
Spam + Reload) sauber, weiter beobachten unter höherem Emote-Volumen.
2026-05-05 08:09:53 +02:00
JonKazama-Hellion 497197eb2c chore(deps): cap major-bump packages with closed version ranges
ImageSharp, MessagePack and Pidgin pinned to [x.y, next-major) so a
lock-file regeneration cannot drift across a major. Resolved versions
unchanged; lock-file diff is request-string only.
2026-05-05 07:54:33 +02:00
JonKazama-Hellion 08b2ffc600 ci(codeql): pin actions to commit SHAs
Replaces floating major-version tags with full commit SHAs (Tag-
Kommentar dahinter), so a tag-republish can't slip a different action
into the workflow.
2026-05-05 07:45:37 +02:00
JonKazama-Hellion 8db3eca46c chore(fontmanager): drop unused Lodestone font download
The FontManager constructor downloaded FFXIV_Lodestone_SSF.ttf from
img.finalfantasyxiv.com on first start (or read it from a local
cache) into a GameSymFont byte array. Both historical readers of
that field are gone:

- BuildFonts() used to feed the bytes into AddFontFromMemory; that
  path was replaced by the Dalamud-provided AddGameSymbol helper.
- The upstream webinterface server wrote the bytes through a
  BinaryWriter to serve them to the Svelte frontend; the entire
  webinterface was intentionally removed in HellionChat.

With no live consumer left, the field, the constructor block, the
HttpClient call and the disk cache are all dead code. Removing them:

- eliminates the synchronous HTTP request on the plugin-load thread
  (no more multi-second startup hang on slow networks)
- closes the implicit "no timeout, no size guard" exposure on that
  request
- removes one outbound network endpoint (Square Enix Lodestone CDN)
  from the privacy footprint

PRIVACY.md and THIRD_PARTY_NOTICES.md updated to reflect that
HellionChat now talks to BetterTTV only (opt-out via setting). Cached
TTF files left over from earlier versions stay in pluginConfigs/
HellionChat/ until a user removes them; they are simply no longer
read.

Build: 0 warnings, 0 errors. No behavioural change for users — symbol
glyphs (job icons, item glyphs, status effects) keep rendering through
Dalamud's built-in symbol font.
2026-05-05 07:37:35 +02:00
JonKazama-Hellion 4d54eabdac chore: code quality sweep 2026-05-04 / 2026-05-05
General code-quality and robustness pass across the plugin: thread-
safety on IPC state, resource-disposal cleanups, input validation,
defensive null-checks and a few small UX glitches. Compliance docs
(THIRD_PARTY_NOTICES, PRIVACY, COPYRIGHT) refreshed to v1.0.3.

Highlights
- ExtraChat IPC state synchronised across threads
- ChatLogWindow autocomplete no longer leaks the unmanaged
  ImGuiListClipper allocation
- ChatLogWindow + Popout style stack stays balanced when config
  toggles mid-frame
- Retention sweep and privacy cleanup wait for the actual filter
  pass instead of the fire-and-forget Task that started it
- Configuration.LatestVersion bumped to 13 to match the active
  migration path
- GameFunctions placeholder buffer guarded against oversized
  replacement names
- TellTarget.IsSet, ResolveTempInputChannel, InputPreview, IconUtil,
  Lender, Payloads, ExtraPayload all hardened against null / empty /
  EOF / cycle inputs
- FontManager Lodestone download stays in scope for a follow-up
  (timeout + lazy init pending)
- AutoTranslate replaced the msvcrt.dll memcmp P/Invoke with a
  managed Span comparison
- Privacy cleanup worker thread marked IsBackground = true
- Database cleanup now removes both legacy files in one click
- Tell-target name redacted in the verbose debug log

Compliance
- THIRD_PARTY_NOTICES: last-reviewed bumped to v1.0.3, Pidgin 3.5.1,
  SQLitePCLRaw.lib.e_sqlite3 3.50.3 listed as direct dependency with
  CVE-2025-6965 / CVE-2025-7709 rationale
- PRIVACY: last-reviewed bumped to v1.0.3, BetterTTV trigger wording
  clarified (list fetch at startup vs. on-demand image fetch)
- COPYRIGHT: upstream attribution range widened

Build: 0 warnings, 0 errors. No behavioural changes that would alter
existing user configuration or stored chat history.
2026-05-05 07:28:12 +02:00
71 changed files with 2371 additions and 435 deletions
+12
View File
@@ -0,0 +1,12 @@
---
subtitle: "Theme Foundation"
versionsnatur: "Major-UI-Cycle"
---
- Theme-Engine mit fünf Built-In-Themes: Hellion Arctic (Default), Chat 2 Klassik, Event Horizon, Moonlit Bloom, Mint Grove
- Settings öffnet jetzt eine Card-Grid-Übersicht — Klick auf eine Card führt in den Detail-View, Breadcrumb und ESC zurück zur Übersicht
- Themes-Tab mit Mini-Mockup pro Theme, Live-Switch beim Klick
- Eigene Themes als JSON in `pluginConfigs/HellionChat/themes/` — Beispiel-Vorlage wird beim ersten Start automatisch abgelegt
- Optional pro Theme eigene Chat-Channel-Farben mit Übernehmen/Behalten-Banner — niemals automatisch überschrieben
- Plugin-Icon zum Hellion-Forge-Hammer gewechselt
- Migration v13 → v14: alle User landen auf Hellion Arctic. Wer den Upstream-Look will, wählt Chat 2 Klassik in Settings → Themes
- Anleitung zum Schreiben eigener Themes: `docs/THEME-AUTHORING.md`
+7 -7
View File
@@ -39,10 +39,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
dotnet-version: 10.0.x
@@ -55,7 +55,7 @@ jobs:
Expand-Archive -Force -Path dalamud.zip -DestinationPath $hooks
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: csharp
build-mode: manual
@@ -68,7 +68,7 @@ jobs:
run: dotnet build HellionChat/HellionChat.csproj --configuration Release --no-restore
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: /language:csharp
@@ -79,15 +79,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: actions
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: /language:actions
+4
View File
@@ -11,6 +11,10 @@
.vscode/
scripts/
# Local test project (stays out of the published plugin repo;
# pure-function safety net for refactor cycles)
HellionChat.Tests/
# Packaging
pack/
+1 -1
View File
@@ -1,6 +1,6 @@
HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV
Copyright (c) 2024-2025 Infiziert90 (Infi) and Anna Clemens (ascclemens)
Copyright (c) 2022-2026 Infiziert90 (Infi) and Anna Clemens (ascclemens)
Original ChatTwo authors and copyright holders of the upstream
plugin this fork is built on. Their work covers the message store,
the channel filtering, the sidebar tab system, the FFXIV chat
+16 -1
View File
@@ -1,16 +1,31 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HellionChat", "HellionChat\HellionChat.csproj", "{739F75E6-B65F-41EF-9D90-F7BC519E4875}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.Build.0 = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x64.ActiveCfg = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x64.Build.0 = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x86.ActiveCfg = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x86.Build.0 = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.ActiveCfg = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.Build.0 = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x64.ActiveCfg = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x64.Build.0 = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x86.ActiveCfg = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+29 -2
View File
@@ -34,10 +34,23 @@ public class ConfigKeyBind
[Serializable]
public class Configuration : IPluginConfiguration
{
private const int LatestVersion = 12;
private const int LatestVersion = 14;
public int Version { get; set; } = LatestVersion;
// v1.1.0 — Theme-Engine. Slug-basiert; ThemeRegistry liefert das Objekt.
public string Theme = "hellion-arctic";
// v1.1.0 — Globale Window-Opacity, theme-übergreifend. Migration aus
// HellionThemeWindowOpacity beim Bump v13 → v14.
public float WindowOpacity = 0.85f;
// v1.1.0 — Felder für künftige UI-Toggles (v1.2.0 / v1.3.0). Werden
// vorab angelegt, damit später keine Migration nötig ist.
public bool ReduceMotion;
public bool UseCompactDensity;
public bool ShowThemeQuickPicker;
// Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default).
// Master-switch defaults to true; set false to restore upstream behavior.
public bool PrivacyFilterEnabled = true;
@@ -70,12 +83,14 @@ public class Configuration : IPluginConfiguration
// 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.51.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
@@ -279,7 +294,10 @@ public class Configuration : IPluginConfiguration
MaxLinesToRender = other.MaxLinesToRender;
Use24HourClock = other.Use24HourClock;
ShowEmotes = other.ShowEmotes;
BlockedEmotes = other.BlockedEmotes;
// Deep-copy the set so the live and mutable Configuration instances don't share state
// — a HashSet reference assignment would cause edits in the settings window to leak
// into the live config before the user clicks Save.
BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
FontsEnabled = other.FontsEnabled;
ItalicEnabled = other.ItalicEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges;
@@ -318,10 +336,19 @@ 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
Theme = other.Theme;
WindowOpacity = other.WindowOpacity;
ReduceMotion = other.ReduceMotion;
UseCompactDensity = other.UseCompactDensity;
ShowThemeQuickPicker = other.ShowThemeQuickPicker;
EnableAutoTellTabs = other.EnableAutoTellTabs;
AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
+58 -17
View File
@@ -66,16 +66,29 @@ public static class EmoteCache
public static string[] SortedCodeArray = [];
// Plugin-scoped cancellation source for in-flight emote loads. Dispose
// cancels every running download/texture-create so the workers don't
// touch a torn-down TextureProvider on plugin reload. Replaced with a
// fresh source on the next LoadData() call so a re-enable still works.
private static CancellationTokenSource Cts = new();
internal static CancellationToken Token => Cts.Token;
public static async Task LoadData()
{
if (State is not LoadingState.Unloaded)
return;
// Refresh the CTS in case Dispose was called and we're being re-enabled
// in the same process (Dalamud /xlplugins toggle).
if (Cts.IsCancellationRequested)
Cts = new CancellationTokenSource();
State = LoadingState.Loading;
var ct = Cts.Token;
try
{
var global = await Client.GetAsync(GlobalEmotes);
var globalList = await global.Content.ReadAsStringAsync();
var global = await Client.GetAsync(GlobalEmotes, ct);
var globalList = await global.Content.ReadAsStringAsync(ct);
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
@@ -84,8 +97,8 @@ public static class EmoteCache
var lastId = string.Empty;
for (var i = 0; i < 15; i++)
{
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId));
var topList = await top.Content.ReadAsStringAsync();
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
var topList = await top.Content.ReadAsStringAsync(ct);
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
// BetterTTV occasionally returns entries with a null Code; the
@@ -103,6 +116,12 @@ public static class EmoteCache
SortedCodeArray = Cache.Keys.Order().ToArray();
State = LoadingState.Done;
}
catch (OperationCanceledException)
{
// Plugin disposed while the cache was loading; leave State on
// Loading so a subsequent re-enable can re-issue LoadData with
// a fresh CTS (handled above).
}
catch (Exception ex)
{
// Reset to Unloaded so a later trigger (e.g. the user reopening
@@ -116,6 +135,10 @@ public static class EmoteCache
public static void Dispose()
{
// Cancel in-flight downloads / texture creates so the async-void
// Load methods bail out before they touch a disposed TextureProvider.
Cts.Cancel();
foreach (var emote in EmoteImages.Values)
emote.InnerDispose();
}
@@ -171,7 +194,7 @@ public static class EmoteCache
ImGui.Image(Texture!.Handle, size);
}
internal async Task<byte[]> LoadAsync(Emote emote)
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
{
// BetterTTV-supplied Id and ImageType are interpolated straight
// into the filename. HTTPS protects the wire, but a compromised
@@ -188,15 +211,15 @@ public static class EmoteCache
if (File.Exists(filePath))
{
RawData = await File.ReadAllBytesAsync(filePath);
RawData = await File.ReadAllBytesAsync(filePath, ct);
}
else
{
var content = await Client.GetAsync(EmotePath.Format(emote.Id));
RawData = await content.Content.ReadAsByteArrayAsync();
var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
RawData = await content.Content.ReadAsByteArrayAsync(ct);
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
stream.Write(RawData, 0, RawData.Length);
await stream.WriteAsync(RawData, ct);
}
return RawData;
@@ -209,21 +232,28 @@ public static class EmoteCache
{
public ImGuiEmote Prepare(Emote emote)
{
Task.Run(() => Load(emote));
var ct = EmoteCache.Token;
Task.Run(() => Load(emote, ct), ct);
return this;
}
private async void Load(Emote emote)
private async void Load(Emote emote, CancellationToken ct)
{
try
{
var image = await LoadAsync(emote);
var image = await LoadAsync(emote, ct);
if (image.Length <= 0)
return;
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image);
ct.ThrowIfCancellationRequested();
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image, cancellationToken: ct);
IsLoaded = true;
}
catch (OperationCanceledException)
{
// Plugin disposed mid-load; the EmoteImages entry is also
// being torn down, no extra cleanup needed.
}
catch (Exception ex)
{
Failed = true;
@@ -279,15 +309,16 @@ public static class EmoteCache
public ImGuiGif Prepare(Emote emote)
{
Task.Run(() => Load(emote));
var ct = EmoteCache.Token;
Task.Run(() => Load(emote, ct), ct);
return this;
}
private async void Load(Emote emote)
private async void Load(Emote emote, CancellationToken ct)
{
try
{
var image = await LoadAsync(emote);
var image = await LoadAsync(emote, ct);
if (image.Length <= 0)
return;
@@ -299,6 +330,8 @@ public static class EmoteCache
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
foreach (var frame in img.Frames)
{
ct.ThrowIfCancellationRequested();
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
@@ -307,13 +340,21 @@ public static class EmoteCache
var buffer = new byte[4 * frame.Width * frame.Height];
frame.CopyPixelDataTo(buffer);
var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer);
var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer, cancellationToken: ct);
frames.Add((tex, delay));
}
Frames = frames;
IsLoaded = true;
}
catch (OperationCanceledException)
{
// Plugin disposed mid-load; partial frames are released by
// InnerDispose on the next dispose pass.
foreach (var f in Frames)
f.Texture.Dispose();
Frames = [];
}
catch (Exception ex)
{
Failed = true;
-28
View File
@@ -18,8 +18,6 @@ public class FontManager
internal IFontHandle FontAwesome = null!;
internal readonly byte[] GameSymFont;
private ushort[] Ranges = [];
private ushort[] JpRange = [];
@@ -30,32 +28,6 @@ public class FontManager
36f, 40f, 45f, 46f, 68f, 90f,
];
public FontManager()
{
var filePath = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "FFXIV_Lodestone_SSF.ttf");
if (File.Exists(filePath))
{
GameSymFont = File.ReadAllBytes(filePath);
}
else
{
// Dispose HttpClient and HttpResponseMessage to avoid socket
// exhaustion on repeated cold-start downloads. GetAwaiter().GetResult()
// unwraps AggregateException so failures surface cleanly. A full
// async refactor of the constructor would be cleaner but is out of
// scope for v1.0.0 — tracked in the backlog.
using var client = new HttpClient();
using var response = client
.GetAsync("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")
.GetAwaiter()
.GetResult();
response.EnsureSuccessStatusCode();
GameSymFont = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
Dalamud.Utility.FilesystemUtil.WriteAllBytesSafe(filePath, GameSymFont);
}
}
/// <summary>
/// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily
/// extracted from the assembly's manifest resources on first use; the
+4 -2
View File
@@ -252,7 +252,7 @@ internal sealed unsafe class Chat : IDisposable
{
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
worldId = agent->TellWorldId;
Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}");
Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}");
}
Plugin.CurrentTab.CurrentChannel = new UsedChannel
@@ -400,7 +400,9 @@ internal sealed unsafe class Chat : IDisposable
}
var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell);
return channel + idx;
// RotateLinkshell returns null when no valid linkshell is found within 8 iterations.
// Forward the null so the caller can keep the existing channel instead of crashing on nullable arithmetic.
return idx is null ? null : channel + idx.Value;
}
default:
return channel;
+13 -1
View File
@@ -245,7 +245,8 @@ internal unsafe class GameFunctions : IDisposable
vf0(agent, &result, &value, 0, 0);
}
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(128);
private const int PlaceholderBufferSize = 128;
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(PlaceholderBufferSize);
private readonly string Placeholder = $"<{Guid.NewGuid():N}>";
private string? ReplacementName;
@@ -261,6 +262,17 @@ internal unsafe class GameFunctions : IDisposable
if (ReplacementName == null || placeholder != Placeholder)
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
// The fixed buffer is 128 bytes; UTF-8 + null-terminator must fit.
// FFXIV player names plus an @World suffix should never approach this
// limit, but a malformed ReplacementName must not overflow the buffer.
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
if (byteCount >= PlaceholderBufferSize)
{
Plugin.Log.Warning($"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original.");
ReplacementName = null;
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
}
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
ReplacementName = null;
@@ -20,7 +20,7 @@ public class TellTarget
}
public bool IsSet()
=> Name.Length > 0 && World > 0;
=> !string.IsNullOrEmpty(Name) && World > 0;
public string ToWorldString()
=> Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty;
+19 -4
View File
@@ -4,8 +4,9 @@
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. -->
<Version>1.0.3</Version>
<Version>1.1.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Honor packages.lock.json on restore so floating version ranges
don't silently drift between machines or CI runs. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
@@ -18,7 +19,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<!-- Closed ranges on packages with breaking-change history block a
surprise major bump when the lock file is regenerated. The
lock file pins the exact version per build; the upper bound
keeps the unlock path from drifting across major lines. -->
<PackageReference Include="MessagePack" Version="[3.1.4, 4.0.0)" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<!-- Override the transitively-referenced native SQLite build to one
that ships SQLite >= 3.50.3 (CVE-2025-6965 memory corruption,
@@ -28,8 +33,15 @@
without a major bump on the managed wrapper. -->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageReference Include="morelinq" Version="4.4.0" />
<PackageReference Include="Pidgin" Version="3.5.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" />
<PackageReference Include="SixLabors.ImageSharp" Version="[3.1.12, 4.0.0)" />
</ItemGroup>
<ItemGroup>
<!-- Pure-function test suites in HellionChat.Tests need access to
the internal helper classes (StringUtil, UriPayload, Tokenizer
etc.). Test assembly does not get redistributed. -->
<InternalsVisibleTo Include="HellionChat.Tests" />
</ItemGroup>
<ItemGroup>
@@ -63,6 +75,9 @@
<EmbeddedResource Include="Resources\HellionFont-OFL.txt">
<LogicalName>HellionFont-OFL.txt</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Themes\Builtin\example-theme.json">
<LogicalName>HellionChat.Themes.Builtin.example-theme.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
+65 -1
View File
@@ -31,6 +31,11 @@ description: |-
- Independent plugin state — own config file and database directory,
so Hellion Chat does not share state with upstream Chat 2
v1.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.
Based on Chat 2 by Infi and Anna, licensed under EUPL-1.2.
Modding & support: join the Hellion Forge Discord at
@@ -41,7 +46,8 @@ accepts_feedback: true
icon_url: https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/icon.png
image_urls:
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/chatWindow.png
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/withSimpleTweaks.png
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/settingsOverview.png
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/themesPicker.png
tags:
- Social
- UI
@@ -49,6 +55,64 @@ tags:
- Replacement
- Privacy
changelog: |-
**Hellion Chat 1.1.0 — Theme Foundation**
First major UI cycle after the standalone v1.0.0 cut. Theme engine,
five built-in themes, customisable JSON themes, modernised settings
layout.
New themes (Settings → Themes):
- **Hellion Arctic** — the brand default, Arctic Cyan + Ember Glow
on industrial slate.
- **Chat 2 Klassik** — Steel Blue on neutral grey, eckige Kanten.
The upstream Chat 2 look on the new engine.
- **Event Horizon** — Cosmic Purple on near-black. Deep-space mood.
- **Moonlit Bloom** — Bloom Magenta + Soft Sage on deep-violet
night.
- **Mint Grove** — Mint Green + Honey Amber on deep forest. First
member of the Grove family.
Theme engine highlights:
- Slug-based selection in Settings → Themes with mini-mockup
previews per theme.
- Click a theme card and the whole plugin (chat, settings,
pop-outs, viewer) repaints instantly.
- Custom themes via JSON in pluginConfigs/HellionChat/themes/.
Example template seeded on first launch.
- 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.
- Migration v13 → v14: existing users land on Hellion Arctic. Pick
Chat 2 Klassik to keep the upstream look.
Settings layout:
- New card-grid overview on Settings open. Click a card to drill
into the section.
- Breadcrumb back to overview, ESC also returns.
- Detail view drops the redundant tab list — section content uses
the full width.
Branding:
- Plugin icon swapped from the ChatTwo derivative to the Hellion
Forge hammer.
- New docs/THEME-AUTHORING.md walks you through writing your own
themes with the Forge logo on top.
Technical:
- HellionStyle.PushGlobal is now theme-driven. Configuration.
HellionThemeEnabled is deprecated and will be removed in v1.2.0.
- New ThemeRegistry singleton with LastWriteTime-cached custom-
theme loader.
- 51 local unit tests cover the data model, registry, JSON round-
trip and built-in sanity checks.
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 1.0.3 — Polish patch**
- New: optionally hide chat (and every other plugin window) while the
+9 -4
View File
@@ -20,10 +20,14 @@ public sealed class ExtraChat : IDisposable
internal (string, uint)? ChannelOverride { get; set; }
private Dictionary<string, uint> ChannelCommandColoursInternal { get; set; } = new();
// Volatile reference: IPC callbacks (OnChannelCommandColours/OnChannelNames) fire on a
// Dalamud-dispatcher thread while the ImGui thread reads the IReadOnlyDictionary projections.
// Reference assignment is atomic on x64, but the JIT (especially Mono on Wine/Linux) needs
// the volatile barrier to guarantee visibility across threads. See AUDIT-2026-05-05 [SEC-01].
private volatile Dictionary<string, uint> ChannelCommandColoursInternal = new();
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal;
private Dictionary<Guid, string> ChannelNamesInternal { get; set; } = new();
private volatile Dictionary<Guid, string> ChannelNamesInternal = new();
internal IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal;
internal ExtraChat()
@@ -40,9 +44,10 @@ public sealed class ExtraChat : IDisposable
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!);
}
catch (Exception)
catch (Exception ex)
{
// no-op
// ExtraChat is optional; missing IPC peer is normal when the plugin isn't loaded.
Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)");
}
}
+17 -7
View File
@@ -34,7 +34,10 @@ internal class MessageManager : IAsyncDisposable
// After that, the message is enqueued in the PendingAsync queue, which will
// be consumed in a separate thread and perform more processing (emotes,
// URLs) as well as inserting the message into the database.
private Queue<PendingMessage> PendingSync { get; } = [];
// LinkedList instead of Queue: ContentIdResolver hits PendingSync.Last
// every hook call. Queue<T>.Last() is the LINQ extension and walks the
// whole queue (O(n)); LinkedList<T>.Last is an O(1) node reference.
private LinkedList<PendingMessage> PendingSync { get; } = [];
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
private readonly Thread PendingMessageThread;
private readonly CancellationTokenSource PendingThreadCancellationToken = new();
@@ -93,6 +96,10 @@ internal class MessageManager : IAsyncDisposable
Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive");
}
// CancellationTokenSource owns an unmanaged WaitHandle; dispose after the
// worker thread has drained, otherwise it leaks across plugin reloads.
PendingThreadCancellationToken.Dispose();
Store.Dispose();
}
@@ -113,8 +120,11 @@ internal class MessageManager : IAsyncDisposable
LastContentId = contentId;
// Drain the PendingSync queue into the PendingAsync queue.
while (PendingSync.TryDequeue(out var pending))
PendingAsync.Enqueue(pending);
while (PendingSync.First is { } first)
{
PendingSync.RemoveFirst();
PendingAsync.Enqueue(first.Value);
}
}
private void ProcessPendingMessages(CancellationToken token)
@@ -219,7 +229,7 @@ internal class MessageManager : IAsyncDisposable
// We delay messages to be handed off to the async processing thread
// in the next tick, otherwise we can't get the content ID from the hook
// below.
PendingSync.Enqueue(pendingMessage);
PendingSync.AddLast(pendingMessage);
}
// This hook is called immediately after receiving a message with the
@@ -231,11 +241,11 @@ internal class MessageManager : IAsyncDisposable
try
{
ContentIdResolverHook?.Original(agent, contentId, accountId, messageIndex, worldId, chatType);
if (PendingSync.Count == 0)
if (PendingSync.Last is not { } last)
return;
PendingSync.Last().ContentId = contentId;
PendingSync.Last().AccountId = accountId;
last.Value.ContentId = contentId;
last.Value.AccountId = accountId;
}
catch (Exception ex)
{
+4 -1
View File
@@ -452,7 +452,10 @@ internal class MessageStore : IDisposable
// covers any future write paths e.g. webinterface backfill).
if (!Plugin.Config.IsAllowedForStorage(message.Code.Type))
{
Plugin.Log.Debug($"Privacy filter dropped message: ChatType={message.Code.Type}");
// Verbose-only: this fires for every dropped message, which is
// the common case for users with a tight privacy whitelist. Keep
// it for diagnostics but stay out of the default xllog stream.
Plugin.Log.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}");
return;
}
+72 -8
View File
@@ -63,6 +63,7 @@ public sealed class Plugin : IDalamudPlugin
internal ExtraChat ExtraChat { get; }
internal TypingIpc TypingIpc { get; }
internal FontManager FontManager { get; }
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
internal int DeferredSaveFrames = -1;
@@ -237,6 +238,27 @@ public sealed class Plugin : IDalamudPlugin
});
}
// Hellion Chat v13 → v14 — theme-engine migration. Alle User landen
// auf "hellion-arctic" als neues Default-Theme; die alte
// HellionThemeEnabled-Flag wird deprecated und nur noch ein Release
// als Safety-Net im JSON behalten. Window-Opacity wandert von
// HellionThemeWindowOpacity in das neue WindowOpacity-Feld.
if (Config.Version < 14)
{
Config.Theme = "hellion-arctic";
#pragma warning disable CS0612, CS0618 // Obsolete: HellionThemeWindowOpacity bleibt readable bis v1.2.0
Config.WindowOpacity = Config.HellionThemeWindowOpacity;
#pragma warning restore CS0612, CS0618
Config.ReduceMotion = false;
Config.UseCompactDensity = false;
Config.ShowThemeQuickPicker = false;
Config.Version = 14;
SaveConfig();
Log.Information(
"Migrated config v13 → v14: theme engine introduced, all users land on hellion-arctic; " +
"pick chat2-classic in Settings → Themes for the upstream look");
}
// Hellion v1.0.0 default tab layout. Five thematically separated
// tabs: General catches the immediate-surroundings public chat
// (Say/Yell/Shout) only; System absorbs the rest of the technical
@@ -266,6 +288,14 @@ public sealed class Plugin : IDalamudPlugin
ExtraChat = new ExtraChat();
FontManager = new FontManager();
// v1.1.0 — Theme-Engine init. Custom-Themes liegen in
// pluginConfigs/HellionChat/themes/, lazy geladen beim ersten Get.
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
Directory.CreateDirectory(customThemesDir);
SeedExampleThemeIfEmpty(customThemesDir);
ThemeRegistry = new Themes.ThemeRegistry(customThemesDir);
ThemeRegistry.Switch(Config.Theme);
MessageManager = new MessageManager(this); // Does it require UI?
// Hellion Chat — Auto-Tell-Tabs service. Subscribes to the
@@ -529,10 +559,15 @@ public sealed class Plugin : IDalamudPlugin
if (deleted > 0)
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run the clear+refilter synchronously on the framework thread.
// Earlier this called FilterAllTabsAsync(), which is fire-and-forget
// — the .Wait() here would return as soon as the inner Task.Run was
// dispatched, racing the next sweep cycle against the still-running
// filter pass. See AUDIT-2026-05-05 [QUAL-02].
Framework.Run(() =>
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabsAsync();
MessageManager.FilterAllTabs();
}).Wait();
}
else
@@ -554,13 +589,10 @@ public sealed class Plugin : IDalamudPlugin
private void Draw()
{
// Hellion theme is pushed once per frame here so every plugin window
// (chat log, settings, viewers, wizard, file dialog) renders with
// the same palette. Skipping the push leaves the upstream Dalamud
// look untouched for users who flipped the toggle off.
using IDisposable? _style = Config.HellionThemeEnabled
? HellionStyle.PushGlobal(Config.HellionThemeWindowOpacity)
: null;
// Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes
// Theme statt einem deaktivierten Hellion-Theme. Active wird einmal
// pro Frame aus der Registry gelesen.
using IDisposable _style = HellionStyle.PushGlobal(ThemeRegistry.Active, Config.WindowOpacity);
ChatLogWindow.BeginFrame();
@@ -645,4 +677,36 @@ public sealed class Plugin : IDalamudPlugin
public static bool InBattle => Condition[ConditionFlag.InCombat];
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
public static bool CutsceneActive => Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78];
// v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded
// example-theme.json als Vorlage rein. Bestehende User-Customs werden
// nicht angefasst (existing JSONs lassen den Block überspringen).
private static void SeedExampleThemeIfEmpty(string dir)
{
if (Directory.EnumerateFiles(dir, "*.json").Any())
return;
var examplePath = Path.Combine(dir, "example-theme.json");
var resourceStream = typeof(Plugin).Assembly.GetManifestResourceStream("HellionChat.Themes.Builtin.example-theme.json");
if (resourceStream is null)
{
Log.Warning("Themes example template not found in assembly resources; skipping seed.");
return;
}
try
{
using var fileStream = File.Create(examplePath);
resourceStream.CopyTo(fileStream);
Log.Information($"Seeded example-theme.json into {dir}");
}
catch (IOException ex)
{
Log.Warning(ex, "Failed to seed example-theme.json; user can create custom themes manually.");
}
finally
{
resourceStream.Dispose();
}
}
}
+31
View File
@@ -203,6 +203,37 @@ internal class HellionStrings
internal static string Settings_Tab_Database => Get(nameof(Settings_Tab_Database));
internal static string Settings_Tab_Information => Get(nameof(Settings_Tab_Information));
// v1.1.0 — Settings card-grid overview
internal static string Settings_Card_General_Title => Get(nameof(Settings_Card_General_Title));
internal static string Settings_Card_General_Subtext => Get(nameof(Settings_Card_General_Subtext));
internal static string Settings_Card_Appearance_Title => Get(nameof(Settings_Card_Appearance_Title));
internal static string Settings_Card_Appearance_Subtext => Get(nameof(Settings_Card_Appearance_Subtext));
internal static string Settings_Card_Themes_Title => Get(nameof(Settings_Card_Themes_Title));
internal static string Settings_Card_Themes_Subtext => Get(nameof(Settings_Card_Themes_Subtext));
internal static string Settings_Card_Window_Title => Get(nameof(Settings_Card_Window_Title));
internal static string Settings_Card_Window_Subtext => Get(nameof(Settings_Card_Window_Subtext));
internal static string Settings_Card_Chat_Title => Get(nameof(Settings_Card_Chat_Title));
internal static string Settings_Card_Chat_Subtext => Get(nameof(Settings_Card_Chat_Subtext));
internal static string Settings_Card_Tabs_Title => Get(nameof(Settings_Card_Tabs_Title));
internal static string Settings_Card_Tabs_Subtext => Get(nameof(Settings_Card_Tabs_Subtext));
internal static string Settings_Card_Privacy_Title => Get(nameof(Settings_Card_Privacy_Title));
internal static string Settings_Card_Privacy_Subtext => Get(nameof(Settings_Card_Privacy_Subtext));
internal static string Settings_Card_Database_Title => Get(nameof(Settings_Card_Database_Title));
internal static string Settings_Card_Database_Subtext => Get(nameof(Settings_Card_Database_Subtext));
internal static string Settings_Card_Information_Title => Get(nameof(Settings_Card_Information_Title));
internal static string Settings_Card_Information_Subtext => Get(nameof(Settings_Card_Information_Subtext));
// v1.1.0 — Themes-Settings-Tab
internal static string Settings_Tab_Themes => Get(nameof(Settings_Tab_Themes));
internal static string Settings_Themes_Active => Get(nameof(Settings_Themes_Active));
internal static string Settings_Themes_BuiltIns => Get(nameof(Settings_Themes_BuiltIns));
internal static string Settings_Themes_Custom => Get(nameof(Settings_Themes_Custom));
internal static string Settings_Themes_OpenFolder => Get(nameof(Settings_Themes_OpenFolder));
internal static string Settings_Themes_ExportActive => Get(nameof(Settings_Themes_ExportActive));
internal static string Settings_Themes_ApplyChatColors_Hint => Get(nameof(Settings_Themes_ApplyChatColors_Hint));
internal static string Settings_Themes_ApplyChatColors_Apply => Get(nameof(Settings_Themes_ApplyChatColors_Apply));
internal static string Settings_Themes_ApplyChatColors_Keep => Get(nameof(Settings_Themes_ApplyChatColors_Keep));
// Hellion Chat — General-Tab section headings
internal static string Settings_General_Input_Heading => Get(nameof(Settings_General_Input_Heading));
internal static string Settings_General_Audio_Heading => Get(nameof(Settings_General_Audio_Heading));
@@ -624,4 +624,85 @@
<data name="ChatTwoConflictAction" xml:space="preserve">
<value>Chat 2 in /xlplugins deaktivieren, danach Hellion Chat erneut aktivieren.</value>
</data>
<data name="Settings_Card_General_Title" xml:space="preserve">
<value>Allgemein</value>
</data>
<data name="Settings_Card_General_Subtext" xml:space="preserve">
<value>Sprache und grundlegendes Verhalten</value>
</data>
<data name="Settings_Card_Appearance_Title" xml:space="preserve">
<value>Erscheinungsbild</value>
</data>
<data name="Settings_Card_Appearance_Subtext" xml:space="preserve">
<value>Fensterdeckkraft, Schriften, Bewegung</value>
</data>
<data name="Settings_Card_Themes_Title" xml:space="preserve">
<value>Themes</value>
</data>
<data name="Settings_Card_Themes_Subtext" xml:space="preserve">
<value>Theme wählen oder eigenes importieren</value>
</data>
<data name="Settings_Card_Window_Title" xml:space="preserve">
<value>Fenster</value>
</data>
<data name="Settings_Card_Window_Subtext" xml:space="preserve">
<value>Fensterposition, Rahmen, Hide-Zustände</value>
</data>
<data name="Settings_Card_Chat_Title" xml:space="preserve">
<value>Chat</value>
</data>
<data name="Settings_Card_Chat_Subtext" xml:space="preserve">
<value>Chat-Verhalten, Emotes, Auto-Tells</value>
</data>
<data name="Settings_Card_Tabs_Title" xml:space="preserve">
<value>Tabs</value>
</data>
<data name="Settings_Card_Tabs_Subtext" xml:space="preserve">
<value>Tab-Layout, Kanäle, eigene Tabs</value>
</data>
<data name="Settings_Card_Privacy_Title" xml:space="preserve">
<value>Datenschutz</value>
</data>
<data name="Settings_Card_Privacy_Subtext" xml:space="preserve">
<value>Filter, Aufbewahrung, Bereinigung, Export</value>
</data>
<data name="Settings_Card_Database_Title" xml:space="preserve">
<value>Datenbank</value>
</data>
<data name="Settings_Card_Database_Subtext" xml:space="preserve">
<value>Speicher, Migration, alte Bereinigung</value>
</data>
<data name="Settings_Card_Information_Title" xml:space="preserve">
<value>Information</value>
</data>
<data name="Settings_Card_Information_Subtext" xml:space="preserve">
<value>Über, Mitwirkende, Support</value>
</data>
<data name="Settings_Tab_Themes" xml:space="preserve">
<value>Themes</value>
</data>
<data name="Settings_Themes_Active" xml:space="preserve">
<value>Aktiv: {0}</value>
</data>
<data name="Settings_Themes_BuiltIns" xml:space="preserve">
<value>Eingebaute Themes</value>
</data>
<data name="Settings_Themes_Custom" xml:space="preserve">
<value>Eigene Themes</value>
</data>
<data name="Settings_Themes_OpenFolder" xml:space="preserve">
<value>Themes-Ordner öffnen</value>
</data>
<data name="Settings_Themes_ExportActive" xml:space="preserve">
<value>Aktives exportieren...</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Hint" xml:space="preserve">
<value>Dieses Theme schlägt eigene Channel-Farben vor.</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Apply" xml:space="preserve">
<value>Übernehmen</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Keep" xml:space="preserve">
<value>Behalten</value>
</data>
</root>
+81
View File
@@ -624,4 +624,85 @@
<data name="ChatTwoConflictAction" xml:space="preserve">
<value>Disable Chat 2 in /xlplugins, then re-enable Hellion Chat.</value>
</data>
<data name="Settings_Card_General_Title" xml:space="preserve">
<value>General</value>
</data>
<data name="Settings_Card_General_Subtext" xml:space="preserve">
<value>Language and basic behaviour</value>
</data>
<data name="Settings_Card_Appearance_Title" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="Settings_Card_Appearance_Subtext" xml:space="preserve">
<value>Window opacity, fonts, motion</value>
</data>
<data name="Settings_Card_Themes_Title" xml:space="preserve">
<value>Themes</value>
</data>
<data name="Settings_Card_Themes_Subtext" xml:space="preserve">
<value>Choose a theme or import your own</value>
</data>
<data name="Settings_Card_Window_Title" xml:space="preserve">
<value>Window</value>
</data>
<data name="Settings_Card_Window_Subtext" xml:space="preserve">
<value>Window position, frame, hide states</value>
</data>
<data name="Settings_Card_Chat_Title" xml:space="preserve">
<value>Chat</value>
</data>
<data name="Settings_Card_Chat_Subtext" xml:space="preserve">
<value>Chat behaviour, emotes, auto-tells</value>
</data>
<data name="Settings_Card_Tabs_Title" xml:space="preserve">
<value>Tabs</value>
</data>
<data name="Settings_Card_Tabs_Subtext" xml:space="preserve">
<value>Tab layout, channels, custom tabs</value>
</data>
<data name="Settings_Card_Privacy_Title" xml:space="preserve">
<value>Privacy</value>
</data>
<data name="Settings_Card_Privacy_Subtext" xml:space="preserve">
<value>Filter, retention, cleanup, export</value>
</data>
<data name="Settings_Card_Database_Title" xml:space="preserve">
<value>Database</value>
</data>
<data name="Settings_Card_Database_Subtext" xml:space="preserve">
<value>Storage, migration, legacy cleanup</value>
</data>
<data name="Settings_Card_Information_Title" xml:space="preserve">
<value>Information</value>
</data>
<data name="Settings_Card_Information_Subtext" xml:space="preserve">
<value>About, credits, support</value>
</data>
<data name="Settings_Tab_Themes" xml:space="preserve">
<value>Themes</value>
</data>
<data name="Settings_Themes_Active" xml:space="preserve">
<value>Active: {0}</value>
</data>
<data name="Settings_Themes_BuiltIns" xml:space="preserve">
<value>Built-in themes</value>
</data>
<data name="Settings_Themes_Custom" xml:space="preserve">
<value>Custom themes</value>
</data>
<data name="Settings_Themes_OpenFolder" xml:space="preserve">
<value>Open themes folder</value>
</data>
<data name="Settings_Themes_ExportActive" xml:space="preserve">
<value>Export active...</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Hint" xml:space="preserve">
<value>This theme suggests its own chat channel colours.</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Apply" xml:space="preserve">
<value>Apply</value>
</data>
<data name="Settings_Themes_ApplyChatColors_Keep" xml:space="preserve">
<value>Keep current</value>
</data>
</root>
@@ -0,0 +1,50 @@
using HellionChat.Util;
namespace HellionChat.Themes.Builtin;
internal static class Chat2Classic
{
public const string Slug = "chat2-classic";
public static Theme Build() => new(
Slug: Slug,
Name: "Chat 2 Klassik",
Author: "Upstream (Infi & Anna)",
Description: "Steel-blue accents on neutral dark grey, eckige Kanten. Vertraut für ChatTwo-Veteranen.",
Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#3D6E92"),
Primary: ColourUtil.HexToRgba("#4682B4"),
PrimaryLight: ColourUtil.HexToRgba("#5C9DC8"),
PrimaryGlow: ColourUtil.HexToRgba("#4682B466"),
AccentDark: ColourUtil.HexToRgba("#3D6E92"),
Accent: ColourUtil.HexToRgba("#4682B4"),
AccentLight: ColourUtil.HexToRgba("#5C9DC8"),
Identity: ColourUtil.HexToRgba("#4682B4"),
WindowBg: ColourUtil.HexToRgba("#0F0F0FF2"),
ChildBg: ColourUtil.HexToRgba("#141414"),
FrameBg: ColourUtil.HexToRgba("#1A1A1A"),
Surface: ColourUtil.HexToRgba("#202020"),
SurfaceHover: ColourUtil.HexToRgba("#2C2C2C"),
Border: ColourUtil.HexToRgba("#404040"),
TextPrimary: ColourUtil.HexToRgba("#E6E6E6"),
TextMuted: ColourUtil.HexToRgba("#999999"),
TextDim: ColourUtil.HexToRgba("#666666"),
StatusSuccess: ColourUtil.HexToRgba("#5CB85C"),
StatusDanger: ColourUtil.HexToRgba("#D9534F"),
StatusWarning: ColourUtil.HexToRgba("#F0AD4E"),
StatusInfo: ColourUtil.HexToRgba("#4682B4")
),
Layout: new ThemeLayout(
WindowRounding: 0f, ChildRounding: 0f, PopupRounding: 0f,
FrameRounding: 0f, GrabRounding: 0f, TabRounding: 0f,
ScrollbarRounding: 0f, WindowBorderSize: 1f, FrameBorderSize: 1f
),
Typography: new ThemeTypography(),
IsBuiltIn: true
);
}
@@ -0,0 +1,77 @@
using HellionChat.Util;
namespace HellionChat.Themes.Builtin;
internal static class EventHorizon
{
public const string Slug = "event-horizon";
public static Theme Build() => new(
Slug: Slug,
Name: "Event Horizon",
Author: "Hellion Online Media",
Description: "Cosmic Purple auf Near-Black. Deep-Space-Stimmung.",
Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#7B3FCF"),
Primary: ColourUtil.HexToRgba("#9D5CFF"),
PrimaryLight: ColourUtil.HexToRgba("#B585FF"),
PrimaryGlow: ColourUtil.HexToRgba("#9D5CFF99"),
AccentDark: ColourUtil.HexToRgba("#C9982E"),
Accent: ColourUtil.HexToRgba("#E0AB36"),
AccentLight: ColourUtil.HexToRgba("#F2C25C"),
Identity: ColourUtil.HexToRgba("#9D5CFF"),
WindowBg: ColourUtil.HexToRgba("#040308"),
ChildBg: ColourUtil.HexToRgba("#0A081A"),
FrameBg: ColourUtil.HexToRgba("#140F23"),
Surface: ColourUtil.HexToRgba("#1B1530"),
SurfaceHover: ColourUtil.HexToRgba("#251D40"),
Border: ColourUtil.HexToRgba("#9D5CFF44"),
TextPrimary: ColourUtil.HexToRgba("#E6E0F5"),
TextMuted: ColourUtil.HexToRgba("#9890B5"),
TextDim: ColourUtil.HexToRgba("#5A5570"),
StatusSuccess: ColourUtil.HexToRgba("#26A269"),
StatusDanger: ColourUtil.HexToRgba("#ED333B"),
StatusWarning: ColourUtil.HexToRgba("#E0AB36"),
StatusInfo: ColourUtil.HexToRgba("#9D5CFF")
),
Layout: new ThemeLayout(
WindowRounding: 6f, ChildRounding: 5f, PopupRounding: 5f,
FrameRounding: 4f, GrabRounding: 4f, TabRounding: 4f,
ScrollbarRounding: 4f, WindowBorderSize: 1f, FrameBorderSize: 1f
),
Typography: new ThemeTypography(),
IsBuiltIn: true,
ChatColors: new ThemeChatColors(new Dictionary<HellionChat.Code.ChatType, uint>
{
// Event Horizon — Cosmic-Purple-Drift: helle Pastelle bekommen
// Lavender-Tinte, Akzent-Channels (Tell) ziehen Richtung Magenta-
// Lila. Channel-Identität bleibt klar erkennbar.
[HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#E6E0F5"),
[HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#F2C25C"),
[HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#FF9050"),
[HellionChat.Code.ChatType.TellIncoming] = ColourUtil.HexToRgba("#E090FF"),
[HellionChat.Code.ChatType.TellOutgoing] = ColourUtil.HexToRgba("#E090FF"),
[HellionChat.Code.ChatType.Party] = ColourUtil.HexToRgba("#90A0FF"),
[HellionChat.Code.ChatType.Alliance] = ColourUtil.HexToRgba("#FFAA80"),
[HellionChat.Code.ChatType.FreeCompany] = ColourUtil.HexToRgba("#9090E8"),
[HellionChat.Code.ChatType.NoviceNetwork] = ColourUtil.HexToRgba("#A0E090"),
[HellionChat.Code.ChatType.CrossParty] = ColourUtil.HexToRgba("#90A0FF"),
[HellionChat.Code.ChatType.Linkshell1] = ColourUtil.HexToRgba("#A0E090"),
[HellionChat.Code.ChatType.Linkshell2] = ColourUtil.HexToRgba("#F0B070"),
[HellionChat.Code.ChatType.Linkshell3] = ColourUtil.HexToRgba("#F2C25C"),
[HellionChat.Code.ChatType.Linkshell4] = ColourUtil.HexToRgba("#80E0B0"),
[HellionChat.Code.ChatType.Linkshell5] = ColourUtil.HexToRgba("#90A0FF"),
[HellionChat.Code.ChatType.Linkshell6] = ColourUtil.HexToRgba("#B585FF"),
[HellionChat.Code.ChatType.Linkshell7] = ColourUtil.HexToRgba("#E090FF"),
[HellionChat.Code.ChatType.Linkshell8] = ColourUtil.HexToRgba("#D0A0F0"),
[HellionChat.Code.ChatType.CustomEmote] = ColourUtil.HexToRgba("#E0B870"),
[HellionChat.Code.ChatType.StandardEmote] = ColourUtil.HexToRgba("#E0B870"),
[HellionChat.Code.ChatType.Echo] = ColourUtil.HexToRgba("#9890B5"),
})
);
}
@@ -0,0 +1,76 @@
using HellionChat.Util;
namespace HellionChat.Themes.Builtin;
internal static class HellionArctic
{
public const string Slug = "hellion-arctic";
public static Theme Build() => new(
Slug: Slug,
Name: "Hellion Arctic",
Author: "Hellion Online Media",
Description: "Arctic Cyan + Ember Glow on industrial slate. Plugin default.",
Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#0097A7"),
Primary: ColourUtil.HexToRgba("#00BED2"),
PrimaryLight: ColourUtil.HexToRgba("#4DD9E8"),
PrimaryGlow: ColourUtil.HexToRgba("#00BED299"),
AccentDark: ColourUtil.HexToRgba("#E85D04"),
Accent: ColourUtil.HexToRgba("#F97316"),
AccentLight: ColourUtil.HexToRgba("#FB923C"),
Identity: ColourUtil.HexToRgba("#0097A7"),
WindowBg: ColourUtil.HexToRgba("#070B12"),
ChildBg: ColourUtil.HexToRgba("#0C1220"),
FrameBg: ColourUtil.HexToRgba("#141E30"),
Surface: ColourUtil.HexToRgba("#1A2538"),
SurfaceHover: ColourUtil.HexToRgba("#22303F"),
Border: ColourUtil.HexToRgba("#00BED266"),
TextPrimary: ColourUtil.HexToRgba("#E6F4F1"),
TextMuted: ColourUtil.HexToRgba("#8FA3B5"),
TextDim: ColourUtil.HexToRgba("#566273"),
StatusSuccess: ColourUtil.HexToRgba("#5CB85C"),
StatusDanger: ColourUtil.HexToRgba("#D9534F"),
StatusWarning: ColourUtil.HexToRgba("#F0AD4E"),
StatusInfo: ColourUtil.HexToRgba("#00BED2")
),
Layout: new ThemeLayout(
WindowRounding: 4f, ChildRounding: 3f, PopupRounding: 3f,
FrameRounding: 2f, GrabRounding: 2f, TabRounding: 2f,
ScrollbarRounding: 2f, WindowBorderSize: 1f, FrameBorderSize: 1f
),
Typography: new ThemeTypography(),
IsBuiltIn: true,
ChatColors: new ThemeChatColors(new Dictionary<HellionChat.Code.ChatType, uint>
{
// Hellion Arctic — FFXIV-Standard mit dezenter Cyan-Tinte in den
// blauen Channels (Party/FC). Channel-Identität bleibt klar.
[HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#FFFFFF"),
[HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#FFE066"),
[HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#FFA040"),
[HellionChat.Code.ChatType.TellIncoming] = ColourUtil.HexToRgba("#FF99CC"),
[HellionChat.Code.ChatType.TellOutgoing] = ColourUtil.HexToRgba("#FF99CC"),
[HellionChat.Code.ChatType.Party] = ColourUtil.HexToRgba("#80C0E8"),
[HellionChat.Code.ChatType.Alliance] = ColourUtil.HexToRgba("#FFB870"),
[HellionChat.Code.ChatType.FreeCompany] = ColourUtil.HexToRgba("#4DD9E8"),
[HellionChat.Code.ChatType.NoviceNetwork] = ColourUtil.HexToRgba("#A8E060"),
[HellionChat.Code.ChatType.CrossParty] = ColourUtil.HexToRgba("#80C0E8"),
[HellionChat.Code.ChatType.Linkshell1] = ColourUtil.HexToRgba("#A8E060"),
[HellionChat.Code.ChatType.Linkshell2] = ColourUtil.HexToRgba("#FFC080"),
[HellionChat.Code.ChatType.Linkshell3] = ColourUtil.HexToRgba("#FFE066"),
[HellionChat.Code.ChatType.Linkshell4] = ColourUtil.HexToRgba("#80E8A8"),
[HellionChat.Code.ChatType.Linkshell5] = ColourUtil.HexToRgba("#80C0E8"),
[HellionChat.Code.ChatType.Linkshell6] = ColourUtil.HexToRgba("#A8A0F0"),
[HellionChat.Code.ChatType.Linkshell7] = ColourUtil.HexToRgba("#FF99CC"),
[HellionChat.Code.ChatType.Linkshell8] = ColourUtil.HexToRgba("#E8B0F0"),
[HellionChat.Code.ChatType.CustomEmote] = ColourUtil.HexToRgba("#E8C880"),
[HellionChat.Code.ChatType.StandardEmote] = ColourUtil.HexToRgba("#E8C880"),
[HellionChat.Code.ChatType.Echo] = ColourUtil.HexToRgba("#C0C0C0"),
})
);
}
+77
View File
@@ -0,0 +1,77 @@
using HellionChat.Util;
namespace HellionChat.Themes.Builtin;
internal static class MintGrove
{
public const string Slug = "mint-grove";
public static Theme Build() => new(
Slug: Slug,
Name: "Mint Grove",
Author: "Hellion Online Media",
Description: "Mint Green + Honey Amber auf Deep Forest. Naturthemen-tauglich.",
Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#3CB371"),
Primary: ColourUtil.HexToRgba("#5DD39E"),
PrimaryLight: ColourUtil.HexToRgba("#8FE0B8"),
PrimaryGlow: ColourUtil.HexToRgba("#5DD39E99"),
AccentDark: ColourUtil.HexToRgba("#F4C870"),
Accent: ColourUtil.HexToRgba("#F9D580"),
AccentLight: ColourUtil.HexToRgba("#FCDD93"),
Identity: ColourUtil.HexToRgba("#5DD39E"),
WindowBg: ColourUtil.HexToRgba("#0A1410"),
ChildBg: ColourUtil.HexToRgba("#10201A"),
FrameBg: ColourUtil.HexToRgba("#162B22"),
Surface: ColourUtil.HexToRgba("#1E372B"),
SurfaceHover: ColourUtil.HexToRgba("#284335"),
Border: ColourUtil.HexToRgba("#5DD39E55"),
TextPrimary: ColourUtil.HexToRgba("#E8F5EA"),
TextMuted: ColourUtil.HexToRgba("#9BB5A5"),
TextDim: ColourUtil.HexToRgba("#5C6F65"),
StatusSuccess: ColourUtil.HexToRgba("#5DD39E"),
StatusDanger: ColourUtil.HexToRgba("#D9534F"),
StatusWarning: ColourUtil.HexToRgba("#E8B590"),
StatusInfo: ColourUtil.HexToRgba("#5DA9C7")
),
Layout: new ThemeLayout(
WindowRounding: 5f, ChildRounding: 4f, PopupRounding: 4f,
FrameRounding: 3f, GrabRounding: 3f, TabRounding: 3f,
ScrollbarRounding: 3f, WindowBorderSize: 1f, FrameBorderSize: 1f
),
Typography: new ThemeTypography(),
IsBuiltIn: true,
ChatColors: new ThemeChatColors(new Dictionary<HellionChat.Code.ChatType, uint>
{
// Mint Grove — Naturthemen-Tönung: Honey-Amber in Yell-Familie,
// Mint-Drift in NoviceNetwork und Linkshell. Tell-Pink-Identität
// bleibt erhalten für Erkennbarkeit.
[HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#E8F5EA"),
[HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#F9D580"),
[HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#F0A050"),
[HellionChat.Code.ChatType.TellIncoming] = ColourUtil.HexToRgba("#F098C8"),
[HellionChat.Code.ChatType.TellOutgoing] = ColourUtil.HexToRgba("#F098C8"),
[HellionChat.Code.ChatType.Party] = ColourUtil.HexToRgba("#80B8D0"),
[HellionChat.Code.ChatType.Alliance] = ColourUtil.HexToRgba("#F0B070"),
[HellionChat.Code.ChatType.FreeCompany] = ColourUtil.HexToRgba("#80C8B0"),
[HellionChat.Code.ChatType.NoviceNetwork] = ColourUtil.HexToRgba("#8FE0B8"),
[HellionChat.Code.ChatType.CrossParty] = ColourUtil.HexToRgba("#80B8D0"),
[HellionChat.Code.ChatType.Linkshell1] = ColourUtil.HexToRgba("#8FE0B8"),
[HellionChat.Code.ChatType.Linkshell2] = ColourUtil.HexToRgba("#F0BC80"),
[HellionChat.Code.ChatType.Linkshell3] = ColourUtil.HexToRgba("#F9D580"),
[HellionChat.Code.ChatType.Linkshell4] = ColourUtil.HexToRgba("#80E0A0"),
[HellionChat.Code.ChatType.Linkshell5] = ColourUtil.HexToRgba("#80B8D0"),
[HellionChat.Code.ChatType.Linkshell6] = ColourUtil.HexToRgba("#A89DC0"),
[HellionChat.Code.ChatType.Linkshell7] = ColourUtil.HexToRgba("#F098C8"),
[HellionChat.Code.ChatType.Linkshell8] = ColourUtil.HexToRgba("#D0A8C8"),
[HellionChat.Code.ChatType.CustomEmote] = ColourUtil.HexToRgba("#E8C088"),
[HellionChat.Code.ChatType.StandardEmote] = ColourUtil.HexToRgba("#E8C088"),
[HellionChat.Code.ChatType.Echo] = ColourUtil.HexToRgba("#9BB5A5"),
})
);
}
@@ -0,0 +1,76 @@
using HellionChat.Util;
namespace HellionChat.Themes.Builtin;
internal static class MoonlitBloom
{
public const string Slug = "moonlit-bloom";
public static Theme Build() => new(
Slug: Slug,
Name: "Moonlit Bloom",
Author: "Hellion Online Media",
Description: "Bloom Magenta + Soft Sage auf Deep Violet Night.",
Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#C957D0"),
Primary: ColourUtil.HexToRgba("#E374E8"),
PrimaryLight: ColourUtil.HexToRgba("#EF8AF4"),
PrimaryGlow: ColourUtil.HexToRgba("#E374E899"),
AccentDark: ColourUtil.HexToRgba("#7AAC5C"),
Accent: ColourUtil.HexToRgba("#9CCB7C"),
AccentLight: ColourUtil.HexToRgba("#B6E297"),
Identity: ColourUtil.HexToRgba("#E374E8"),
WindowBg: ColourUtil.HexToRgba("#0E0C1F"),
ChildBg: ColourUtil.HexToRgba("#15122B"),
FrameBg: ColourUtil.HexToRgba("#1F1A38"),
Surface: ColourUtil.HexToRgba("#28224A"),
SurfaceHover: ColourUtil.HexToRgba("#332B5B"),
Border: ColourUtil.HexToRgba("#E374E844"),
TextPrimary: ColourUtil.HexToRgba("#ECE6F5"),
TextMuted: ColourUtil.HexToRgba("#9A8BB0"),
TextDim: ColourUtil.HexToRgba("#554B6E"),
StatusSuccess: ColourUtil.HexToRgba("#7AAC5C"),
StatusDanger: ColourUtil.HexToRgba("#E85C6A"),
StatusWarning: ColourUtil.HexToRgba("#E8B590"),
StatusInfo: ColourUtil.HexToRgba("#6278FF")
),
Layout: new ThemeLayout(
WindowRounding: 6f, ChildRounding: 5f, PopupRounding: 5f,
FrameRounding: 4f, GrabRounding: 4f, TabRounding: 4f,
ScrollbarRounding: 4f, WindowBorderSize: 1f, FrameBorderSize: 1f
),
Typography: new ThemeTypography(),
IsBuiltIn: true,
ChatColors: new ThemeChatColors(new Dictionary<HellionChat.Code.ChatType, uint>
{
// Moonlit Bloom — Bloom-Magenta-Tönung. Sage-Drift in NoviceNetwork
// und Linkshell4. Tell-Pink-Identität bleibt sichtbar.
[HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#ECE6F5"),
[HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#F0D080"),
[HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#F09A60"),
[HellionChat.Code.ChatType.TellIncoming] = ColourUtil.HexToRgba("#EF8AF4"),
[HellionChat.Code.ChatType.TellOutgoing] = ColourUtil.HexToRgba("#EF8AF4"),
[HellionChat.Code.ChatType.Party] = ColourUtil.HexToRgba("#A0B0F0"),
[HellionChat.Code.ChatType.Alliance] = ColourUtil.HexToRgba("#F0B090"),
[HellionChat.Code.ChatType.FreeCompany] = ColourUtil.HexToRgba("#A8C8E8"),
[HellionChat.Code.ChatType.NoviceNetwork] = ColourUtil.HexToRgba("#9CCB7C"),
[HellionChat.Code.ChatType.CrossParty] = ColourUtil.HexToRgba("#A0B0F0"),
[HellionChat.Code.ChatType.Linkshell1] = ColourUtil.HexToRgba("#9CCB7C"),
[HellionChat.Code.ChatType.Linkshell2] = ColourUtil.HexToRgba("#F0BC92"),
[HellionChat.Code.ChatType.Linkshell3] = ColourUtil.HexToRgba("#F0D080"),
[HellionChat.Code.ChatType.Linkshell4] = ColourUtil.HexToRgba("#B6E297"),
[HellionChat.Code.ChatType.Linkshell5] = ColourUtil.HexToRgba("#A0B0F0"),
[HellionChat.Code.ChatType.Linkshell6] = ColourUtil.HexToRgba("#C098D8"),
[HellionChat.Code.ChatType.Linkshell7] = ColourUtil.HexToRgba("#EF8AF4"),
[HellionChat.Code.ChatType.Linkshell8] = ColourUtil.HexToRgba("#E8B0E8"),
[HellionChat.Code.ChatType.CustomEmote] = ColourUtil.HexToRgba("#E8B590"),
[HellionChat.Code.ChatType.StandardEmote] = ColourUtil.HexToRgba("#E8B590"),
[HellionChat.Code.ChatType.Echo] = ColourUtil.HexToRgba("#9A8BB0"),
})
);
}
@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"slug": "example-custom",
"name": "Example Custom",
"author": "You",
"description": "Starting template — duplicate, rename, edit colors and reload.",
"colors": {
"primaryDark": "#0097A7",
"primary": "#00BED2",
"primaryLight": "#4DD9E8",
"primaryGlow": "#00BED299",
"accentDark": "#E85D04",
"accent": "#F97316",
"accentLight": "#FB923C",
"identity": "#0097A7",
"windowBg": "#070B12",
"childBg": "#0C1220",
"frameBg": "#141E30",
"surface": "#1A2538",
"surfaceHover": "#22303F",
"border": "#00BED266",
"textPrimary": "#E6F4F1",
"textMuted": "#8FA3B5",
"textDim": "#566273",
"statusSuccess": "#5CB85C",
"statusDanger": "#D9534F",
"statusWarning": "#F0AD4E",
"statusInfo": "#00BED2"
},
"layout": {
"windowRounding": 4,
"childRounding": 3,
"popupRounding": 3,
"frameRounding": 2,
"grabRounding": 2,
"tabRounding": 2,
"scrollbarRounding": 2,
"windowBorderSize": 1,
"frameBorderSize": 1
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace HellionChat.Themes;
public sealed record Theme(
string Slug,
string Name,
string Author,
string Description,
ThemeColors Colors,
ThemeLayout Layout,
ThemeTypography Typography,
bool IsBuiltIn,
ThemeChatColors? ChatColors = null
);
+11
View File
@@ -0,0 +1,11 @@
using HellionChat.Code;
namespace HellionChat.Themes;
// Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der
// User sie per Klick im Themes-Tab auf Configuration.ChatColours anwenden.
// Ein Theme ohne ChatColors (z.B. chat2-classic) lässt die User-Channel-
// Farben unverändert.
public sealed record ThemeChatColors(
IReadOnlyDictionary<ChatType, uint> Channels
);
+31
View File
@@ -0,0 +1,31 @@
namespace HellionChat.Themes;
// Color-Werte als 0xRRGGBBAA, RgbaToAbgr handled den Byte-Swap zu ImGui.
public sealed record ThemeColors(
uint PrimaryDark,
uint Primary,
uint PrimaryLight,
uint PrimaryGlow,
uint AccentDark,
uint Accent,
uint AccentLight,
uint Identity,
uint WindowBg,
uint ChildBg,
uint FrameBg,
uint Surface,
uint SurfaceHover,
uint Border,
uint TextPrimary,
uint TextMuted,
uint TextDim,
uint StatusSuccess,
uint StatusDanger,
uint StatusWarning,
uint StatusInfo
);
+131
View File
@@ -0,0 +1,131 @@
using System.Text.Json;
using HellionChat.Util;
namespace HellionChat.Themes;
internal static class ThemeJsonLoader
{
public const int SupportedSchemaVersion = 1;
public static Theme LoadFromString(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new FormatException("Theme JSON is empty");
JsonDocument doc;
try { doc = JsonDocument.Parse(json); }
catch (JsonException ex) { throw new FormatException("Theme JSON is not valid JSON", ex); }
using (doc)
{
var root = doc.RootElement;
var schemaVersion = ReadInt(root, "schemaVersion");
if (schemaVersion != SupportedSchemaVersion)
throw new FormatException($"Unsupported schemaVersion {schemaVersion}; expected {SupportedSchemaVersion}");
var slug = ReadString(root, "slug");
var name = ReadString(root, "name");
var author = ReadString(root, "author");
var description = ReadString(root, "description");
var colors = ReadColors(root.GetProperty("colors"));
var layout = ReadLayout(root.GetProperty("layout"));
ThemeChatColors? chatColors = null;
if (root.TryGetProperty("chatChannels", out var ch) && ch.ValueKind == JsonValueKind.Object)
chatColors = ReadChatColors(ch);
return new Theme(slug, name, author, description, colors, layout, new ThemeTypography(), IsBuiltIn: false, ChatColors: chatColors);
}
}
private static ThemeChatColors ReadChatColors(JsonElement el)
{
var dict = new Dictionary<HellionChat.Code.ChatType, uint>();
foreach (var prop in el.EnumerateObject())
{
// Property-Name ist der ChatType-Name als String (z.B. "Say", "Tell"),
// Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names
// werden still übersprungen — Forward-Compat falls SE neue Channels
// einführt.
if (!Enum.TryParse<HellionChat.Code.ChatType>(prop.Name, ignoreCase: true, out var channel))
continue;
if (prop.Value.ValueKind != JsonValueKind.String)
continue;
var hex = prop.Value.GetString();
if (string.IsNullOrWhiteSpace(hex))
continue;
dict[channel] = HellionChat.Util.ColourUtil.HexToRgba(hex);
}
return new ThemeChatColors(dict);
}
public static Theme LoadFromFile(string path)
{
var json = File.ReadAllText(path);
return LoadFromString(json);
}
private static ThemeColors ReadColors(JsonElement el) => new(
PrimaryDark: ColourUtil.HexToRgba(ReadString(el, "primaryDark")),
Primary: ColourUtil.HexToRgba(ReadString(el, "primary")),
PrimaryLight: ColourUtil.HexToRgba(ReadString(el, "primaryLight")),
PrimaryGlow: ColourUtil.HexToRgba(ReadString(el, "primaryGlow")),
AccentDark: ColourUtil.HexToRgba(ReadString(el, "accentDark")),
Accent: ColourUtil.HexToRgba(ReadString(el, "accent")),
AccentLight: ColourUtil.HexToRgba(ReadString(el, "accentLight")),
Identity: ColourUtil.HexToRgba(ReadString(el, "identity")),
WindowBg: ColourUtil.HexToRgba(ReadString(el, "windowBg")),
ChildBg: ColourUtil.HexToRgba(ReadString(el, "childBg")),
FrameBg: ColourUtil.HexToRgba(ReadString(el, "frameBg")),
Surface: ColourUtil.HexToRgba(ReadString(el, "surface")),
SurfaceHover: ColourUtil.HexToRgba(ReadString(el, "surfaceHover")),
Border: ColourUtil.HexToRgba(ReadString(el, "border")),
TextPrimary: ColourUtil.HexToRgba(ReadString(el, "textPrimary")),
TextMuted: ColourUtil.HexToRgba(ReadString(el, "textMuted")),
TextDim: ColourUtil.HexToRgba(ReadString(el, "textDim")),
StatusSuccess: ColourUtil.HexToRgba(ReadString(el, "statusSuccess")),
StatusDanger: ColourUtil.HexToRgba(ReadString(el, "statusDanger")),
StatusWarning: ColourUtil.HexToRgba(ReadString(el, "statusWarning")),
StatusInfo: ColourUtil.HexToRgba(ReadString(el, "statusInfo"))
);
private static ThemeLayout ReadLayout(JsonElement el) => new(
WindowRounding: ReadFloat(el, "windowRounding"),
ChildRounding: ReadFloat(el, "childRounding"),
PopupRounding: ReadFloat(el, "popupRounding"),
FrameRounding: ReadFloat(el, "frameRounding"),
GrabRounding: ReadFloat(el, "grabRounding"),
TabRounding: ReadFloat(el, "tabRounding"),
ScrollbarRounding: ReadFloat(el, "scrollbarRounding"),
WindowBorderSize: ReadFloat(el, "windowBorderSize"),
FrameBorderSize: ReadFloat(el, "frameBorderSize")
);
private static string ReadString(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String)
throw new FormatException($"Theme JSON missing string property '{name}'");
return v.GetString() ?? throw new FormatException($"Theme JSON property '{name}' is null");
}
private static int ReadInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
throw new FormatException($"Theme JSON missing number property '{name}'");
return v.GetInt32();
}
private static float ReadFloat(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
throw new FormatException($"Theme JSON missing number property '{name}'");
return (float)v.GetDouble();
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Text.Json;
namespace HellionChat.Themes;
internal static class ThemeJsonWriter
{
public static string Serialize(Theme theme)
{
using var ms = new MemoryStream();
using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true }))
{
writer.WriteStartObject();
writer.WriteNumber("schemaVersion", ThemeJsonLoader.SupportedSchemaVersion);
writer.WriteString("slug", theme.Slug);
writer.WriteString("name", theme.Name);
writer.WriteString("author", theme.Author);
writer.WriteString("description", theme.Description);
writer.WriteStartObject("colors");
WriteColor(writer, "primaryDark", theme.Colors.PrimaryDark);
WriteColor(writer, "primary", theme.Colors.Primary);
WriteColor(writer, "primaryLight", theme.Colors.PrimaryLight);
WriteColor(writer, "primaryGlow", theme.Colors.PrimaryGlow);
WriteColor(writer, "accentDark", theme.Colors.AccentDark);
WriteColor(writer, "accent", theme.Colors.Accent);
WriteColor(writer, "accentLight", theme.Colors.AccentLight);
WriteColor(writer, "identity", theme.Colors.Identity);
WriteColor(writer, "windowBg", theme.Colors.WindowBg);
WriteColor(writer, "childBg", theme.Colors.ChildBg);
WriteColor(writer, "frameBg", theme.Colors.FrameBg);
WriteColor(writer, "surface", theme.Colors.Surface);
WriteColor(writer, "surfaceHover", theme.Colors.SurfaceHover);
WriteColor(writer, "border", theme.Colors.Border);
WriteColor(writer, "textPrimary", theme.Colors.TextPrimary);
WriteColor(writer, "textMuted", theme.Colors.TextMuted);
WriteColor(writer, "textDim", theme.Colors.TextDim);
WriteColor(writer, "statusSuccess", theme.Colors.StatusSuccess);
WriteColor(writer, "statusDanger", theme.Colors.StatusDanger);
WriteColor(writer, "statusWarning", theme.Colors.StatusWarning);
WriteColor(writer, "statusInfo", theme.Colors.StatusInfo);
writer.WriteEndObject();
writer.WriteStartObject("layout");
writer.WriteNumber("windowRounding", theme.Layout.WindowRounding);
writer.WriteNumber("childRounding", theme.Layout.ChildRounding);
writer.WriteNumber("popupRounding", theme.Layout.PopupRounding);
writer.WriteNumber("frameRounding", theme.Layout.FrameRounding);
writer.WriteNumber("grabRounding", theme.Layout.GrabRounding);
writer.WriteNumber("tabRounding", theme.Layout.TabRounding);
writer.WriteNumber("scrollbarRounding", theme.Layout.ScrollbarRounding);
writer.WriteNumber("windowBorderSize", theme.Layout.WindowBorderSize);
writer.WriteNumber("frameBorderSize", theme.Layout.FrameBorderSize);
writer.WriteEndObject();
if (theme.ChatColors is { Channels.Count: > 0 } cc)
{
writer.WriteStartObject("chatChannels");
foreach (var kvp in cc.Channels)
writer.WriteString(kvp.Key.ToString(), $"#{kvp.Value:X8}");
writer.WriteEndObject();
}
writer.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
private static void WriteColor(Utf8JsonWriter writer, string key, uint rgba)
{
writer.WriteString(key, $"#{rgba:X8}");
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace HellionChat.Themes;
// Layout-Werte spiegeln die ImGuiStyleVar-Slots, die HellionStyle pusht.
public sealed record ThemeLayout(
float WindowRounding,
float ChildRounding,
float PopupRounding,
float FrameRounding,
float GrabRounding,
float TabRounding,
float ScrollbarRounding,
float WindowBorderSize,
float FrameBorderSize
);
+96
View File
@@ -0,0 +1,96 @@
using HellionChat.Themes.Builtin;
namespace HellionChat.Themes;
public sealed class ThemeRegistry
{
public const string DefaultSlug = HellionArctic.Slug;
private readonly Dictionary<string, Theme> _builtIns;
private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(StringComparer.OrdinalIgnoreCase);
private readonly string? _customThemesDir;
private Theme _active;
public ThemeRegistry(string? customThemesDir = null)
{
_builtIns = new Dictionary<string, Theme>(StringComparer.OrdinalIgnoreCase)
{
{ HellionArctic.Slug, HellionArctic.Build() },
{ Chat2Classic.Slug, Chat2Classic.Build() },
{ EventHorizon.Slug, EventHorizon.Build() },
{ MoonlitBloom.Slug, MoonlitBloom.Build() },
{ MintGrove.Slug, MintGrove.Build() },
};
_active = _builtIns[DefaultSlug];
_customThemesDir = customThemesDir;
}
public Theme Active => _active;
public Theme Get(string slug)
{
if (_builtIns.TryGetValue(slug, out var b)) return b;
var custom = LoadCustomBySlug(slug);
if (custom != null) return custom;
return _builtIns[DefaultSlug];
}
public IEnumerable<Theme> AllBuiltIns() => _builtIns.Values;
public IEnumerable<Theme> AllCustom() => RefreshCustomCache();
public void Switch(string slug) => _active = Get(slug);
// Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit
// LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup
// neu eingelesen.
private Theme? LoadCustomBySlug(string slug)
{
if (_customThemesDir is null) return null;
if (!Directory.Exists(_customThemesDir)) return null;
foreach (var theme in RefreshCustomCache())
if (string.Equals(theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
return theme;
return null;
}
private IEnumerable<Theme> RefreshCustomCache()
{
if (_customThemesDir is null || !Directory.Exists(_customThemesDir))
yield break;
var seenSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in Directory.EnumerateFiles(_customThemesDir, "*.json"))
{
Theme? theme = null;
var stamp = File.GetLastWriteTimeUtc(path);
var key = path;
if (_customCache.TryGetValue(key, out var cached) && cached.Stamp == stamp)
{
theme = cached.Theme;
}
else
{
try
{
theme = ThemeJsonLoader.LoadFromFile(path);
_customCache[key] = (theme, stamp);
}
catch (Exception ex)
{
// Logging passiert in Plugin.cs durch den Aufrufer; hier still
// ignorieren, damit ein einzelnes kaputtes JSON nicht alle
// Custom-Themes blockt.
_ = ex;
continue;
}
}
if (theme is not null && seenSlugs.Add(theme.Slug))
yield return theme;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace HellionChat.Themes;
// Optional pro Theme. v1.1.0 nutzt das nicht aktiv; ist als Erweiterungspunkt
// für zukünftige Theme-Slots vorbereitet.
public sealed record ThemeTypography(
float? OverrideGlobalFontSizePt = null,
float? OverrideSymbolsFontSizePt = null
);
+1 -1
View File
@@ -104,7 +104,7 @@ public sealed class ChatInputBar
// window's logic but operates on _state.HistoryCursor and the shared
// InputHistoryService. Index semantics match v0.5.x InputBacklog:
// 0 = oldest, Count-1 = newest.
private unsafe int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
private int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
{
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
return 0;
+71 -61
View File
@@ -34,6 +34,9 @@ public sealed class ChatLogWindow : Window
internal Plugin Plugin { get; }
private readonly CommandWrapper _clearHellionCommand;
private readonly CommandWrapper _hellionCommand;
internal bool ScreenshotMode;
private string Salt { get; }
@@ -110,8 +113,14 @@ public sealed class ChatLogWindow : Window
SetUpTextCommandChannels();
SetUpAllCommands();
Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log").Execute += ClearLog;
Plugin.Commands.Register("/hellion").Execute += ToggleChat;
// Cache the registered wrapper instances so Dispose can detach the same
// event objects the constructor attached to, without going through
// Register() again (which would re-create the wrapper if the command
// happened to be missing from the dictionary).
_clearHellionCommand = Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log");
_hellionCommand = Plugin.Commands.Register("/hellion");
_clearHellionCommand.Execute += ClearLog;
_hellionCommand.Execute += ToggleChat;
Plugin.ClientState.Login += Login;
Plugin.ClientState.Logout += Logout;
@@ -126,8 +135,8 @@ public sealed class ChatLogWindow : Window
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
Plugin.ClientState.Logout -= Logout;
Plugin.ClientState.Login -= Login;
Plugin.Commands.Register("/hellion").Execute -= ToggleChat;
Plugin.Commands.Register("/clearhellion").Execute -= ClearLog;
_hellionCommand.Execute -= ToggleChat;
_clearHellionCommand.Execute -= ClearLog;
}
private void Logout(int _, int __)
@@ -485,9 +494,7 @@ public sealed class ChatLogWindow : Window
Flags |= ImGuiWindowFlags.NoTitleBar;
if (LastViewport == ImGuiHelpers.MainViewport.Handle && !WasDocked)
BgAlpha = Plugin.Config.HellionThemeEnabled
? Plugin.Config.HellionThemeWindowOpacity
: Plugin.Config.WindowAlpha / 100f;
BgAlpha = Plugin.Config.WindowOpacity;
LastViewport = ImGui.GetWindowViewport().Handle;
WasDocked = ImGui.IsWindowDocked();
@@ -519,8 +526,11 @@ public sealed class ChatLogWindow : Window
if (Plugin.Config.KeepInputFocus && Activate)
ImGui.SetWindowFocus(WindowName);
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
// Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein
// zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw
// pusht das aktive Hellion-Theme global; ChatLogWindow zeichnet sich
// damit konsistent zu Settings/Pop-Out/Wizard. Wer den Upstream-Look
// will, wählt das Built-In-Theme "Chat 2 Klassik" in Settings → Themes.
}
public override void PostDraw()
@@ -531,9 +541,6 @@ public sealed class ChatLogWindow : Window
// doesn't get called if the input is disabled.
if (Plugin.CurrentTab.InputDisabled)
Activate = false;
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
}
public override void OnClose()
@@ -597,10 +604,11 @@ public sealed class ChatLogWindow : Window
Plugin.InputPreview.CalculatePreview();
// Hellion Chat v0.6.1 — render the one-time hint banner first so it
// sits above the tab area / sidebar in full window width. Stash the
// height for GetRemainingHeightForMessageLog so the message log
// shrinks accordingly while the banner is visible.
_v061HintBannerHeight = DrawV061HintBannerIfNeeded();
// sits above the tab area / sidebar in full window width. ImGui's
// GetContentRegionAvail subtracts its height automatically because the
// cursor advances past it before the message log calls
// GetRemainingHeightForMessageLog, so we don't track the height here.
DrawV061HintBannerIfNeeded();
if (Plugin.Config.SidebarTabView)
DrawTabSidebar();
@@ -1540,11 +1548,14 @@ public sealed class ChatLogWindow : Window
var startY = ImGui.GetCursorPosY();
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
ImGui.PushStyleColor(ImGuiCol.ChildBg, bg);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
var dismiss = false;
var openSettings = false;
// RAII for the style stack so an early return in this block
// (or a later refactor that introduces one) can never leave the
// ImGui style stack unbalanced. Matches the convention used
// elsewhere in this file.
using (ImRaii.PushColor(ImGuiCol.ChildBg, bg))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f))
using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true))
{
if (child)
@@ -1561,8 +1572,6 @@ public sealed class ChatLogWindow : Window
}
}
ImGui.PopStyleVar();
ImGui.PopStyleColor();
ImGui.Spacing();
if (dismiss)
@@ -1636,13 +1645,6 @@ public sealed class ChatLogWindow : Window
internal readonly List<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = [];
// Hellion Chat v0.6.1 — height the v0.6.1 hint banner consumed in the
// current frame, read by GetRemainingHeightForMessageLog so the message
// log can shrink. Unconditionally reassigned at the top of DrawChatLog
// (before any tab-area render) so the value is always in sync with the
// current frame. Returns 0 once the banner is dismissed.
private float _v061HintBannerHeight;
// v0.6.0 — live enumeration of all active Popout windows so the
// KeybindManager can find a focused ChatInputBar to forward tab-cycle
// keybinds to. Filter on IsOpen prevents touching closed-but-still-
@@ -1745,47 +1747,55 @@ public sealed class ChatLogWindow : Window
return;
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
clipper.Begin(AutoCompleteList.Count);
while (clipper.Step())
try
{
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
clipper.Begin(AutoCompleteList.Count);
while (clipper.Step())
{
var entry = AutoCompleteList[i];
var highlight = AutoCompleteSelection == i;
var clicked = ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) || selected == i;
if (i < 10)
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
{
var button = (i + 1) % 10;
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
var size = ImGui.CalcTextSize(text);
var entry = AutoCompleteList[i];
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
var highlight = AutoCompleteSelection == i;
var clicked = ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) || selected == i;
if (i < 10)
{
var button = (i + 1) % 10;
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
var size = ImGui.CalcTextSize(text);
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]))
ImGui.TextUnformatted(text);
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]))
ImGui.TextUnformatted(text);
}
if (!clicked)
continue;
var before = Chat[..AutoCompleteInfo.StartPos];
var after = Chat[AutoCompleteInfo.EndPos..];
var replacement = $"<at:{entry.Group},{entry.Row}>";
Chat = $"{before}{replacement}{after}";
ImGui.CloseCurrentPopup();
Activate = true;
ActivatePos = AutoCompleteInfo.StartPos + replacement.Length;
}
if (!clicked)
continue;
var before = Chat[..AutoCompleteInfo.StartPos];
var after = Chat[AutoCompleteInfo.EndPos..];
var replacement = $"<at:{entry.Group},{entry.Row}>";
Chat = $"{before}{replacement}{after}";
ImGui.CloseCurrentPopup();
Activate = true;
ActivatePos = AutoCompleteInfo.StartPos + replacement.Length;
}
if (!AutoCompleteShouldScroll)
return;
AutoCompleteShouldScroll = false;
var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
}
finally
{
// ImGuiListClipperPtr wraps an unmanaged ImGuiListClipper allocated above.
// Without Destroy() the unmanaged block leaks per autocomplete render.
clipper.Destroy();
}
if (!AutoCompleteShouldScroll)
return;
AutoCompleteShouldScroll = false;
var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
}
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
+5 -2
View File
@@ -47,8 +47,11 @@ public class CommandHelpWindow : Window {
Position = pos;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(width, 0),
MaximumSize = LogWindow.LastWindowSize with { X = width }
// Use scaledWidth here so the size constraints stay in the same
// coordinate space as Position above; otherwise the help window
// ends up the wrong width at non-100% DPI.
MinimumSize = new Vector2(scaledWidth, 0),
MaximumSize = LogWindow.LastWindowSize with { X = scaledWidth }
};
IsOpen = true;
+86 -173
View File
@@ -1,3 +1,4 @@
using HellionChat.Themes;
using HellionChat.Util;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
@@ -5,207 +6,119 @@ using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Ui;
/// <summary>
/// ImGui style override for Hellion Chat. Industrial HUD palette with three
/// distinct accents — cyan-teal as the primary action color, industrial
/// amber for active state highlights, slate-violet for title bars and
/// active tabs — on a deep-slate frame background with steel borders.
///
/// Two entry points:
/// Push — local color stack, scoped via using-block. Use inside
/// Hellion-only surfaces (Privacy tab, first-run wizard).
/// PushGlobal — full color + style variable stack. Pushed once per frame
/// in Plugin.Draw so every Hellion-rendered window inherits
/// the look. Cheap to pop because ImGui keeps its own stack.
/// ImGui style override for Hellion Chat. v1.1.0 ist die Engine
/// theme-getrieben: PushGlobal nimmt eine Theme-Instance + Window-
/// Opacity, die gesamten Color- und Style-Slots werden aus dem Theme
/// gelesen statt aus einer fixen Konstanten-Tabelle.
/// </summary>
internal static class HellionStyle
{
// Encoded as 0xRRGGBBAA, matching ChatTwo convention (see Settings.cs
// Ko-fi buttons). RgbaToAbgr handles the byte swap to the format ImGui
// expects. Hex values are sourced from the Hellion Online Media brand
// guide ("Arctic Cyan + Ember Glow", BRANDING.md in the website repo).
// Primary — Arctic Cyan, used for every interactive control (buttons,
// checks, sliders, separators when hovered). Three brand stages plus a
// hover that lifts to brand-color-light and a press that drops to
// brand-color-dark.
private const uint PrimaryRgba = 0x00BED2FF; // brand-color
private const uint PrimaryHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint PrimaryActiveRgba = 0x0097A7FF; // brand-color-dark
// Identity — brand-color-dark teal for window title bars and the
// active tab. Sits visibly below the primary cyan on buttons so the
// user sees "where am I" (deep teal) versus "what can I click"
// (brand cyan) without leaving the cyan family.
private const uint IdentityRgba = 0x0097A7FF; // brand-color-dark
private const uint IdentityHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint IdentityDeepRgba = 0x005670FF; // dimmer teal for unfocused-active tab
// Accent — Ember Orange for warm highlights on grips and scrollbar
// pulls. Replaces the previous industrial amber so the plugin matches
// the website's CTA palette. AccentActive is reserved for any future
// pressed-state on accent surfaces; the current slots only need
// AccentRgba and AccentHoverRgba.
private const uint AccentRgba = 0xF97316FF; // accent-color
private const uint AccentHoverRgba = 0xFB923CFF; // accent-color-light
// Surfaces — Hellion brand background ladder. Window darkest, frame
// hover ladder climbs into surface tones. Matches the website's
// background / background-medium / background-light / surface vars.
private const uint WindowBgRgba = 0x070B12FF; // background
private const uint ChildBgRgba = 0x0C1220FF; // background-medium
private const uint PopupBgRgba = 0x0C1220FF; // background-medium
private const uint FrameBgRgba = 0x141E30FF; // background-light
private const uint FrameBgHoverRgba = 0x1A2538FF; // surface
private const uint FrameBgActiveRgba = 0x22303FFF; // surface-hover
// Cyan-tinted border — matches website --border-brand (cyan @ 40% α).
private const uint BorderRgba = 0x00BED266;
private const uint BorderShadowRgba = 0x00000000;
// Headers / collapsing-headers / tree nodes / selectables — same
// surface ladder as frames so panels feel consistent.
private const uint HeaderRgba = 0x141E30FF;
private const uint HeaderHoverRgba = 0x1A2538FF;
private const uint HeaderActiveRgba = 0x22303FFF;
// Title bars — Identity teal on active so the focused window reads
// as "yours" without using accent or primary slots.
private const uint TitleBgRgba = 0x070B12FF;
private const uint TitleBgActiveRgba = IdentityRgba;
private const uint TitleBgCollapsedRgba = 0x05080EFF;
// Tabs — neutral inactive, Identity-light on hover, Identity teal on
// active. Unfocused-active uses the deeper Identity stage so an
// unfocused window's active tab still reads but does not pull focus.
private const uint TabRgba = 0x141E30FF;
private const uint TabHoveredRgba = IdentityHoverRgba;
private const uint TabActiveRgba = IdentityRgba;
private const uint TabUnfocusedRgba = 0x0C1220FF;
private const uint TabUnfocusedActiveRgba = IdentityDeepRgba;
// Scrollbar — Ember on grab so the pull stands out without competing
// with the cyan action buttons. Idle grab is a subtle surface tone,
// hover/active climb into accent.
private const uint ScrollbarBgRgba = 0x070B12FF;
private const uint ScrollbarGrabRgba = 0x22303FFF; // surface-hover
private const uint ScrollbarGrabHoveredRgba = AccentHoverRgba;
private const uint ScrollbarGrabActiveRgba = AccentRgba;
// Resize grip — same Ember treatment as the scrollbar.
private const uint ResizeGripRgba = 0x141E30FF;
private const uint ResizeGripHoveredRgba = AccentHoverRgba;
private const uint ResizeGripActiveRgba = AccentRgba;
// Separator and check mark / slider follow the primary cyan.
/// <summary>
/// Local color stack for Hellion-only surfaces. Cheap. Use inside a
/// `using var _ = HellionStyle.Push();` block.
/// Local color stack auf Basis des aktiven Themes. Cheap. Use inside a
/// `using var _ = HellionStyle.Push(theme);` block.
/// </summary>
internal static IDisposable Push()
internal static IDisposable Push(Theme theme)
{
var c = theme.Colors;
var stack = new StackHandle();
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
stack.PushColor(ImGuiCol.Border, BorderRgba);
stack.PushColor(ImGuiCol.Header, HeaderRgba);
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.Button, c.Primary);
stack.PushColor(ImGuiCol.ButtonHovered, c.PrimaryLight);
stack.PushColor(ImGuiCol.ButtonActive, c.PrimaryDark);
stack.PushColor(ImGuiCol.FrameBg, c.FrameBg);
stack.PushColor(ImGuiCol.FrameBgHovered, c.SurfaceHover);
stack.PushColor(ImGuiCol.FrameBgActive, c.Surface);
stack.PushColor(ImGuiCol.Border, c.Border);
stack.PushColor(ImGuiCol.Header, c.Surface);
stack.PushColor(ImGuiCol.HeaderHovered, c.SurfaceHover);
stack.PushColor(ImGuiCol.HeaderActive, c.Identity);
stack.PushColor(ImGuiCol.CheckMark, c.Primary);
stack.PushColor(ImGuiCol.SliderGrab, c.Primary);
stack.PushColor(ImGuiCol.SliderGrabActive, c.PrimaryLight);
return stack;
}
/// <summary>
/// Global color and style-variable stack pushed once per frame in
/// Plugin.Draw. Covers every ImGui surface the plugin renders so the
/// Hellion look is consistent across upstream and Hellion tabs.
/// Plugin.Draw. Drives every Hellion-rendered window from the active
/// theme's palette and layout values.
/// </summary>
/// <param name="windowOpacity">Window background alpha (0.51.0). Lower
/// values let the game shine through the plugin panes.</param>
internal static IDisposable PushGlobal(float windowOpacity = 1.0f)
/// <param name="theme">Active theme from ThemeRegistry.</param>
/// <param name="windowOpacity">Window background alpha (0.51.0).</param>
internal static IDisposable PushGlobal(Theme theme, float windowOpacity = 1.0f)
{
var c = theme.Colors;
var l = theme.Layout;
var stack = new StackHandle();
// Mix the configured opacity into both the outer window and the
// inner content child backgrounds — without ChildBg following the
// slider the chat log stays opaque inside even when the user
// wants to see the game behind it during combat. Form fields and
// popups (FrameBg, PopupBg) still stay opaque so input is readable.
var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF);
var windowBgWithAlpha = (WindowBgRgba & 0xFFFFFF00u) | alphaByte;
var childBgWithAlpha = (ChildBgRgba & 0xFFFFFF00u) | alphaByte;
var windowBgWithAlpha = (c.WindowBg & 0xFFFFFF00u) | alphaByte;
var childBgWithAlpha = (c.ChildBg & 0xFFFFFF00u) | alphaByte;
// Layout — geometric edges, modest rounding, single-pixel borders.
stack.PushStyleVar(ImGuiStyleVar.WindowRounding, 4f);
stack.PushStyleVar(ImGuiStyleVar.ChildRounding, 3f);
stack.PushStyleVar(ImGuiStyleVar.PopupRounding, 3f);
stack.PushStyleVar(ImGuiStyleVar.FrameRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.GrabRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.TabRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f);
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
// Layout
stack.PushStyleVar(ImGuiStyleVar.WindowRounding, l.WindowRounding);
stack.PushStyleVar(ImGuiStyleVar.ChildRounding, l.ChildRounding);
stack.PushStyleVar(ImGuiStyleVar.PopupRounding, l.PopupRounding);
stack.PushStyleVar(ImGuiStyleVar.FrameRounding, l.FrameRounding);
stack.PushStyleVar(ImGuiStyleVar.GrabRounding, l.GrabRounding);
stack.PushStyleVar(ImGuiStyleVar.TabRounding, l.TabRounding);
stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, l.ScrollbarRounding);
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize);
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize);
// Surfaces.
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
stack.PushColor(ImGuiCol.PopupBg, PopupBgRgba);
stack.PushColor(ImGuiCol.Border, BorderRgba);
stack.PushColor(ImGuiCol.BorderShadow, BorderShadowRgba);
// Surfaces
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
stack.PushColor(ImGuiCol.PopupBg, c.ChildBg);
stack.PushColor(ImGuiCol.Border, c.Border);
stack.PushColor(ImGuiCol.BorderShadow, 0u);
// Frames (input fields, combos, sliders).
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
// Frames
stack.PushColor(ImGuiCol.FrameBg, c.FrameBg);
stack.PushColor(ImGuiCol.FrameBgHovered, c.SurfaceHover);
stack.PushColor(ImGuiCol.FrameBgActive, c.Surface);
// Title bars — tertiary identity on active.
stack.PushColor(ImGuiCol.TitleBg, TitleBgRgba);
stack.PushColor(ImGuiCol.TitleBgActive, TitleBgActiveRgba);
stack.PushColor(ImGuiCol.TitleBgCollapsed, TitleBgCollapsedRgba);
// Title bars
stack.PushColor(ImGuiCol.TitleBg, c.WindowBg);
stack.PushColor(ImGuiCol.TitleBgActive, c.Identity);
stack.PushColor(ImGuiCol.TitleBgCollapsed, c.WindowBg);
// Buttons — primary cyan.
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
// Buttons
stack.PushColor(ImGuiCol.Button, c.Primary);
stack.PushColor(ImGuiCol.ButtonHovered, c.PrimaryLight);
stack.PushColor(ImGuiCol.ButtonActive, c.PrimaryDark);
// Headers / selectables — slate with subtle steps.
stack.PushColor(ImGuiCol.Header, HeaderRgba);
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
// Headers / selectables
stack.PushColor(ImGuiCol.Header, c.Surface);
stack.PushColor(ImGuiCol.HeaderHovered, c.SurfaceHover);
stack.PushColor(ImGuiCol.HeaderActive, c.Identity);
// Tabs — tertiary identity for the active tab.
stack.PushColor(ImGuiCol.Tab, TabRgba);
stack.PushColor(ImGuiCol.TabHovered, TabHoveredRgba);
stack.PushColor(ImGuiCol.TabActive, TabActiveRgba);
stack.PushColor(ImGuiCol.TabUnfocused, TabUnfocusedRgba);
stack.PushColor(ImGuiCol.TabUnfocusedActive, TabUnfocusedActiveRgba);
// Tabs
stack.PushColor(ImGuiCol.Tab, c.FrameBg);
stack.PushColor(ImGuiCol.TabHovered, c.PrimaryLight);
stack.PushColor(ImGuiCol.TabActive, c.Identity);
stack.PushColor(ImGuiCol.TabUnfocused, c.ChildBg);
stack.PushColor(ImGuiCol.TabUnfocusedActive, c.PrimaryDark);
// Scrollbar.
stack.PushColor(ImGuiCol.ScrollbarBg, ScrollbarBgRgba);
stack.PushColor(ImGuiCol.ScrollbarGrab, ScrollbarGrabRgba);
stack.PushColor(ImGuiCol.ScrollbarGrabHovered, ScrollbarGrabHoveredRgba);
stack.PushColor(ImGuiCol.ScrollbarGrabActive, ScrollbarGrabActiveRgba);
// Scrollbar
stack.PushColor(ImGuiCol.ScrollbarBg, c.WindowBg);
stack.PushColor(ImGuiCol.ScrollbarGrab, c.Surface);
stack.PushColor(ImGuiCol.ScrollbarGrabHovered, c.AccentLight);
stack.PushColor(ImGuiCol.ScrollbarGrabActive, c.Accent);
// Resize grip — secondary amber on active.
stack.PushColor(ImGuiCol.ResizeGrip, ResizeGripRgba);
stack.PushColor(ImGuiCol.ResizeGripHovered, ResizeGripHoveredRgba);
stack.PushColor(ImGuiCol.ResizeGripActive, ResizeGripActiveRgba);
// Resize grip
stack.PushColor(ImGuiCol.ResizeGrip, c.FrameBg);
stack.PushColor(ImGuiCol.ResizeGripHovered, c.AccentLight);
stack.PushColor(ImGuiCol.ResizeGripActive, c.Accent);
// Check mark + slider grab — primary cyan.
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
// Check mark + slider grab
stack.PushColor(ImGuiCol.CheckMark, c.Primary);
stack.PushColor(ImGuiCol.SliderGrab, c.Primary);
stack.PushColor(ImGuiCol.SliderGrabActive, c.PrimaryLight);
// Separator — primary cyan when hovered/active so the eye
// immediately sees that splitters are interactive.
stack.PushColor(ImGuiCol.Separator, BorderRgba);
stack.PushColor(ImGuiCol.SeparatorHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.SeparatorActive, PrimaryRgba);
// Separator
stack.PushColor(ImGuiCol.Separator, c.Border);
stack.PushColor(ImGuiCol.SeparatorHovered, c.PrimaryLight);
stack.PushColor(ImGuiCol.SeparatorActive, c.Primary);
return stack;
}
+4 -1
View File
@@ -177,7 +177,10 @@ public partial class InputPreview : Window
return;
NextChunkIsAutoTranslate = true;
var payload = (AutoTranslatePayload) chunk.Link!;
// Malformed chunks could carry an AutoTranslateBegin icon without the matching
// payload; bail out instead of dereferencing a null Link.
if (chunk.Link is not AutoTranslatePayload payload)
return;
CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length;
return;
+5 -9
View File
@@ -67,9 +67,10 @@ internal class Popout : Window
public override void PreDraw()
{
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
// Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein
// zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw
// pusht das aktive Hellion-Theme global; Pop-Out zeichnet sich damit
// konsistent zum Haupt-Chat-Window.
Flags = ImGuiWindowFlags.None;
if (!Plugin.Config.ShowPopOutTitleBar)
Flags |= ImGuiWindowFlags.NoTitleBar;
@@ -91,9 +92,7 @@ internal class Popout : Window
}
else
{
BgAlpha = Plugin.Config.HellionThemeEnabled
? Plugin.Config.HellionThemeWindowOpacity
: Plugin.Config.WindowAlpha / 100f;
BgAlpha = Plugin.Config.WindowOpacity;
}
}
}
@@ -200,9 +199,6 @@ internal class Popout : Window
{
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count)
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
}
public override void OnClose()
+10 -1
View File
@@ -222,7 +222,16 @@ public class SeStringDebugger : Window
default:
var payloadData = payload.Encode();
var initialByte = payloadData.First();
if (payloadData.Length == 0)
{
RenderMetadataDictionary("Empty Payload", new Dictionary<string, string?>
{
{ "Type", payload.GetType().Name },
});
break;
}
var initialByte = payloadData[0];
if (initialByte != 0x02)
{
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
+83 -31
View File
@@ -9,13 +9,21 @@ using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui;
internal enum SettingsView
{
Overview,
Detail,
}
public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
{
private readonly Plugin Plugin;
internal readonly Plugin Plugin;
private Configuration Mutable { get; }
private List<ISettingsTab> Tabs { get; }
private int CurrentTab;
private SettingsView View = SettingsView.Overview;
private readonly SettingsOverview Overview;
internal SettingsWindow(Plugin plugin) : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
{
@@ -31,10 +39,13 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
Plugin = plugin;
Mutable = new Configuration();
Overview = new SettingsOverview(this);
Tabs =
[
new General(Plugin, Mutable),
new Appearance(Plugin, Mutable),
new SettingsTabs.Themes(Plugin, Mutable),
new SettingsTabs.Window(Plugin, Mutable),
new Chat(Plugin, Mutable),
new SettingsTabs.Tabs(Plugin, Mutable),
@@ -72,40 +83,81 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
public override void Draw()
{
if (ImGui.IsWindowAppearing())
Initialise();
using (var table = ImRaii.Table("##chat2-settings-table", 2))
{
if (table.Success)
{
ImGui.TableSetupColumn("tab", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("settings", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextColumn();
var changed = false;
for (var i = 0; i < Tabs.Count; i++)
{
if (!ImGui.Selectable($"{Tabs[i].Name}###tab-{i}", CurrentTab == i))
continue;
CurrentTab = i;
changed = true;
}
ImGui.TableNextColumn();
var style = ImGui.GetStyle();
var height = ImGui.GetContentRegionAvail().Y - style.FramePadding.Y * 2 - style.ItemSpacing.Y - style.ItemInnerSpacing.Y * 2 - ImGui.CalcTextSize("A").Y;
using var child = ImRaii.Child("##chat2-settings", new Vector2(-1, height));
if (child.Success)
Tabs[CurrentTab].Draw(changed);
}
Initialise();
View = SettingsView.Overview;
}
ImGui.Separator();
// ESC im Detail-View kehrt zur Overview zurück. Window-Focus-Check ist
// Pflicht — sonst triggert ESC auch wenn der User ein anderes Fenster
// fokussiert hat und ESC fürs Game-Menü drückt (Codebase-Pattern siehe
// Util/SearchSelector.cs:37).
if (View == SettingsView.Detail
&& ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows)
&& ImGui.IsKeyPressed(ImGuiKey.Escape))
{
View = SettingsView.Overview;
return;
}
if (View == SettingsView.Overview)
Overview.Draw();
else
DrawDetail();
ImGui.Separator();
DrawSaveButtons();
}
internal void OpenSection(int tabIndex)
{
CurrentTab = tabIndex;
View = SettingsView.Detail;
}
internal void OpenOverview()
{
View = SettingsView.Overview;
}
private void DrawDetail()
{
// Breadcrumb-Header — Akzent-Cyan, klickbar, führt zurück zur Overview
using (ImRaii.PushColor(ImGuiCol.Text, 0xFF00BED2u))
using (ImRaii.PushColor(ImGuiCol.Button, 0u))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, 0x33FFFFFFu))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, 0x55FFFFFFu))
{
if (ImGui.SmallButton("← Settings"))
{
View = SettingsView.Overview;
return;
}
}
ImGui.SameLine();
ImGui.TextUnformatted("·");
ImGui.SameLine();
ImGui.TextUnformatted(Tabs[CurrentTab].Name.Split("###")[0]);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
// Section-Content in voller Breite. Die Tab-Liste links ist überholt:
// der User ist bereits über die Card-Übersicht navigiert, eine zweite
// Tab-Liste daneben würde nur den Vanilla-Look zurückbringen. Falls
// der User in eine andere Section will, geht er zurück zur Overview
// (Breadcrumb / ESC).
var style = ImGui.GetStyle();
var height = ImGui.GetContentRegionAvail().Y - style.FramePadding.Y * 2 - style.ItemSpacing.Y - style.ItemInnerSpacing.Y * 2 - ImGui.CalcTextSize("A").Y;
using var child = ImRaii.Child("##chat2-settings-detail", new Vector2(-1, height));
if (child.Success)
Tabs[CurrentTab].Draw(false);
}
private void DrawSaveButtons()
{
var save = ImGui.Button(Language.Settings_Save);
ImGui.SameLine();
+95
View File
@@ -0,0 +1,95 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui;
internal sealed class SettingsOverview
{
private readonly SettingsWindow _window;
// Card-Reihenfolge entspricht 1:1 dem Tabs-Index in SettingsWindow.
// Themes ist Card-Index 2, eingeschoben zwischen Appearance und Window.
private static readonly (FontAwesomeIcon Icon, string TitleKey, string SubtextKey)[] CardDefs =
[
(FontAwesomeIcon.SlidersH, "Settings_Card_General_Title", "Settings_Card_General_Subtext"),
(FontAwesomeIcon.Palette, "Settings_Card_Appearance_Title", "Settings_Card_Appearance_Subtext"),
(FontAwesomeIcon.Swatchbook, "Settings_Card_Themes_Title", "Settings_Card_Themes_Subtext"),
(FontAwesomeIcon.WindowMaximize, "Settings_Card_Window_Title", "Settings_Card_Window_Subtext"),
(FontAwesomeIcon.Comments, "Settings_Card_Chat_Title", "Settings_Card_Chat_Subtext"),
(FontAwesomeIcon.FolderTree, "Settings_Card_Tabs_Title", "Settings_Card_Tabs_Subtext"),
(FontAwesomeIcon.ShieldAlt, "Settings_Card_Privacy_Title", "Settings_Card_Privacy_Subtext"),
(FontAwesomeIcon.Database, "Settings_Card_Database_Title", "Settings_Card_Database_Subtext"),
(FontAwesomeIcon.InfoCircle, "Settings_Card_Information_Title", "Settings_Card_Information_Subtext"),
];
public SettingsOverview(SettingsWindow window)
{
_window = window;
}
public void Draw()
{
var avail = ImGui.GetContentRegionAvail();
var columns = avail.X >= 700f ? 3 : 2;
var cardWidth = (avail.X - (columns - 1) * 8f) / columns;
var cardHeight = 96f;
for (var i = 0; i < CardDefs.Length; i++)
{
var (icon, titleKey, subtextKey) = CardDefs[i];
var title = HellionStrings.ResourceManager.GetString(titleKey) ?? titleKey;
var subtext = HellionStrings.ResourceManager.GetString(subtextKey) ?? subtextKey;
DrawCard(i, icon, title, subtext, cardWidth, cardHeight);
if ((i + 1) % columns != 0 && i != CardDefs.Length - 1)
ImGui.SameLine();
}
}
private void DrawCard(int index, FontAwesomeIcon icon, string title, string subtext, float w, float h)
{
// BeginGroup macht den Card-Bereich zu einem einzelnen ImGui-Layout-Item.
// Damit funktioniert SameLine() im Caller-Loop — sonst tracked ImGui die
// einzelnen InvisibleButton/Text-Items separat und das Wrapping bricht.
ImGui.BeginGroup();
var cursorBefore = ImGui.GetCursorScreenPos();
var clicked = ImGui.InvisibleButton($"##settings-card-{index}", new Vector2(w, h));
var hovered = ImGui.IsItemHovered();
var bgColor = hovered ? 0xFF22303Fu : 0xFF1A2538u;
var draw = ImGui.GetWindowDrawList();
draw.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bgColor, 4f);
// Inhalts-Overlay: Icon + Title + Subtext direkt mit DrawList in den
// Card-Bereich zeichnen, statt Cursor-Hopping mit SetCursorScreenPos.
// DrawList-Overlays ändern den Cursor nicht, BeginGroup/EndGroup
// hält den Layout-Anker stabil für SameLine.
var iconPos = cursorBefore + new Vector2(16f, 12f);
var titlePos = cursorBefore + new Vector2(16f, 40f);
var subtextPos = cursorBefore + new Vector2(16f, 62f);
var titleColor = ColourUtil.RgbaToAbgr(0xE6F4F1FFu);
var subtextColor = ColourUtil.RgbaToAbgr(0x8FA3B5FFu);
// Icon via FontAwesome — temporär den Font pushen, mit DrawList zeichnen
using (_window.Plugin.FontManager.FontAwesome.Push())
{
draw.AddText(iconPos, titleColor, icon.ToIconString());
}
draw.AddText(titlePos, titleColor, title);
draw.AddText(subtextPos, subtextColor, subtext);
ImGui.EndGroup();
if (clicked)
{
_window.OpenSection(index);
}
}
}
@@ -45,6 +45,13 @@ 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);
@@ -81,6 +88,7 @@ internal sealed class Appearance : ISettingsTab
{
ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp);
}
#pragma warning restore CS0612, CS0618
}
}
+9 -1
View File
@@ -21,6 +21,10 @@ internal sealed class Chat : ISettingsTab
public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat";
private SearchSelector.SelectorPopupOptions WordPopupOptions;
// Snapshot of EmoteCache.State for which we last built WordPopupOptions.
// Without this, an empty FilteredSheet (e.g., the user blocked every emote)
// would trigger a refill every frame the settings tab is open.
private EmoteCache.LoadingState? WordPopupOptionsBuiltFor;
internal Chat(Plugin plugin, Configuration mutable)
{
@@ -28,6 +32,7 @@ internal sealed class Chat : ISettingsTab
Mutable = mutable;
WordPopupOptions = RefillSheet();
WordPopupOptionsBuiltFor = EmoteCache.State;
}
private SearchSelector.SelectorPopupOptions RefillSheet()
@@ -160,9 +165,12 @@ internal sealed class Chat : ISettingsTab
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
ImGui.Spacing();
if (EmoteCache.State is EmoteCache.LoadingState.Done && WordPopupOptions.FilteredSheet.Length == 0)
if (EmoteCache.State is EmoteCache.LoadingState.Done
&& WordPopupOptions.FilteredSheet.Length == 0
&& WordPopupOptionsBuiltFor != EmoteCache.LoadingState.Done)
{
WordPopupOptions = RefillSheet();
WordPopupOptionsBuiltFor = EmoteCache.LoadingState.Done;
}
var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
+3 -1
View File
@@ -81,9 +81,11 @@ internal sealed class Database : ISettingsTab
{
try
{
// Delete both legacy files in one click — the previous if/else
// left the second file behind when both happened to exist.
if (old.Exists)
old.Delete();
else
if (migratedOld.Exists)
migratedOld.Delete();
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
}
+10 -3
View File
@@ -615,7 +615,7 @@ internal sealed class Privacy : ISettingsTab
CleanupRunning = true;
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
new Thread(() =>
var thread = new Thread(() =>
{
try
{
@@ -625,10 +625,14 @@ internal sealed class Privacy : ISettingsTab
// Bound the wait so a hung framework tick can't deadlock
// the background cleanup worker. See the matching comment in
// the retention path above for rationale.
// Note: FilterAllTabs() is called synchronously instead of
// FilterAllTabsAsync() — the async variant fires-and-forgets
// a Task.Run, so the .Wait() would return before the filter
// pass actually finishes. See AUDIT-2026-05-05 [QUAL-02].
if (!Plugin.Framework.Run(() =>
{
Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync();
Plugin.MessageManager.FilterAllTabs();
}).Wait(TimeSpan.FromSeconds(5)))
{
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
@@ -646,6 +650,9 @@ internal sealed class Privacy : ISettingsTab
CleanupRunning = false;
CleanupCounts = null;
}
}).Start();
});
// Background thread so a still-running cleanup doesn't hold up FFXIV exit.
thread.IsBackground = true;
thread.Start();
}
}
@@ -0,0 +1,70 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using HellionChat.Themes;
using HellionChat.Util;
namespace HellionChat.Ui.SettingsTabs;
internal static class ThemeMockup
{
// Zeichnet ein Mini-Chat-Window-Mockup mit den Theme-Werten direkt
// ins WindowDrawList. Keine Texture, keine Allocation pro Frame —
// alles via DrawList.AddRectFilled / AddText.
public static void Draw(Vector2 origin, Vector2 size, Theme theme)
{
var draw = ImGui.GetWindowDrawList();
var c = theme.Colors;
// Window-Bg
draw.AddRectFilled(origin, origin + size, ColourUtil.RgbaToAbgr(c.WindowBg | 0xFFu), theme.Layout.WindowRounding);
// Title-Bar
var titleHeight = 14f;
draw.AddRectFilled(
origin,
new Vector2(origin.X + size.X, origin.Y + titleHeight),
ColourUtil.RgbaToAbgr(c.Identity), theme.Layout.WindowRounding);
// Tab-Bar — 3 Mini-Tabs
var tabY = origin.Y + titleHeight + 4f;
var tabHeight = 12f;
for (var i = 0; i < 3; i++)
{
var tabX = origin.X + 6f + i * 28f;
var color = i == 0 ? c.FrameBg : c.ChildBg;
draw.AddRectFilled(
new Vector2(tabX, tabY),
new Vector2(tabX + 26f, tabY + tabHeight),
ColourUtil.RgbaToAbgr(color), theme.Layout.TabRounding);
if (i == 0) // Active-Pill
{
draw.AddRectFilled(
new Vector2(tabX, tabY + tabHeight - 2f),
new Vector2(tabX + 26f, tabY + tabHeight),
ColourUtil.RgbaToAbgr(c.Primary));
}
}
// Card-Row mit Mock-Sender + Text
var rowY = tabY + tabHeight + 6f;
var rowHeight = 18f;
draw.AddRectFilled(
new Vector2(origin.X + 6f, rowY),
new Vector2(origin.X + size.X - 6f, rowY + rowHeight),
ColourUtil.RgbaToAbgr(c.Surface), 2f);
// Akzent-Button rechts unten
var btnW = 28f;
var btnH = 10f;
var btnX = origin.X + size.X - btnW - 6f;
var btnY = origin.Y + size.Y - btnH - 6f;
draw.AddRectFilled(
new Vector2(btnX, btnY),
new Vector2(btnX + btnW, btnY + btnH),
ColourUtil.RgbaToAbgr(c.Accent), theme.Layout.FrameRounding);
// Border um das gesamte Mockup
draw.AddRect(origin, origin + size, ColourUtil.RgbaToAbgr(c.Border), theme.Layout.WindowRounding);
}
}
+223
View File
@@ -0,0 +1,223 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Util;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Themes : ISettingsTab
{
private readonly Plugin Plugin;
private readonly Configuration Mutable;
// Tracks ob der User die Apply-Frage für das aktive Theme bereits
// beantwortet hat. Banner wird nur angezeigt wenn das Theme ein
// ChatColors-Set hat UND noch keine Antwort vorliegt UND die aktuellen
// Mutable.ChatColours davon abweichen.
private string? _applyDismissedFor;
public string Name => HellionStrings.ResourceManager.GetString("Settings_Tab_Themes") ?? "Themes" + "###tabs-themes";
internal Themes(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
public void Draw(bool changed)
{
var registry = Plugin.ThemeRegistry;
var active = registry.Get(Mutable.Theme);
var activeLabelTemplate = HellionStrings.ResourceManager.GetString("Settings_Themes_Active") ?? "Active: {0}";
ImGui.TextUnformatted(string.Format(activeLabelTemplate, active.Name));
using (ImRaii.PushColor(ImGuiCol.Text, 0xFF8FA3B5u))
ImGui.TextUnformatted(active.Author);
DrawChatColorsApplyBanner(active);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var builtInsLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_BuiltIns") ?? "Built-in themes";
ImGui.TextUnformatted(builtInsLabel);
ImGui.Spacing();
DrawThemeGrid(registry.AllBuiltIns(), active.Slug);
var customs = registry.AllCustom().ToList();
if (customs.Count > 0)
{
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var customLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_Custom") ?? "Custom themes";
ImGui.TextUnformatted(customLabel);
ImGui.Spacing();
DrawThemeGrid(customs, active.Slug);
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var openFolderLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_OpenFolder") ?? "Open themes folder";
if (ImGui.Button(openFolderLabel))
{
var dir = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "themes");
Directory.CreateDirectory(dir);
Dalamud.Utility.Util.OpenLink(dir);
}
ImGui.SameLine();
var exportLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_ExportActive") ?? "Export active...";
if (ImGui.Button(exportLabel))
{
var dir = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "themes");
Directory.CreateDirectory(dir);
var fileName = $"{active.Slug}.export.json";
var path = Path.Combine(dir, fileName);
var json = ThemeJsonWriter.Serialize(active);
File.WriteAllText(path, json);
Plugin.Log.Information($"Exported active theme '{active.Slug}' to {path}");
}
}
private void DrawThemeGrid(IEnumerable<Theme> themes, string activeSlug)
{
var avail = ImGui.GetContentRegionAvail();
var columns = avail.X >= 700f ? 3 : 2;
var cardWidth = (avail.X - (columns - 1) * 8f) / columns;
var cardHeight = 140f; // Mockup + Name + Author brauchen den Platz
var list = themes.ToList();
for (var i = 0; i < list.Count; i++)
{
DrawThemeCard(list[i], activeSlug, cardWidth, cardHeight);
// SameLine zwischen den Cards einer Reihe; am Spalten-Ende kein
// SameLine, dann beginnt automatisch eine neue Zeile.
if ((i + 1) % columns != 0 && i != list.Count - 1)
ImGui.SameLine();
}
}
private void DrawThemeCard(Theme theme, string activeSlug, float w, float h)
{
// BeginGroup macht den Card-Bereich zu einem einzelnen ImGui-Layout-Item.
// Damit funktioniert SameLine() im Caller-Loop — sonst tracked ImGui die
// einzelnen InvisibleButton-Items separat und das Wrapping bricht.
ImGui.BeginGroup();
var isActive = string.Equals(theme.Slug, activeSlug, StringComparison.OrdinalIgnoreCase);
var cursorBefore = ImGui.GetCursorScreenPos();
var clicked = ImGui.InvisibleButton($"##theme-card-{theme.Slug}", new Vector2(w, h));
var hovered = ImGui.IsItemHovered();
var draw = ImGui.GetWindowDrawList();
var bg = ColourUtil.RgbaToAbgr(theme.Colors.WindowBg | 0xFFu);
draw.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bg, 4f);
if (isActive)
{
var border = ColourUtil.RgbaToAbgr(theme.Colors.Primary);
draw.AddRect(cursorBefore, cursorBefore + new Vector2(w, h), border, 4f, ImDrawFlags.None, 2f);
}
else if (hovered)
{
var border = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight & 0xFFFFFF99u);
draw.AddRect(cursorBefore, cursorBefore + new Vector2(w, h), border, 4f, ImDrawFlags.None, 1f);
}
// Mini-Mockup oben — DrawList-Operation, kein Cursor-Hopping
var mockupOrigin = cursorBefore + new Vector2(12f, 12f);
var mockupSize = new Vector2(w - 24f, 60f);
ThemeMockup.Draw(mockupOrigin, mockupSize, theme);
// Name + Author direkt via DrawList (statt SetCursorScreenPos +
// TextUnformatted), damit der ImGui-Layout-Cursor stabil bleibt
// und die BeginGroup/EndGroup-Klammer den Card-Bereich als ein
// Layout-Item führt.
var textColor = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedColor = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
draw.AddText(cursorBefore + new Vector2(12f, 80f), textColor, theme.Name);
draw.AddText(cursorBefore + new Vector2(12f, 100f), mutedColor, theme.Author);
ImGui.EndGroup();
if (clicked)
{
Mutable.Theme = theme.Slug;
Plugin.ThemeRegistry.Switch(theme.Slug);
_applyDismissedFor = null; // Banner für neues Theme wieder zeigen
}
}
private void DrawChatColorsApplyBanner(Theme active)
{
// Klassik hat per Design keine ChatColors — kein Banner.
if (active.ChatColors is not { Channels.Count: > 0 } themeChatColors)
return;
// User hat die Frage bereits für genau dieses Theme beantwortet.
if (_applyDismissedFor == active.Slug)
return;
// Wenn die aktuellen Channel-Colors bereits exakt mit dem Theme-Vorschlag
// übereinstimmen, gibt's nichts zu tun.
var alreadyMatching = themeChatColors.Channels.All(kvp =>
Mutable.ChatColours.TryGetValue(kvp.Key, out var current) && current == kvp.Value);
if (alreadyMatching)
return;
ImGui.Spacing();
// Dezent-Akzent-Banner mit Border in Theme-Primary
var border = ColourUtil.RgbaToAbgr(active.Colors.Primary);
var bgFill = ColourUtil.RgbaToAbgr((active.Colors.Surface & 0xFFFFFF00u) | 0xCCu);
var origin = ImGui.GetCursorScreenPos();
var width = ImGui.GetContentRegionAvail().X;
var height = 64f;
var draw = ImGui.GetWindowDrawList();
draw.AddRectFilled(origin, origin + new Vector2(width, height), bgFill, 4f);
draw.AddRect(origin, origin + new Vector2(width, height), border, 4f, ImDrawFlags.None, 1f);
var hint = HellionStrings.ResourceManager.GetString("Settings_Themes_ApplyChatColors_Hint")
?? "This theme suggests its own chat channel colours.";
var applyLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_ApplyChatColors_Apply")
?? "Apply";
var keepLabel = HellionStrings.ResourceManager.GetString("Settings_Themes_ApplyChatColors_Keep")
?? "Keep current";
var textColor = ColourUtil.RgbaToAbgr(active.Colors.TextPrimary);
draw.AddText(origin + new Vector2(12f, 10f), textColor, hint);
// Buttons als InvisibleButton + DrawList-Overlay, damit sie konsistent
// zum Banner-Look bleiben.
using (ImRaii.PushColor(ImGuiCol.Button, active.Colors.Primary))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, active.Colors.PrimaryLight))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, active.Colors.PrimaryDark))
{
ImGui.SetCursorScreenPos(origin + new Vector2(12f, 32f));
if (ImGui.Button(applyLabel))
{
foreach (var kvp in themeChatColors.Channels)
Mutable.ChatColours[kvp.Key] = kvp.Value;
_applyDismissedFor = active.Slug;
}
}
ImGui.SameLine();
if (ImGui.Button(keepLabel))
{
_applyDismissedFor = active.Slug;
}
// Cursor unter den Banner setzen
ImGui.SetCursorScreenPos(origin + new Vector2(0f, height + 8f));
ImGui.Spacing();
}
}
+4 -5
View File
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Game;
using Dalamud.Utility;
@@ -233,9 +232,6 @@ internal static class AutoTranslate
.ToList();
}
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] b1, byte[] b2, nuint count);
internal static void ReplaceWithPayload(ref byte[] bytes)
{
var search = "<at:"u8.ToArray();
@@ -279,7 +275,10 @@ internal static class AutoTranslate
start = -1;
}
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (nuint) search.Length) == 0)
// Pure managed comparison via Span avoids the msvcrt.dll P/Invoke,
// which is fragile under Wine and triggered an extra managed-to-
// unmanaged copy per check.
if (i + search.Length < bytes.Length && bytes.AsSpan(i, search.Length).SequenceEqual(search))
start = i;
}
}
+16
View File
@@ -62,4 +62,20 @@ internal static class ColourUtil {
return ((uint) a << 24) | ((uint) nb << 16) | ((uint) ng << 8) | nr;
}
public static uint HexToRgba(string hex)
{
ArgumentNullException.ThrowIfNull(hex);
var s = hex.StartsWith('#') ? hex[1..] : hex;
if (s.Length != 6 && s.Length != 8)
throw new FormatException($"Hex colour must be 6 or 8 hex digits, got {s.Length}: '{hex}'");
if (!uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var value))
throw new FormatException($"Hex colour '{hex}' is not a valid hexadecimal value");
if (s.Length == 6)
value = (value << 8) | 0xFFu; // RRGGBB → RRGGBBFF
return value;
}
}
+2
View File
@@ -25,6 +25,8 @@ public class ColorPayload
return payload;
case 0xE9:
var param = stream.ReadByte();
if (param == -1)
throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(stream));
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
payload.Enabled = true;
payload.UnshiftedColor = globalValue;
+21 -4
View File
@@ -49,9 +49,21 @@ public readonly unsafe ref struct GfdFileView
var entries = Entries;
if (DirectLookup)
{
if (iconId <= entries.Length)
// Resolve redirects on the direct-lookup path too — the binary-search
// path follows them, and skipping them here was inconsistent for
// contiguous ID sets.
var visited = 0;
while (iconId <= entries.Length)
{
entry = entries[(int)(iconId - 1)];
if (followRedirect && entry.Redirect != 0 && entry.Redirect != iconId)
{
if (++visited > entries.Length)
break; // cycle guard
iconId = entry.Redirect;
continue;
}
return !entry.IsEmpty;
}
@@ -146,12 +158,17 @@ public readonly unsafe ref struct GfdFileView
internal static class IconUtil
{
private static byte[]? GfdFile;
public static unsafe GfdFileView GfdFileView
public static GfdFileView GfdFileView
{
get
{
GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
if (GfdFile is null)
{
var file = Plugin.DataManager.GetFile("common/font/gfdata.gfd")
?? throw new FileNotFoundException("Failed to load common/font/gfdata.gfd from the game data.");
GfdFile = file.Data;
}
return new GfdFileView(GfdFile);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ internal class Lender<T>
internal Lender(Func<T> ctor)
{
Ctor = ctor;
Ctor = ctor ?? throw new ArgumentNullException(nameof(ctor));
}
internal void ResetCounter()
+13
View File
@@ -4,8 +4,21 @@ namespace HellionChat.Util;
public static class MemoryUtil
{
// Diagnostic helper. Pointer dereferences here would crash on a null/garbage
// address and a huge length would log megabytes of raw bytes; both are easy
// to trigger from a debugger and pollute the log with potentially sensitive
// game-state. Validate the inputs before reading.
private const int MaxDumpLength = 4096;
public static unsafe void PrintMemoryArea(nint address, int length)
{
if (address == nint.Zero)
throw new ArgumentException("Memory address cannot be zero.", nameof(address));
if (length <= 0)
throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be positive.");
if (length > MaxDumpLength)
throw new ArgumentOutOfRangeException(nameof(length), length, $"Length exceeds the {MaxDumpLength}-byte safety cap.");
var ptr = (byte*)address;
var str = new StringBuilder("\n");
for(var i = 0; i < length; i++)
+2
View File
@@ -66,6 +66,8 @@ internal class UriPayload(Uri uri) : Payload
public static UriPayload ResolveUri(string rawUri)
{
ArgumentNullException.ThrowIfNull(rawUri);
if (string.IsNullOrWhiteSpace(rawUri))
throw new UriFormatException("URI cannot be empty or whitespace.");
// Check for an expected scheme '://', if not add 'https://'
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
+5 -1
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
namespace HellionChat.Util;
@@ -23,6 +24,9 @@ internal static class StringUtil
var bytes = Math.Abs(byteCount);
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return (Math.Sign(byteCount) * num).ToString("N0") + suf[place];
// "0.#" keeps the rounded fractional digit (1.5 GB stays "1.5GB"); "N0"
// would truncate it back to integer. InvariantCulture pins the decimal
// separator to '.' so a German locale doesn't render "1,5GB".
return (Math.Sign(byteCount) * num).ToString("0.#", CultureInfo.InvariantCulture) + suf[place];
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+3 -3
View File
@@ -16,7 +16,7 @@
},
"MessagePack": {
"type": "Direct",
"requested": "[3.1.4, )",
"requested": "[3.1.4, 4.0.0)",
"resolved": "3.1.4",
"contentHash": "BH0wlHWmVoZpbAPyyt2Awbq30C+ZsS3eHSkYdnyUAbqVJ22fAJDzn2xTieBeoT5QlcBzp61vHcv878YJGfi3mg==",
"dependencies": {
@@ -44,13 +44,13 @@
},
"Pidgin": {
"type": "Direct",
"requested": "[3.5.1, )",
"requested": "[3.5.1, 4.0.0)",
"resolved": "3.5.1",
"contentHash": "zU7tkXlF3D6d2GLTjJDomAL3nnl4AwfZvSgSz8D4b+Ry21/clqedYlxBnEAkAU/bkGfEv6uRR7QCdWZUpKrB/g=="
},
"SixLabors.ImageSharp": {
"type": "Direct",
"requested": "[3.1.12, )",
"requested": "[3.1.12, 4.0.0)",
"resolved": "3.1.12",
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
},
+34 -29
View File
@@ -12,7 +12,7 @@ because no data ever leaves your machine on the maintainer's
infrastructure. Independently of that, the plugin is built so that
you can act on your own data the way the GDPR expects.
Last reviewed: 2026-05-03 (HellionChat v0.5.4).
Last reviewed: 2026-05-05 (HellionChat v1.1.0).
---
@@ -23,10 +23,9 @@ Last reviewed: 2026-05-03 (HellionChat v0.5.4).
- The plugin does not phone home. There is no telemetry, no analytics,
no crash reporter, no usage counter, no remote update check beyond
what Dalamud itself does.
- Two outbound network calls exist by design: the BetterTTV emote
service (for chat emotes) and the Square Enix Lodestone font CDN
(for the in-game symbol font). Both are documented in detail below
and both can be reasoned about per request.
- One outbound network call exists by design: the BetterTTV emote
service (for chat emotes). It is documented in detail below and
can be reasoned about per request.
- You can export every message the plugin has stored, in Markdown,
JSON or CSV, and you can wipe stored history per channel, per date
range, or globally.
@@ -103,8 +102,17 @@ on your behalf.
reaches BetterTTV (unavoidable for any HTTPS request); the request
itself contains no identifying user data, no character name, no
message text. Only the emote ID being looked up is in the URL path.
- **When it triggers:** Only when an incoming message contains an
emote token that is on the BetterTTV emote list.
- **When it triggers:**
- The emote *list* (global emotes plus the top-1500 community emotes
over fifteen API pages) is fetched from `api.betterttv.net` once
per session at plugin startup, provided the **Show emotes** option
is on. This first list-fetch happens before any chat message has
arrived; BetterTTV's edge therefore sees your IP as soon as the
plugin loads, not only after an emote is mentioned.
- The individual emote *images* on `cdn.betterttv.net` are fetched
on demand, only when an incoming chat message contains a token
matching one of the cached IDs. These are cached locally
(`emoteCache/`) and reused across sessions.
- **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once
per machine and reused.
- **How to opt out:** Turn off the **Show emotes** option in
@@ -114,24 +122,22 @@ on your behalf.
Source: `HellionChat/EmoteCache.cs`.
### 2. Square Enix Lodestone font (`img.finalfantasyxiv.com`)
### 2. Square Enix Lodestone font — removed in v1.0.4
- **What it does:** Downloads the `FFXIV_Lodestone_SSF.ttf` font file
from the official Square Enix Lodestone CDN once during font setup,
so the plugin can render in-game special symbols (job icons, item
glyphs, etc.) inside ImGui.
- **What is sent:** A single HTTPS GET request to the public Square
Enix font URL. Your IP address reaches Square Enix (unavoidable);
no character data, no plugin identifier, no message content.
- **When it triggers:** Once per font initialisation, not per session
if the file is already cached locally.
- **Cached:** Yes, by Dalamud's font subsystem.
- **How to opt out:** This call is part of the font pipeline inherited
from upstream Chat 2 and not toggleable from the settings UI today.
If a user-facing opt-out for this would be useful for you, please
open a feature-request issue.
Earlier versions of HellionChat (and upstream Chat 2) downloaded
`FFXIV_Lodestone_SSF.ttf` from `img.finalfantasyxiv.com` once during
font setup. That code path was a leftover from upstream's removed
webinterface feature and was no longer consumed anywhere — the in-game
symbol glyphs (job icons, item glyphs, status effects) come from
Dalamud's bundled symbol-font helper, not from the downloaded TTF.
Source: `HellionChat/FontManager.cs`.
The download was removed in v1.0.4. As of that version HellionChat
makes no automatic network call to Square Enix or to any
`finalfantasyxiv.com` host.
Cached `FFXIV_Lodestone_SSF.ttf` files left over from earlier versions
remain in `pluginConfigs/HellionChat/` until manually deleted; they
are no longer read.
### Links you click yourself (no automatic traffic)
@@ -209,14 +215,13 @@ retroactive cleanup to apply retroactively, by design.
| Party | Why they appear | What reaches them | Their privacy policy |
| --- | --- | --- | --- |
| BetterTTV (NightDev LLC) | Optional emote rendering | HTTPS request for an emote ID; your IP | <https://betterttv.com/privacy> |
| Square Enix | Lodestone font download (once) | HTTPS request for the font file; your IP | <https://www.square-enix.com/privacy> |
| GitHub (Microsoft) | Plugin distribution via custom repo, issue tracker | Whatever GitHub sees from any HTTPS request to a public repo | <https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement> |
| Dalamud / XIVLauncher (goatcorp) | Plugin loader, font subsystem, repo polling | Whatever Dalamud reports for itself; out of HellionChat's scope | <https://github.com/goatcorp/Dalamud> |
Square Enix and GitHub are unavoidable for anyone playing FFXIV
through Dalamud at all. BetterTTV is the only third party HellionChat
introduces on top of the baseline that is not also part of using FFXIV
or Dalamud, and BetterTTV is opt-out via settings.
GitHub and the Dalamud/XIVLauncher loader are unavoidable for anyone
playing FFXIV through Dalamud at all. BetterTTV is the only third
party HellionChat introduces on top of that baseline, and it is
opt-out via settings.
---
@@ -232,7 +237,7 @@ direct dependencies the plugin pulls in:
- `SixLabors.ImageSharp` — image decoding (used for the BetterTTV
emote pipeline), no network on its own.
The two network calls listed under "Outbound network calls" are
The single network call listed under "Outbound network calls" is
written directly in HellionChat's own source, not delegated to a
dependency.
+8
View File
@@ -62,6 +62,13 @@ Hellion Chat baut auf [Chat 2](https://github.com/Infiziert90/ChatTwo) von **Inf
- **Mitgelieferte Hellion-Schrift** (Exo 2, OFL-1.1) als optionaler Default statt System-Font.
- **Hellion-Logo** im Plugin-Bundle und in der Dalamud-Plugin-Liste.
#### Custom Themes (v1.1.0)
HellionChat 1.1.0 bringt eine Theme-Engine mit fünf eingebauten Themes
(Hellion Arctic, Chat 2 Klassik, Event Horizon, Moonlit Bloom, Mint Grove)
und ein JSON-basiertes Authoring-Format für eigene Themes. Schema und
Schritt-für-Schritt-Anleitung in [`docs/THEME-AUTHORING.md`](docs/THEME-AUTHORING.md).
### Pop-Out Convenience (v0.6.0)
- **Eingabe-Bar in Pop-Out-Fenstern** als globaler Opt-In in Settings → Fenster → Fenster-Rahmen. Wenn aktiv hat jedes Pop-Out-Window unten einen kompakten Input mit kanal-farbigem Icon-Button und Text-Eingabe — kein Wechsel mehr ins Hauptfenster für eine schnelle Antwort.
@@ -302,6 +309,7 @@ Dokumentation lebt unter [`docs/`](docs/).
| [`docs/CONTRIBUTORS.md`](docs/CONTRIBUTORS.md) | Tester, Übersetzer und Code-Beiträger der Hellion-Seite. |
| [`docs/LEARNING-JOURNEY.md`](docs/LEARNING-JOURNEY.md) | Entwicklungsgeschichte, vom Web-Stack zu C# / Dalamud, was ich aus dem Fork gelernt habe. |
| [`docs/IPC.md`](docs/IPC.md) | IPC-Kanal-Reference, Tuple-Payload-Felder, Migrations-Diff für Drittplugins. |
| [`docs/THEME-AUTHORING.md`](docs/THEME-AUTHORING.md) | Theme-Engine-Authoring-Guide (EN): JSON-Schema, Color-/Layout-Slots, Channel-Identity-Regeln, Validierung. |
| [`docs/UPSTREAM_SYNC.md`](docs/UPSTREAM_SYNC.md) | Cherry-Pick-Policy gegenüber Chat 2. |
| [`docs/THIRD_PARTY_NOTICES.md`](docs/THIRD_PARTY_NOTICES.md) | NuGet-Dependencies mit Lizenzen, Bundled Assets, Network-Status pro Komponente. |
| [`docs/AI_DISCLOSURE.md`](docs/AI_DISCLOSURE.md) | Offenlegung der KI-Unterstützung im Entwicklungsprozess. |
+52
View File
@@ -12,6 +12,58 @@ und verlinkt für Details auf die Release-Pages.
---
## [1.1.0] — 2026-05-05 — Theme Foundation
Erster großer UI-Cycle nach v1.0.0. Theme-Engine, fünf Built-In-Themes,
Custom-Themes via JSON, Settings-Card-Grid.
### Hinzugefügt
- **Theme-Engine** mit fünf Built-In-Themes: Hellion Arctic (Default),
Chat 2 Klassik, Event Horizon, Moonlit Bloom, Mint Grove.
- **Settings → Themes** mit Mini-Mockup-Preview pro Theme. Klick auf
eine Card switcht sofort das ganze Plugin (Chat, Settings, Pop-Out).
- **Custom-Themes via JSON** in `pluginConfigs/HellionChat/themes/`.
Beim ersten Start wird `example-theme.json` als Vorlage abgelegt.
- **Optional Theme-Chat-Channel-Colors**: Themes können eigene
Channel-Farben mitliefern. Beim Switch erscheint ein Banner mit
*Übernehmen / Behalten* — nie automatisch.
- **Settings-Card-Grid**: neue Übersicht beim Öffnen, Card-Klick führt
in die Detail-Ansicht der Section. Breadcrumb + ESC führen zurück.
- **`docs/THEME-AUTHORING.md`** als Anleitung zum Schreiben eigener
Themes, mit Hellion-Forge-Branding.
### Geändert
- **Plugin-Icon** auf Hellion Forge Hammer (vorher ChatTwo-Derivat).
- **Settings-Detail-View** verwendet die volle Breite — die zweite
Tab-Liste links ist weg, weil die Card-Übersicht den Wechsel
übernimmt.
- **`HellionStyle.PushGlobal`** ist jetzt theme-driven (`PushGlobal(theme,
opacity)`) statt const-palette-driven.
- **Configuration v13 → v14**: alle User landen auf `hellion-arctic`.
Wer den Upstream-Look will, wählt `chat2-classic` in Settings →
Themes.
### Veraltet
- `Configuration.HellionThemeEnabled` und `HellionThemeWindowOpacity`
bleiben für ein Release lesbar als Safety-Net, werden aber nicht
mehr ausgewertet. Entfernung geplant in v1.2.0.
### Sicherheit
- Custom-Theme-JSON-Loader prüft `schemaVersion`, Pflichtfelder und
Hex-Format. Ungültige Themes werden mit Warning übersprungen, das
Plugin lädt mit Built-Ins weiter.
### Intern
- 51 lokale Unit-Tests (Theme-Records, Registry, JSON-Round-Trip,
Sanity pro Built-In-Theme). Tests sind gitignored.
---
## [1.0.3] — 2026-05-04 — Polish patch
Vier kleine Polish-Items aus dem Backlog gebündelt:
+27 -10
View File
@@ -12,22 +12,39 @@ Privacy-First-Schnittmenge des Plugins erweisen.
---
## Nächster Cycle (v1.1.0)
## Nächster Cycle (v1.2.0)
**Layout Refresh** — sichtbare Modernisierung des Chat-Windows selbst.
- Top-Tabs-Refresh mit Akzent-Pill-Underline statt Background-Fill
- Sidebar-Tabs (existing) bekommen Icons + vertikale Pill am Window-Rand
- Bottom-Status-Bar (Channel-Indikator, Privacy-Badge, Tab-Count,
Tells, Version)
- Card-Rows als Default-Message-Rendering, mit Compact-Density-Toggle
- Per-Tab Custom-Icons im Tabs-Settings-Dialog
- Removal des deprecated `HellionThemeEnabled`/`HellionThemeWindowOpacity`
Configuration-Felder
Spec liegt in [[Hellion Chat UI Modernisierung Spec]] (Vault).
## v1.1.0 — Theme Foundation (released 2026-05-05)
Theme-Engine mit fünf Built-In-Themes, Settings-Card-Grid, Custom-
Themes via JSON, Theme-Authoring-Doku. Plugin-Icon auf Hellion Forge.
Siehe `docs/CHANGELOG.md` für Details.
Aus dem ursprünglichen v1.1.0-Plan (Ad-Block / Spam-Filter, Receive-
Suppressed-Tells-Toggle) wurden zugunsten der Theme-Engine zurück­
gestellt — beide Items leben weiter im Mittelfrist-Block.
## Mittelfristig (v1.2.x v1.3.0)
- **Ad-Block / Spam-Filter** — Hybrid-Konzept aus eigenem Light-Filter und
optionaler `NoSoliciting`-IPC-Integration. Adressiert Werbe-Spam in
öffentlichen Channels und Tells. Größter Block des Cycles.
öffentlichen Channels und Tells. Aus dem v1.1.0-Plan zurückgestellt.
- **Receive-Suppressed-Tells-Toggle** — Auto-Tell-Tabs greift auch wenn ein
Drittplugin (z.B. XIVMessenger) die /tell-Anzeige global suppressed.
Gleicher Hook-Layer wie Ad-Block, deshalb gebündelt.
## Mittelfristig (v1.1.x v1.2.0)
- **Plugin-weite Theme-Varianten** — über die ChatColours-Presets aus v0.6.0
hinaus. Mehrere komplette Window-Themes (Frame, Surface, Border, Text)
inkl. Farbfamilien mit Helligkeits-Abstufungen. Anknüpfung an
Hellion-Online-Media-Brand-Themes (Event Horizon, Night Blue, Indigo Violet
und weitere).
- **Database-Viewer Inline-Search** — Volltext-Suche im DB-Viewer via
SQLite FTS5. Aktuell gibt es nur Datums- und Channel-Filter.
- **TempTell Persistence** — Pin-Toggle auf TempTell-Tabs damit ausgewählte
+185
View File
@@ -0,0 +1,185 @@
<p align="center">
<img src="images/hellion-forge.png" alt="Hellion Forge" width="180" />
</p>
# Theme Authoring Guide
> Built by **Hellion Forge** — the plugin workshop arm of [Hellion Online Media](https://hellion-media.de). HellionChat ships with five built-in themes; this guide walks you through writing your own.
## TL;DR
1. Open Settings → Themes → **Open themes folder**
2. Copy `example-theme.json` to `<your-name>.json` in the same folder
3. Edit the file with any text editor
4. Reload the plugin (toggle off/on in `/xlplugins`)
5. Your theme appears in the Custom-Themes section in Settings → Themes
That's the whole loop. The rest of this document is reference.
## File location
```
%APPDATA%\XIVLauncher\pluginConfigs\HellionChat\themes\
```
(or the equivalent path on Linux/macOS — Settings → Themes → "Open themes folder" opens it directly).
Each `*.json` file in this folder is loaded as one theme. The `example-theme.json` that HellionChat seeds on first launch is your starting template.
## File format
Theme JSON has four blocks:
```json
{
"schemaVersion": 1,
"slug": "your-slug",
"name": "Your Theme Name",
"author": "You",
"description": "One-line description shown under the theme name.",
"colors": { ... 21 color slots ... },
"layout": { ... 9 layout values ... },
"chatChannels": { ... optional, channel-name hex ... }
}
```
### Top-level fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `schemaVersion` | int | yes | Always `1` for HellionChat 1.1.0. The plugin warns and skips themes with a different number. |
| `slug` | string | yes | Lowercase, hyphenated. Must be unique across all themes (built-in slugs are reserved). |
| `name` | string | yes | Display name in the picker. |
| `author` | string | yes | Shown small under the theme name. |
| `description` | string | yes | One short sentence. |
| `colors` | object | yes | All 21 slots required (see below). |
| `layout` | object | yes | All 9 slots required (see below). |
| `chatChannels` | object | no | Optional channel-name → hex map (see below). |
### Color slots
All values are 6-digit `#RRGGBB` or 8-digit `#RRGGBBAA` hex strings. Six-digit values get an implicit `FF` alpha.
| Slot | Role |
|---|---|
| `primary` | Brand color — used on buttons, sliders, check marks, highlighted separators. |
| `primaryDark` | Pressed-button stage. |
| `primaryLight` | Hovered-button / link-text stage. |
| `primaryGlow` | Glow / subtle accent (typically primary with ~60% alpha). |
| `accent` | Counter-accent — scrollbar grab on hover/active, resize grip, optional CTA. |
| `accentDark` / `accentLight` | Dark/light siblings of accent. |
| `identity` | Title-bar active color and active-tab color. Often equals `primaryDark`. |
| `windowBg` | Outermost window background. |
| `childBg` | Inner panel / popup background. |
| `frameBg` | Input fields, sliders, combos. |
| `surface` | Card surfaces, headers, selectables. |
| `surfaceHover` | Hovered card / header step. |
| `border` | Panel borders. Typically primary with ~40% alpha for a brand-tinted edge. |
| `textPrimary` | Body text. Soft off-white reads better than pure `#FFFFFF` on dark backgrounds. |
| `textMuted` | Captions, secondary lines. |
| `textDim` | Disabled / hint text, separators. |
| `statusSuccess` | Green-ish for success notifications. |
| `statusDanger` | Red for errors. |
| `statusWarning` | Amber for warnings. |
| `statusInfo` | Cyan-ish info. Often equals primary. |
### Layout slots
All values are floats in pixels. `BorderSize` is 0 or 1 (no thicker borders look right with ImGui's edge anti-aliasing).
| Slot | Typical range | Notes |
|---|---|---|
| `windowRounding` | 08 | 0 = sharp upstream look; 46 = softer "app" feel. |
| `childRounding` | 06 | Usually 1 less than `windowRounding`. |
| `popupRounding` | 06 | Same as `childRounding`. |
| `frameRounding` | 04 | For inputs, sliders. |
| `grabRounding` | 04 | Slider grab dot. |
| `tabRounding` | 04 | Tab corners. |
| `scrollbarRounding` | 04 | Scrollbar grab. |
| `windowBorderSize` | 0 or 1 | 1 reads better in dark themes. |
| `frameBorderSize` | 0 or 1 | Usually matches windowBorderSize. |
### Optional `chatChannels`
If present, your theme proposes its own chat-channel colors. Property names are `ChatType` enum values (case-insensitive). Unknown names are skipped silently — safe for forward-compat.
```json
"chatChannels": {
"Say": "#FFFFFF",
"Yell": "#FFE066",
"Shout": "#FFA040",
"TellIncoming": "#FF99CC",
"TellOutgoing": "#FF99CC",
"Party": "#80C0E8",
"FreeCompany": "#4DD9E8",
"NoviceNetwork": "#A8E060",
"Linkshell1": "#A8E060"
}
```
The user is asked **once per theme switch** whether to apply these colors — never auto-overwriting existing picks. The banner shows up only if your suggested colors differ from the user's current `Configuration.ChatColours`.
#### Channel-identity rule
**Don't break FFXIV channel identity.** Players have used these conventions for over a decade:
| Channel | Convention | Why |
|---|---|---|
| Say | white / off-white | Default-readable speech. |
| Yell | yellow | Urgent broadcast. |
| Shout | orange | Local urgent. |
| Tell | pink-magenta | Whisper, must stand out. |
| Party | light blue | Group ops. |
| FreeCompany | cyan-teal | Guild ops. |
| NoviceNetwork | lime-green | Mentor channel. |
A theme can tint these toward its brand family (e.g., a purple theme can shift Tell from `#FF99CC` to `#E090FF`), but **don't** flip them (Tell suddenly green, Yell suddenly cyan). RP groups and combat-spec setups depend on the visual hierarchy.
The four colored built-in themes (Hellion Arctic, Event Horizon, Moonlit Bloom, Mint Grove) all follow this rule — read their JSON for reference. Chat 2 Klassik intentionally ships without `chatChannels` so the user keeps their existing picks.
## Theme families
Naming convention `<color>-<modifier>` is recommended for theme families. The first member of a family is the lightest/brightest:
- `mint-grove` (current built-in, light mint)
- `forest-grove` (planned, dark emerald)
- `moss-grove` (planned, mid muted)
Code-wise families have no special handling — only the slug naming hints at the relationship. The picker may group families later, but that's not required.
## Validation and errors
When HellionChat loads your theme:
- **Schema mismatch** (`schemaVersion != 1`): theme is skipped, warning written to `/xllog`.
- **Missing required field** (e.g., no `slug`): theme is skipped, warning written.
- **Invalid hex** (e.g., `#GGHHII`): theme is skipped, warning written.
- **Unknown channel name** in `chatChannels`: that one channel is skipped silently, the rest of the theme loads normally.
Check `/xllog` after a plugin reload to see what loaded and what didn't.
## Testing your theme
1. Edit the JSON, save the file.
2. Reload the plugin: `/xlplugins` → toggle HellionChat off, then on.
3. Settings → Themes → click your theme card.
4. Watch every plugin window (chat, settings, pop-out) and pick something to fix.
5. Tweak. Reload. Repeat.
Tip: the **Settings → Themes** picker shows a mini-mockup per theme — your colors are visible before you switch.
## Sharing themes
Themes are JSON, so sharing is just a file. Drop it into someone's `pluginConfigs/HellionChat/themes/` folder and their plugin picks it up on next reload.
A community theme repository is on the Hellion Forge roadmap. Until then: share via Discord or any pastebin.
## Reference
- `docs/example-theme.json` (seeded automatically on first launch into `pluginConfigs/HellionChat/themes/`) — minimal valid theme.
- The five built-in themes live in source under `HellionChat/Themes/Builtin/`. They are a good reference for Color choices that work.
- [Hellion Online Media branding](https://hellion-media.de) — the Arctic Cyan + Ember Glow palette that drives the default Hellion Arctic theme.
---
<p align="center"><sub>HellionChat is a privacy-focused fork of <a href="https://github.com/Infiziert90/ChatTwo">Chat 2</a>, distributed under the EUPL-1.2.<br/>Theme engine and authoring guide are part of <strong>Hellion Forge</strong>.</sub></p>
+8 -5
View File
@@ -4,21 +4,22 @@ HellionChat ships and depends on a number of third-party components.
This document lists them, their licences and which of them touch the
network. It is the inventory referenced by `PRIVACY.md`.
Last reviewed: 2026-05-03 (HellionChat v0.5.4).
Last reviewed: 2026-05-05 (HellionChat v1.1.0).
---
## Direct NuGet dependencies
Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.0.0 build.
Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.1.0 build.
| Package | Version | Licence | Network | Purpose |
| --- | --- | --- | --- | --- |
| [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | 3.1.4 | MIT | no | Binary serialisation for the SQLite message store. |
| [Microsoft.Data.Sqlite](https://learn.microsoft.com/dotnet/standard/data/sqlite/) | 10.0.7 | MIT | no | Local SQLite access for the message database. |
| [morelinq](https://github.com/morelinq/MoreLINQ) | 4.4.0 | Apache-2.0 | no | LINQ helper extensions. |
| [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.3.0 | MIT | no | Parser combinator library used for chat-input parsing. |
| [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.5.1 | MIT | no | Parser combinator library used for chat-input parsing. CIString Unicode fix relevant for non-ASCII channel/tab names. |
| [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) | 3.1.12 | [Six Labors Split License 1.0](https://github.com/SixLabors/ImageSharp/blob/main/LICENSE) (OSI-approved; free for open-source / non-commercial use, commercial licence required for closed-source commercial use) | no | Image decoding for cached emotes. |
| [SQLitePCLRaw.lib.e_sqlite3](https://github.com/ericsink/SQLitePCL.raw) | 3.50.3 | MIT | no | Native SQLite binary, explicitly pinned to override the transitive default for CVE-2025-6965 (memory corruption from aggregate-term overflow) and CVE-2025-7709. |
Six Labors note: HellionChat is an EUPL-1.2-licensed open-source
project distributed at no cost. Use of ImageSharp 3.x under the
@@ -63,8 +64,10 @@ traffic is initiated explicitly by HellionChat's own source files
and is documented in `PRIVACY.md` under "Outbound network calls":
- `HellionChat/EmoteCache.cs` → BetterTTV API + CDN (opt-out via setting)
- `HellionChat/FontManager.cs` → Square Enix Lodestone font CDN (one-time
download)
The earlier Square Enix Lodestone font download (`FontManager.cs`)
was removed in v1.0.4 — it was a leftover from upstream's removed
webinterface feature and was no longer consumed.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+9 -8
View File
File diff suppressed because one or more lines are too long