Merge branch 'feature/v1.8.8' into feature/v1.8.0
v1.8.8 Block 4b -> full theme/window restoration (last of the 1.8.x restore series). B4b export-button + schema-v2 default-fill, then P1-P8: custom-theme selection, typography font-size apply, font-selection UI, theme-card mockup, chat-colour editor, header theme/tab quick-picker, window/display toggles (title bar, hide button, 24h clock), and hide-window + Enter-to-restore. Local-only; manifest 1.8.8; all self-tests green.
This commit is contained in:
@@ -261,6 +261,9 @@ public class Configuration : IPluginConfiguration
|
||||
// v20 fields: window visibility state, channel popout pool size and
|
||||
// sidebar auto-switch threshold. All initializers double as the
|
||||
// migration defaults for configs loaded at v19 or earlier.
|
||||
// Still written on open/close, but no longer read for the start state: the
|
||||
// window always shows on login (1.5.6 parity, MainWindow ctor). Kept for the
|
||||
// migration round-trip and a possible future "remember session state" opt-in.
|
||||
public bool MainWindowOpen = true;
|
||||
public bool SettingsWindowOpen;
|
||||
public int MaxParallelPopouts = 8;
|
||||
|
||||
@@ -6,6 +6,7 @@ using Dalamud.Interface.GameFonts;
|
||||
using Dalamud.Interface.ManagedFontAtlas;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Plugin;
|
||||
using HellionChat.Themes;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
@@ -39,6 +40,12 @@ public sealed class FontManager : IDisposable
|
||||
internal IFontHandle? RegularFont;
|
||||
internal IFontHandle? ItalicFont;
|
||||
|
||||
// Wired post-build (B4b-3); a Func keeps FontManager off the theme layer.
|
||||
private Func<ThemeTypography?>? _typographySource;
|
||||
|
||||
// Lets RebuildDelegateFontsIfChanged skip rebuilds when the size is unchanged.
|
||||
private (float Global, float Symbols) _lastBuiltFingerprint;
|
||||
|
||||
// True once every required atlas-owned handle reports Available. Components
|
||||
// gate their first-frame draw on this — without it the layout math would
|
||||
// run against placeholder font metrics and snap when the real atlas
|
||||
@@ -104,6 +111,9 @@ public sealed class FontManager : IDisposable
|
||||
if (Plugin.Config.ItalicEnabled)
|
||||
ItalicFont = BuildItalicFontHandle(atlas);
|
||||
}
|
||||
|
||||
// Source is still null here, so this is the config-only baseline.
|
||||
_lastBuiltFingerprint = EffectiveFontFingerprint();
|
||||
}
|
||||
|
||||
// Called from the settings save path when one of the font-related
|
||||
@@ -125,6 +135,37 @@ public sealed class FontManager : IDisposable
|
||||
|
||||
ItalicFont?.Dispose();
|
||||
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null;
|
||||
|
||||
_lastBuiltFingerprint = EffectiveFontFingerprint();
|
||||
}
|
||||
|
||||
public void SetTypographySource(Func<ThemeTypography?> source) => _typographySource = source;
|
||||
|
||||
internal float ResolveGlobalFontPt() =>
|
||||
FontSizeResolver.ResolveGlobalPt(
|
||||
_typographySource?.Invoke(),
|
||||
Plugin.Config.UseHellionFont,
|
||||
Plugin.Config.FontSizeV2,
|
||||
Plugin.Config.GlobalFontV2.SizePt
|
||||
);
|
||||
|
||||
internal float ResolveSymbolsFontPt() =>
|
||||
FontSizeResolver.ResolveSymbolsPt(
|
||||
_typographySource?.Invoke(),
|
||||
Plugin.Config.SymbolsFontSizeV2
|
||||
);
|
||||
|
||||
internal (float Global, float Symbols) EffectiveFontFingerprint() =>
|
||||
(ResolveGlobalFontPt(), ResolveSymbolsFontPt());
|
||||
|
||||
// Rebuilds only when the effective size changed (live fingerprint, TOCTOU-free).
|
||||
// The atlas rebuild must run on the framework/draw thread — callers ensure that.
|
||||
internal void RebuildDelegateFontsIfChanged()
|
||||
{
|
||||
if (EffectiveFontFingerprint() != _lastBuiltFingerprint)
|
||||
{
|
||||
RebuildDelegateFonts();
|
||||
}
|
||||
}
|
||||
|
||||
// Instance method so Ranges / JpRange are reachable without parameter
|
||||
@@ -133,12 +174,7 @@ public sealed class FontManager : IDisposable
|
||||
atlas.NewDelegateFontHandle(e =>
|
||||
e.OnPreBuild(tk =>
|
||||
{
|
||||
// UseHellionFont swaps the source font but keeps the size
|
||||
// selector tied to FontSizeV2 (the bundled font ships as
|
||||
// a single weight).
|
||||
var basePt = Plugin.Config.UseHellionFont
|
||||
? Plugin.Config.FontSizeV2
|
||||
: Plugin.Config.GlobalFontV2.SizePt;
|
||||
var basePt = ResolveGlobalFontPt();
|
||||
var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges };
|
||||
// Missing embedded resource falls back to the configured
|
||||
// system font instead of taking the whole UiBuilder down.
|
||||
@@ -164,7 +200,7 @@ public sealed class FontManager : IDisposable
|
||||
"noto-cjk-fallback"
|
||||
);
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
config.SizePt = ResolveSymbolsFontPt();
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
@@ -201,7 +237,7 @@ public sealed class FontManager : IDisposable
|
||||
"noto-cjk-fallback"
|
||||
);
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
config.SizePt = ResolveSymbolsFontPt();
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using HellionChat.Themes;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
// Pure size resolution, split out of FontManager so it is unit-testable without
|
||||
// building the font atlas. A typography override wins; null falls back to config.
|
||||
internal static class FontSizeResolver
|
||||
{
|
||||
internal static float ResolveGlobalPt(
|
||||
ThemeTypography? typography,
|
||||
bool useHellionFont,
|
||||
float fontSizeV2,
|
||||
float globalSizePt
|
||||
) => typography?.OverrideGlobalFontSizePt ?? (useHellionFont ? fontSizeV2 : globalSizePt);
|
||||
|
||||
internal static float ResolveSymbolsPt(ThemeTypography? typography, float symbolsSizePt) =>
|
||||
typography?.OverrideSymbolsFontSizePt ?? symbolsSizePt;
|
||||
}
|
||||
@@ -501,12 +501,13 @@ internal unsafe class KeybindManager : IDisposable
|
||||
return;
|
||||
|
||||
Plugin.KeyState[currentBest.Item1] = false;
|
||||
if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info))
|
||||
if (!KeybindsToIntercept.ContainsKey(currentBest.Item2))
|
||||
return;
|
||||
|
||||
// Chat-window Activated integration is offline until the new chat
|
||||
// layer surfaces an Activated entry point.
|
||||
_ = info;
|
||||
// Re-surface the chat-activation entry point retired in v1.6.0: a chat-open
|
||||
// keybind shows + focuses the window, restoring it from a user-hide or a
|
||||
// closed state. Channel/prefill routing from the bind stays out of scope.
|
||||
Plugin.Instance.MainWindow?.ActivateChat();
|
||||
}
|
||||
|
||||
// Tab-cycle dispatch is offline until the new chat layer surfaces a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||
<PropertyGroup>
|
||||
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
|
||||
<Version>1.8.7</Version>
|
||||
<Version>1.8.8</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Use lock file to pin exact versions -->
|
||||
|
||||
@@ -16,16 +16,26 @@ namespace HellionChat.Infrastructure.Hosting;
|
||||
// at Build, which runs the service ctor (IPC subscribe etc.) right then
|
||||
// instead of lazily on first GetRequiredService.
|
||||
|
||||
internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService
|
||||
internal sealed class ThemeRegistryInitHostedService(
|
||||
ThemeRegistry registry,
|
||||
FontManager fontManager
|
||||
) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Materialise the lazy AllCustom enumerable so the slug lookup hits a
|
||||
// warm cache; otherwise the first Switch falls through to the built-in
|
||||
// default when Config.Theme points at a custom slug.
|
||||
foreach (var _ in registry.AllCustom()) { }
|
||||
registry.SwitchSilent(Plugin.Config.Theme);
|
||||
return Task.CompletedTask;
|
||||
|
||||
// B4b-3: point font sizes at the active theme's typography, wire future
|
||||
// theme switches to the atlas rebuild, and apply the boot theme's override.
|
||||
fontManager.SetTypographySource(() => registry.Active.Typography);
|
||||
registry.SetActiveChangedCallback(() => fontManager.RebuildDelegateFontsIfChanged());
|
||||
await Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
fontManager.RebuildDelegateFontsIfChanged()
|
||||
);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -382,6 +382,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
new SelfTests.SidebarModeAutoSwitchStep(this),
|
||||
new SelfTests.ColorEditorBufferStep(this),
|
||||
new SelfTests.ThemePickerCategoryStep(this),
|
||||
new SelfTests.QuickPickerSelfTestStep(this),
|
||||
new SelfTests.HideRestoreSelfTestStep(this),
|
||||
new SelfTests.SettingsWindowOpenStep(this),
|
||||
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
|
||||
new SelfTests.TypingIpcStateStep(this),
|
||||
|
||||
@@ -144,6 +144,10 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<Ui.Components.ChunkRenderer>()
|
||||
));
|
||||
services.AddSingleton(_ => new Ui.Components.SymbolPicker());
|
||||
services.AddSingleton(sp => new Ui.Components.ThemeQuickPicker(
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<Plugin>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.InputBar(
|
||||
sp.GetRequiredService<Ui.Components.SymbolPicker>(),
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
@@ -151,7 +155,9 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
|
||||
sp.GetRequiredService<ILogger<Ui.Components.InputBar>>(),
|
||||
() => sp.GetRequiredService<Plugin>().SettingsWindow.Toggle(),
|
||||
sp.GetRequiredService<Ui.CommandHelpWindow>()
|
||||
sp.GetRequiredService<Ui.CommandHelpWindow>(),
|
||||
sp.GetRequiredService<Ui.Components.ThemeQuickPicker>(),
|
||||
() => sp.GetRequiredService<Plugin>().MainWindow.UserHide()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar(
|
||||
sp.GetRequiredService<FontManager>()
|
||||
@@ -173,11 +179,21 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<ILogger<Ui.Components.Settings.ThemeImportExportRow>>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.FontsSection(
|
||||
sp.GetRequiredService<Plugin>(),
|
||||
sp.GetRequiredService<FontManager>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.ChatColourPicker(
|
||||
sp.GetRequiredService<Plugin>(),
|
||||
sp.GetRequiredService<ThemeRegistry>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab(
|
||||
sp.GetRequiredService<Ui.Components.Settings.ThemePicker>(),
|
||||
sp.GetRequiredService<Ui.Components.Settings.ColorPicker>(),
|
||||
sp.GetRequiredService<Ui.Components.Settings.LivePreviewPanel>(),
|
||||
sp.GetRequiredService<Ui.Components.Settings.ThemeImportExportRow>()
|
||||
sp.GetRequiredService<Ui.Components.Settings.ThemeImportExportRow>(),
|
||||
sp.GetRequiredService<Ui.Components.Settings.FontsSection>(),
|
||||
sp.GetRequiredService<Ui.Components.Settings.ChatColourPicker>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab(
|
||||
sp.GetRequiredService<Plugin>()
|
||||
@@ -334,7 +350,8 @@ internal static class PluginHostFactory
|
||||
// does not need one — its ctor runs the init inline inside a single
|
||||
// SuppressAutoRebuild block on eager resolve.
|
||||
services.AddHostedService(sp => new ThemeRegistryInitHostedService(
|
||||
sp.GetRequiredService<ThemeRegistry>()
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<FontManager>()
|
||||
));
|
||||
services.AddHostedService(sp => new IpcManagerInitHostedService(
|
||||
sp.GetRequiredService<IpcManager>()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
using HellionChat.Ui.Windows;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// P8 wiring: UserHide() suppresses DrawConditions; both ActivateChat() (Enter) and
|
||||
// Toggle() (/hellion) restore it. Pure window-state — the focus side is left to smoke.
|
||||
internal sealed class HideRestoreSelfTestStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
public HideRestoreSelfTestStep(Plugin plugin)
|
||||
{
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public string Name => "Hellion Chat - Hide + activate restore";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var window = _plugin.MainWindow;
|
||||
if (window is null)
|
||||
{
|
||||
ImGui.Text("Plugin.MainWindow is null");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
var savedOpen = window.IsOpen;
|
||||
var result = Evaluate(window);
|
||||
|
||||
// Never leave the window stuck hidden, even if an assertion failed.
|
||||
window.ActivateChat();
|
||||
window.IsOpen = savedOpen;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SelfTestStepResult Evaluate(MainWindow window)
|
||||
{
|
||||
window.UserHide();
|
||||
if (window.DrawConditions())
|
||||
{
|
||||
ImGui.Text("UserHide did not suppress DrawConditions");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
window.ActivateChat();
|
||||
if (!window.DrawConditions() || !window.IsOpen)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"ActivateChat failed: DrawConditions={window.DrawConditions()}, IsOpen={window.IsOpen}"
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// /hellion (Toggle) must also clear a user-hide, not just flip IsOpen.
|
||||
window.UserHide();
|
||||
window.Toggle();
|
||||
if (!window.DrawConditions())
|
||||
{
|
||||
ImGui.Text("Toggle did not restore the window from a user-hide");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -56,7 +56,16 @@ internal sealed class HonorificHeaderRenderStep : ISelfTestStep
|
||||
_snapshotted = true;
|
||||
|
||||
var valid = new HonorificTitleData("Champion", false, false, null, null, null, null, null);
|
||||
var original = new HonorificTitleData("Champion", false, true, null, null, null, null, null);
|
||||
var original = new HonorificTitleData(
|
||||
"Champion",
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Draw at a deliberately wide 420px so the title never hits the truncation
|
||||
// clamp — LastTitleRendered then reflects the GATE outcome, not the width.
|
||||
|
||||
@@ -34,7 +34,11 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep
|
||||
// value for the live config. No state mutation needed.
|
||||
var savedFlags = window.Flags;
|
||||
window.PreDraw();
|
||||
var expected = MainWindow.ResolveFlags(Plugin.Config.CanMove, Plugin.Config.CanResize);
|
||||
var expected = MainWindow.ResolveFlags(
|
||||
Plugin.Config.CanMove,
|
||||
Plugin.Config.CanResize,
|
||||
Plugin.Config.ShowTitleBar
|
||||
);
|
||||
if (window.Flags != expected)
|
||||
{
|
||||
ImGui.Text($"PreDraw set Flags {window.Flags}, expected ResolveFlags = {expected}");
|
||||
@@ -43,7 +47,7 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep
|
||||
}
|
||||
|
||||
// Fresh-base contract: locked window carries NoMove|NoResize ...
|
||||
var locked = MainWindow.ResolveFlags(false, false);
|
||||
var locked = MainWindow.ResolveFlags(false, false, true);
|
||||
if (
|
||||
!locked.HasFlag(ImGuiWindowFlags.NoMove)
|
||||
|| !locked.HasFlag(ImGuiWindowFlags.NoResize)
|
||||
@@ -51,17 +55,35 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep
|
||||
)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"ResolveFlags(false,false) = {locked}, missing NoMove/NoResize/NoScrollbar"
|
||||
$"ResolveFlags(false,false,true) = {locked}, missing NoMove/NoResize/NoScrollbar"
|
||||
);
|
||||
window.Flags = savedFlags;
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// ... and re-enabling both CLEARS NoMove|NoResize (no accumulation).
|
||||
var free = MainWindow.ResolveFlags(true, true);
|
||||
var free = MainWindow.ResolveFlags(true, true, true);
|
||||
if (free.HasFlag(ImGuiWindowFlags.NoMove) || free.HasFlag(ImGuiWindowFlags.NoResize))
|
||||
{
|
||||
ImGui.Text($"ResolveFlags(true,true) = {free}, NoMove/NoResize stuck after re-enable");
|
||||
ImGui.Text(
|
||||
$"ResolveFlags(true,true,true) = {free}, NoMove/NoResize stuck after re-enable"
|
||||
);
|
||||
window.Flags = savedFlags;
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// P7 title-bar contract: ShowTitleBar=false adds NoTitleBar from the
|
||||
// fresh base, true clears it (same no-accumulation guarantee).
|
||||
var barHidden = MainWindow.ResolveFlags(true, true, false);
|
||||
var barShown = MainWindow.ResolveFlags(true, true, true);
|
||||
if (
|
||||
!barHidden.HasFlag(ImGuiWindowFlags.NoTitleBar)
|
||||
|| barShown.HasFlag(ImGuiWindowFlags.NoTitleBar)
|
||||
)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"NoTitleBar wiring wrong: hidden={barHidden} (want NoTitleBar), shown={barShown} (want none)"
|
||||
);
|
||||
window.Flags = savedFlags;
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
using HellionChat.Resources;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// Guards the header quick-picker's data contract: its three section/tooltip
|
||||
// strings must resolve and there must be at least one theme to switch to. The
|
||||
// render path itself (FontAwesome push) can't run headless, so this checks the
|
||||
// data the popup depends on, not the draw.
|
||||
internal sealed class QuickPickerSelfTestStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
public QuickPickerSelfTestStep(Plugin plugin)
|
||||
{
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public string Name => "Hellion Chat - Theme quick-picker contract";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
if (
|
||||
string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tooltip)
|
||||
|| string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Themes_Header)
|
||||
|| string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tabs_Header)
|
||||
)
|
||||
{
|
||||
ImGui.Text("Quick-picker strings did not resolve.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
if (!_plugin.ThemeRegistry.BuiltinSlugs.Any())
|
||||
{
|
||||
ImGui.Text("No built-in themes available for the quick-picker.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using HellionChat.Themes.Builtin;
|
||||
using HellionChat.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
@@ -11,7 +13,8 @@ internal static class ThemeJsonLoader
|
||||
// policy from the v2.x style refactor: v1 user themes are not migrated,
|
||||
// they're silently ignored so the loader stays free of legacy mapping
|
||||
// code. Any other malformed input still throws FormatException.
|
||||
public static Theme? LoadFromString(string json)
|
||||
// B4b-2: callers must pass the logger or the default-fill warnings go silent.
|
||||
public static Theme? LoadFromString(string json, ILogger? logger = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new FormatException("Theme JSON is empty");
|
||||
@@ -43,8 +46,22 @@ internal static class ThemeJsonLoader
|
||||
var author = ReadString(root, "author");
|
||||
var description = ReadString(root, "description");
|
||||
|
||||
var colors = ReadColors(root.GetProperty("colors"));
|
||||
var layout = ReadLayout(root.GetProperty("layout"));
|
||||
// Missing colours/layout object stays fatal, but as FormatException so the
|
||||
// import path catches it — GetProperty's KeyNotFoundException would crash.
|
||||
if (
|
||||
!root.TryGetProperty("colors", out var colorsEl)
|
||||
|| colorsEl.ValueKind != JsonValueKind.Object
|
||||
)
|
||||
throw new FormatException("Theme JSON missing 'colors' object");
|
||||
if (
|
||||
!root.TryGetProperty("layout", out var layoutEl)
|
||||
|| layoutEl.ValueKind != JsonValueKind.Object
|
||||
)
|
||||
throw new FormatException("Theme JSON missing 'layout' object");
|
||||
|
||||
var fallback = HellionArctic.Build();
|
||||
var colors = ReadColors(colorsEl, fallback.Colors, logger);
|
||||
var layout = ReadLayout(layoutEl, fallback.Layout, logger);
|
||||
var typography = ReadTypography(root);
|
||||
|
||||
ThemeChatColors? chatColors = null;
|
||||
@@ -93,52 +110,72 @@ internal static class ThemeJsonLoader
|
||||
return new ThemeChatColors(dict);
|
||||
}
|
||||
|
||||
public static Theme? LoadFromFile(string path)
|
||||
public static Theme? LoadFromFile(string path, ILogger? logger = null)
|
||||
{
|
||||
// FileShare.Read lets concurrent readers and well-behaved editors share
|
||||
// the handle; atomic-replace editors still raise IOException, caught upstream.
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
return LoadFromString(json);
|
||||
return LoadFromString(json, logger);
|
||||
}
|
||||
|
||||
private static ThemeColors ReadColors(JsonElement el) =>
|
||||
private static ThemeColors ReadColors(JsonElement el, ThemeColors fallback, ILogger? logger) =>
|
||||
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"))
|
||||
PrimaryDark: ReadColorOrDefault(el, "primaryDark", fallback.PrimaryDark, logger),
|
||||
Primary: ReadColorOrDefault(el, "primary", fallback.Primary, logger),
|
||||
PrimaryLight: ReadColorOrDefault(el, "primaryLight", fallback.PrimaryLight, logger),
|
||||
PrimaryGlow: ReadColorOrDefault(el, "primaryGlow", fallback.PrimaryGlow, logger),
|
||||
AccentDark: ReadColorOrDefault(el, "accentDark", fallback.AccentDark, logger),
|
||||
Accent: ReadColorOrDefault(el, "accent", fallback.Accent, logger),
|
||||
AccentLight: ReadColorOrDefault(el, "accentLight", fallback.AccentLight, logger),
|
||||
Identity: ReadColorOrDefault(el, "identity", fallback.Identity, logger),
|
||||
WindowBg: ReadColorOrDefault(el, "windowBg", fallback.WindowBg, logger),
|
||||
ChildBg: ReadColorOrDefault(el, "childBg", fallback.ChildBg, logger),
|
||||
FrameBg: ReadColorOrDefault(el, "frameBg", fallback.FrameBg, logger),
|
||||
Surface: ReadColorOrDefault(el, "surface", fallback.Surface, logger),
|
||||
SurfaceHover: ReadColorOrDefault(el, "surfaceHover", fallback.SurfaceHover, logger),
|
||||
Border: ReadColorOrDefault(el, "border", fallback.Border, logger),
|
||||
TextPrimary: ReadColorOrDefault(el, "textPrimary", fallback.TextPrimary, logger),
|
||||
TextMuted: ReadColorOrDefault(el, "textMuted", fallback.TextMuted, logger),
|
||||
TextDim: ReadColorOrDefault(el, "textDim", fallback.TextDim, logger),
|
||||
StatusSuccess: ReadColorOrDefault(el, "statusSuccess", fallback.StatusSuccess, logger),
|
||||
StatusDanger: ReadColorOrDefault(el, "statusDanger", fallback.StatusDanger, logger),
|
||||
StatusWarning: ReadColorOrDefault(el, "statusWarning", fallback.StatusWarning, logger),
|
||||
StatusInfo: ReadColorOrDefault(el, "statusInfo", fallback.StatusInfo, logger)
|
||||
);
|
||||
|
||||
private static ThemeLayout ReadLayout(JsonElement el) =>
|
||||
private static ThemeLayout ReadLayout(JsonElement el, ThemeLayout fallback, ILogger? logger) =>
|
||||
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")
|
||||
WindowRounding: ReadFloatOrDefault(
|
||||
el,
|
||||
"windowRounding",
|
||||
fallback.WindowRounding,
|
||||
logger
|
||||
),
|
||||
ChildRounding: ReadFloatOrDefault(el, "childRounding", fallback.ChildRounding, logger),
|
||||
PopupRounding: ReadFloatOrDefault(el, "popupRounding", fallback.PopupRounding, logger),
|
||||
FrameRounding: ReadFloatOrDefault(el, "frameRounding", fallback.FrameRounding, logger),
|
||||
GrabRounding: ReadFloatOrDefault(el, "grabRounding", fallback.GrabRounding, logger),
|
||||
TabRounding: ReadFloatOrDefault(el, "tabRounding", fallback.TabRounding, logger),
|
||||
ScrollbarRounding: ReadFloatOrDefault(
|
||||
el,
|
||||
"scrollbarRounding",
|
||||
fallback.ScrollbarRounding,
|
||||
logger
|
||||
),
|
||||
WindowBorderSize: ReadFloatOrDefault(
|
||||
el,
|
||||
"windowBorderSize",
|
||||
fallback.WindowBorderSize,
|
||||
logger
|
||||
),
|
||||
FrameBorderSize: ReadFloatOrDefault(
|
||||
el,
|
||||
"frameBorderSize",
|
||||
fallback.FrameBorderSize,
|
||||
logger
|
||||
)
|
||||
);
|
||||
|
||||
// Optional in v2 — themes without a typography block default to the
|
||||
@@ -186,4 +223,54 @@ internal static class ThemeJsonLoader
|
||||
throw new FormatException($"Theme JSON property '{name}' must be a number or null");
|
||||
return (float)v.GetDouble();
|
||||
}
|
||||
|
||||
// Missing / wrong-typed / unparseable colour slot -> built-in default + one warning.
|
||||
private static uint ReadColorOrDefault(
|
||||
JsonElement el,
|
||||
string name,
|
||||
uint fallback,
|
||||
ILogger? logger
|
||||
)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Theme JSON colour slot '{Slot}' missing or not a string, using built-in default",
|
||||
name
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ColourUtil.HexToRgba(v.GetString()!);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Theme JSON colour slot '{Slot}' has an invalid hex value, using built-in default",
|
||||
name
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static float ReadFloatOrDefault(
|
||||
JsonElement el,
|
||||
string name,
|
||||
float fallback,
|
||||
ILogger? logger
|
||||
)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Theme JSON layout slot '{Slot}' missing or not a number, using built-in default",
|
||||
name
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (float)v.GetDouble();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ public sealed class ThemeRegistry
|
||||
public Theme? EditingThemeBuffer => _editingThemeBuffer;
|
||||
public event Action? OnEditingBufferChanged;
|
||||
|
||||
// Fired after _active changes (Switch / RefreshActiveIfStale); the init host
|
||||
// wires it to the font-atlas rebuild. NOT fired by SwitchSilent (boot handles that).
|
||||
private Action? _onActiveChanged;
|
||||
|
||||
internal void SetActiveChangedCallback(Action callback) => _onActiveChanged = callback;
|
||||
|
||||
// Shared slug guard for any code path that turns a slug into a filename.
|
||||
// Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the
|
||||
// path-traversal/invalid-char rules live in exactly one place.
|
||||
@@ -190,27 +196,32 @@ public sealed class ThemeRegistry
|
||||
_active = builtin;
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var customTheme = LoadCustomBySlug(slug, out var customPath);
|
||||
if (customTheme is not null)
|
||||
else
|
||||
{
|
||||
_active = customTheme;
|
||||
// Defensive — ensures any future theme source always gets a populated cache.
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = customPath;
|
||||
// Force a first-tick reload-check after the switch so the stamp
|
||||
// baseline is established on the next RefreshActiveIfStale call.
|
||||
_lastActiveStamp = DateTime.MinValue;
|
||||
return;
|
||||
var customTheme = LoadCustomBySlug(slug, out var customPath);
|
||||
if (customTheme is not null)
|
||||
{
|
||||
_active = customTheme;
|
||||
// Defensive — ensures any future theme source always gets a populated cache.
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = customPath;
|
||||
// Force a first-tick reload-check after the switch so the stamp
|
||||
// baseline is established on the next RefreshActiveIfStale call.
|
||||
_lastActiveStamp = DateTime.MinValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: neither built-in nor custom matched. Drop to default
|
||||
// and clear the active custom path so RefreshActiveIfStale stays idle.
|
||||
_active = _builtIns[DefaultSlug];
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: neither built-in nor custom matched. Drop to default
|
||||
// and clear the active custom path so RefreshActiveIfStale stays idle.
|
||||
_active = _builtIns[DefaultSlug];
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = null;
|
||||
// Notify listeners (the init host wires the font-atlas rebuild here).
|
||||
_onActiveChanged?.Invoke();
|
||||
}
|
||||
|
||||
// SwitchSilent is the plugin-load init path -- identical to Switch
|
||||
@@ -426,6 +437,9 @@ public sealed class ThemeRegistry
|
||||
{
|
||||
reloaded.RecomputeAbgrCache();
|
||||
_active = reloaded;
|
||||
// Same-slug save bypasses Switch's notify (it noop'd on same slug);
|
||||
// fire here so a typography change applies (no-op if size unchanged).
|
||||
_onActiveChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +582,7 @@ public sealed class ThemeRegistry
|
||||
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
|
||||
var reloaded = Get(_active.Slug);
|
||||
_active = reloaded;
|
||||
_onActiveChanged?.Invoke();
|
||||
}
|
||||
|
||||
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
|
||||
@@ -626,7 +641,7 @@ public sealed class ThemeRegistry
|
||||
{
|
||||
try
|
||||
{
|
||||
theme = ThemeJsonLoader.LoadFromFile(path);
|
||||
theme = ThemeJsonLoader.LoadFromFile(path, _logger);
|
||||
// null = hard-cut policy skipped a legacy v1 file. Leave
|
||||
// theme null so the yield-guard below drops the entry.
|
||||
if (theme is not null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Optional per-theme; reserved as an extension point for future theme slots.
|
||||
// Italic body-size override intentionally omitted (v1.9.0 Typography-Polish).
|
||||
public sealed record ThemeTypography(
|
||||
float? OverrideGlobalFontSizePt = null,
|
||||
float? OverrideSymbolsFontSizePt = null
|
||||
|
||||
@@ -40,6 +40,14 @@ internal sealed class InputBar
|
||||
private readonly Action _onOpenSettings;
|
||||
private readonly CommandHelpWindow _commandHelpWindow;
|
||||
|
||||
// Null in pop-out windows: the theme/tab quick-picker only belongs in the
|
||||
// main window (1.5.4 had no pop-outs, and a tab jump from a channel-bound
|
||||
// pop-out would be confusing). The main window's InputBar gets the instance.
|
||||
private readonly ThemeQuickPicker? _themeQuickPicker;
|
||||
|
||||
// Null in pop-outs (those have their own close button). Hides the main window.
|
||||
private readonly Action? _onHideWindow;
|
||||
|
||||
private string _pendingMessage = string.Empty;
|
||||
private bool _isFocused;
|
||||
private bool _wasInputTextHovered;
|
||||
@@ -75,7 +83,9 @@ internal sealed class InputBar
|
||||
TokenResolver resolver,
|
||||
ILogger<InputBar> logger,
|
||||
Action onOpenSettings,
|
||||
CommandHelpWindow commandHelpWindow
|
||||
CommandHelpWindow commandHelpWindow,
|
||||
ThemeQuickPicker? themeQuickPicker = null,
|
||||
Action? onHideWindow = null
|
||||
)
|
||||
{
|
||||
_symbolPicker = symbolPicker;
|
||||
@@ -85,6 +95,8 @@ internal sealed class InputBar
|
||||
_logger = logger;
|
||||
_onOpenSettings = onOpenSettings;
|
||||
_commandHelpWindow = commandHelpWindow;
|
||||
_themeQuickPicker = themeQuickPicker;
|
||||
_onHideWindow = onHideWindow;
|
||||
}
|
||||
|
||||
public string PendingMessage => _pendingMessage;
|
||||
@@ -188,6 +200,9 @@ internal sealed class InputBar
|
||||
if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity)
|
||||
_pendingMessage += inserted;
|
||||
|
||||
// Theme/tab quick-picker popup (main window only; null in pop-outs).
|
||||
_themeQuickPicker?.Draw();
|
||||
|
||||
// Auto-translate popup runs after all other popups so the OpenPopup
|
||||
// anchor lands on the InputText item we just drew.
|
||||
DrawAutoCompletePopup();
|
||||
@@ -507,6 +522,18 @@ internal sealed class InputBar
|
||||
ImGui.SetTooltip("Insert symbol");
|
||||
}
|
||||
|
||||
if (_themeQuickPicker is not null)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString()))
|
||||
_themeQuickPicker.OpenPopup();
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
using (ImRaii.DefaultFont())
|
||||
ImGui.SetTooltip(HellionStrings.Settings_QuickPicker_Tooltip);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString()))
|
||||
{
|
||||
@@ -518,15 +545,18 @@ internal sealed class InputBar
|
||||
ImGui.SetTooltip("Settings");
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
var hidden = Plugin.Config.HideChat;
|
||||
var visIcon = hidden ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye;
|
||||
if (ImGui.Button(visIcon.ToIconString()))
|
||||
Plugin.Config.HideChat = !hidden;
|
||||
if (ImGui.IsItemHovered())
|
||||
// Hides the window (1.5.6 UserHide). One-way — Enter brings it back.
|
||||
// Main window only (pop-outs have their own close); last in the row.
|
||||
if (Plugin.Config.ShowHideButton && _onHideWindow is not null)
|
||||
{
|
||||
using (ImRaii.DefaultFont())
|
||||
ImGui.SetTooltip(hidden ? "Unhide chat" : "Hide chat");
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString()))
|
||||
_onHideWindow();
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
using (ImRaii.DefaultFont())
|
||||
ImGui.SetTooltip("Hide chat (Enter to bring back)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Numerics;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Themes;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Ui.Components.Settings;
|
||||
|
||||
// Restores the 1.5.6 chat-channel colour editor (1d3b429:Appearance.cs:189-243,
|
||||
// 409-534): presets, the per-ChatType ColorEdit3 over Config.ChatColours (which
|
||||
// ChunkRenderer consumes), reset/import-game-colour buttons, and the apply-banner
|
||||
// that adopts the active theme's chatChannels. v1.6.0 saves live + refreshes cache.
|
||||
internal sealed class ChatColourPicker
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
private readonly ThemeRegistry _themes;
|
||||
private string? _applyDismissedFor;
|
||||
private string? _lastSeenSlug;
|
||||
|
||||
public ChatColourPicker(Plugin plugin, ThemeRegistry themes)
|
||||
{
|
||||
_plugin = plugin;
|
||||
_themes = themes;
|
||||
}
|
||||
|
||||
// Drawn directly under the theme picker (1.5.6 placement) so the adopt prompt
|
||||
// sits next to the theme that triggered it, not at the bottom of the tab.
|
||||
public void DrawThemeAdoptBanner() => DrawApplyBanner(_themes.Active);
|
||||
|
||||
public void Draw()
|
||||
{
|
||||
if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Colours))
|
||||
return;
|
||||
|
||||
DrawPresetButtons();
|
||||
ImGui.TextDisabled(HellionStrings.Settings_Appearance_Colours_PresetsHint);
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
if (
|
||||
ImGui.Checkbox(
|
||||
Language.Options_ColorSelectedInputChannelButton_Name,
|
||||
ref Plugin.Config.ColorSelectedInputChannelButton
|
||||
)
|
||||
)
|
||||
{
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
ImGuiUtil.HelpMarker(Language.Options_ColorSelectedInputChannelButton_Description);
|
||||
ImGui.Spacing();
|
||||
|
||||
// Discrete clicks (reset/import) persist at once. The ColorEdit3 drag only
|
||||
// recolours live (Refresh, no disk write) and defers SaveConfig to release
|
||||
// via IsItemDeactivatedAfterEdit, so dragging the colour wheel doesn't fire
|
||||
// a full-config disk write every frame.
|
||||
var commit = false;
|
||||
var liveOnly = false;
|
||||
foreach (var (_, types) in ChatTypeExt.SortOrder)
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (
|
||||
ImGuiUtil.IconButton(
|
||||
FontAwesomeIcon.UndoAlt,
|
||||
$"{type}",
|
||||
Language.Options_ChatColours_Reset
|
||||
)
|
||||
)
|
||||
{
|
||||
Plugin.Config.ChatColours.Remove(type);
|
||||
commit = true;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (
|
||||
ImGuiUtil.IconButton(
|
||||
FontAwesomeIcon.LongArrowAltDown,
|
||||
$"{type}",
|
||||
Language.Options_ChatColours_Import
|
||||
)
|
||||
)
|
||||
{
|
||||
var gameColour = _plugin.Functions.Chat.GetChannelColor(type);
|
||||
Plugin.Config.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0;
|
||||
commit = true;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
var vec = Plugin.Config.ChatColours.TryGetValue(type, out var colour)
|
||||
? ColourUtil.RgbaToVector3(colour)
|
||||
: ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0);
|
||||
if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs))
|
||||
{
|
||||
Plugin.Config.ChatColours[type] = ColourUtil.Vector3ToRgba(vec);
|
||||
liveOnly = true;
|
||||
}
|
||||
if (ImGui.IsItemDeactivatedAfterEdit())
|
||||
commit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (commit)
|
||||
ApplyChatColourChange();
|
||||
else if (liveOnly)
|
||||
GlobalParametersCache.Refresh();
|
||||
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
private void DrawPresetButtons()
|
||||
{
|
||||
var first = true;
|
||||
foreach (var (_, preset) in ChatColourPresets.All)
|
||||
{
|
||||
if (!first)
|
||||
ImGui.SameLine();
|
||||
first = false;
|
||||
|
||||
var brand = preset.IsBrandPreset;
|
||||
if (brand)
|
||||
{
|
||||
var border = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(255, 128, 200));
|
||||
var btn = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(74, 42, 106));
|
||||
ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(border, 1f));
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(btn, 1f));
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.5f);
|
||||
}
|
||||
|
||||
if (ImGui.Button(GetPresetLabel(preset)))
|
||||
ApplyPreset(preset);
|
||||
|
||||
if (brand)
|
||||
{
|
||||
ImGui.PopStyleVar();
|
||||
ImGui.PopStyleColor(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPresetLabel(ChatColourPreset preset)
|
||||
{
|
||||
var localized = HellionStrings.ResourceManager.GetString(
|
||||
preset.LocalizationKey,
|
||||
HellionStrings.Culture
|
||||
);
|
||||
return string.IsNullOrEmpty(localized) ? preset.DisplayName : localized;
|
||||
}
|
||||
|
||||
private void ApplyPreset(ChatColourPreset preset)
|
||||
{
|
||||
foreach (var (channel, colour) in preset.Colours)
|
||||
Plugin.Config.ChatColours[channel] = colour;
|
||||
ApplyChatColourChange();
|
||||
}
|
||||
|
||||
private void ApplyChatColourChange()
|
||||
{
|
||||
_plugin.SaveConfig();
|
||||
GlobalParametersCache.Refresh();
|
||||
}
|
||||
|
||||
// Offers to adopt the active theme's chatChannels into Config.ChatColours when
|
||||
// they differ; dismissable per theme slug (matches 1.5.6 banner behaviour).
|
||||
private void DrawApplyBanner(Theme active)
|
||||
{
|
||||
// Clear the per-theme dismiss whenever the active theme changes, so leaving
|
||||
// a theme and returning re-offers the prompt (1.5.6 reset this on every switch).
|
||||
if (active.Slug != _lastSeenSlug)
|
||||
{
|
||||
_applyDismissedFor = null;
|
||||
_lastSeenSlug = active.Slug;
|
||||
}
|
||||
|
||||
if (active.ChatColors is not { Channels.Count: > 0 } themeChatColors)
|
||||
return;
|
||||
if (_applyDismissedFor == active.Slug)
|
||||
return;
|
||||
|
||||
var alreadyMatching = themeChatColors.Channels.All(kvp =>
|
||||
Plugin.Config.ChatColours.TryGetValue(kvp.Key, out var current) && current == kvp.Value
|
||||
);
|
||||
if (alreadyMatching)
|
||||
return;
|
||||
|
||||
ImGui.Spacing();
|
||||
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;
|
||||
const float 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);
|
||||
draw.AddText(
|
||||
origin + new Vector2(12f, 10f),
|
||||
ColourUtil.RgbaToAbgr(active.Colors.TextPrimary),
|
||||
HellionStrings.Settings_Themes_ApplyChatColors_Hint
|
||||
);
|
||||
|
||||
using (
|
||||
ImRaii.PushColor(
|
||||
ImGuiCol.Button,
|
||||
new Vector4(ColourUtil.RgbaToVector3(active.Colors.Primary), 1f)
|
||||
)
|
||||
)
|
||||
using (
|
||||
ImRaii.PushColor(
|
||||
ImGuiCol.ButtonHovered,
|
||||
new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryLight), 1f)
|
||||
)
|
||||
)
|
||||
using (
|
||||
ImRaii.PushColor(
|
||||
ImGuiCol.ButtonActive,
|
||||
new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryDark), 1f)
|
||||
)
|
||||
)
|
||||
{
|
||||
ImGui.SetCursorScreenPos(origin + new Vector2(12f, 32f));
|
||||
if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Apply))
|
||||
{
|
||||
foreach (var kvp in themeChatColors.Channels)
|
||||
Plugin.Config.ChatColours[kvp.Key] = kvp.Value;
|
||||
_applyDismissedFor = active.Slug;
|
||||
ApplyChatColourChange();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Keep))
|
||||
_applyDismissedFor = active.Slug;
|
||||
|
||||
ImGui.SetCursorScreenPos(origin + new Vector2(0f, height + 8f));
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using Dalamud;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.FontIdentifier;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Ui.Components.Settings;
|
||||
|
||||
// Restores the 1.5.6 font-selection UI (1d3b429:Appearance.cs:249-405): pick the
|
||||
// bundled Hellion font vs a custom global/Japanese/italic font, sizes, and extra
|
||||
// glyph ranges. v1.6.0 saves live, so any change persists and rebuilds the atlas
|
||||
// at once (RebuildDelegateFonts, unconditional — a face change keeps the size, so
|
||||
// the size-gated IfChanged path would miss it).
|
||||
internal sealed class FontsSection
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
private readonly FontManager _fontManager;
|
||||
|
||||
public FontsSection(Plugin plugin, FontManager fontManager)
|
||||
{
|
||||
_plugin = plugin;
|
||||
_fontManager = fontManager;
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
_plugin.SaveConfig();
|
||||
_fontManager.RebuildDelegateFonts();
|
||||
}
|
||||
|
||||
public void Draw()
|
||||
{
|
||||
if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Fonts))
|
||||
return;
|
||||
|
||||
// Readout so the user can see which font is actually active.
|
||||
var active =
|
||||
Plugin.Config.UseHellionFont ? "Hellion Inter (bundled)"
|
||||
: Plugin.Config.FontsEnabled
|
||||
? $"Global: {Plugin.Config.GlobalFontV2.FontId.Family.EnglishName}"
|
||||
: "FFXIV game font";
|
||||
ImGui.TextDisabled($"Active: {active}");
|
||||
ImGui.Spacing();
|
||||
|
||||
if (
|
||||
ImGui.Checkbox(
|
||||
HellionStrings.Theme_UseHellionFont_Name,
|
||||
ref Plugin.Config.UseHellionFont
|
||||
)
|
||||
)
|
||||
{
|
||||
if (Plugin.Config.UseHellionFont)
|
||||
Plugin.Config.FontsEnabled = false;
|
||||
Apply();
|
||||
}
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description);
|
||||
ImGui.Spacing();
|
||||
|
||||
if (Plugin.Config.UseHellionFont)
|
||||
{
|
||||
DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2);
|
||||
ImGui.Spacing();
|
||||
}
|
||||
else if (ImGui.Checkbox(Language.Options_FontsEnabled, ref Plugin.Config.FontsEnabled))
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
|
||||
var unused = false;
|
||||
if (!Plugin.Config.UseHellionFont && !Plugin.Config.FontsEnabled)
|
||||
{
|
||||
DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2);
|
||||
}
|
||||
else if (!Plugin.Config.UseHellionFont)
|
||||
{
|
||||
DrawFontChooser(
|
||||
Language.Options_Font_Name,
|
||||
Plugin.Config.GlobalFontV2,
|
||||
false,
|
||||
ref unused,
|
||||
spec => Plugin.Config.GlobalFontV2 = spec,
|
||||
() => Plugin.Config.GlobalFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular),
|
||||
"global"
|
||||
);
|
||||
ImGuiUtil.HelpMarker(
|
||||
string.Format(Language.Options_Font_Description, Plugin.PluginName)
|
||||
);
|
||||
ImGuiUtil.WarningText(Language.Options_Font_Warning);
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawFontChooser(
|
||||
Language.Options_JapaneseFont_Name,
|
||||
Plugin.Config.JapaneseFontV2,
|
||||
false,
|
||||
ref unused,
|
||||
spec => Plugin.Config.JapaneseFontV2 = spec,
|
||||
() => Plugin.Config.JapaneseFontV2 = DefaultFont(DalamudAsset.NotoSansCjkMedium),
|
||||
"japanese",
|
||||
id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false,
|
||||
"いろはにほへと ちりぬるを"
|
||||
);
|
||||
ImGuiUtil.HelpMarker(
|
||||
string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName)
|
||||
);
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawFontChooser(
|
||||
Language.Options_ItalicFont_Name,
|
||||
Plugin.Config.ItalicFontV2,
|
||||
true,
|
||||
ref Plugin.Config.ItalicEnabled,
|
||||
spec => Plugin.Config.ItalicFontV2 = spec,
|
||||
() =>
|
||||
{
|
||||
Plugin.Config.ItalicEnabled = false;
|
||||
Plugin.Config.ItalicFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular);
|
||||
},
|
||||
"italic"
|
||||
);
|
||||
ImGuiUtil.HelpMarker(
|
||||
string.Format(Language.Options_Italic_Description, Plugin.PluginName)
|
||||
);
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
// ExtraGlyphRanges stays reachable regardless of the font source so the
|
||||
// user can verify/override the per-language auto-activation (v1.5.3 note).
|
||||
ImGui.Spacing();
|
||||
if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name))
|
||||
{
|
||||
ImGuiUtil.HelpMarker(
|
||||
string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName)
|
||||
);
|
||||
|
||||
var range = (int)Plugin.Config.ExtraGlyphRanges;
|
||||
var changed = false;
|
||||
foreach (var extra in Enum.GetValues<ExtraGlyphRanges>())
|
||||
changed |= ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra);
|
||||
|
||||
if (changed)
|
||||
{
|
||||
Plugin.Config.ExtraGlyphRanges = (ExtraGlyphRanges)range;
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
DrawSizeCombo(Language.Options_SymbolsFontSize_Name, ref Plugin.Config.SymbolsFontSizeV2);
|
||||
ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description);
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
private void DrawSizeCombo(string label, ref float size)
|
||||
{
|
||||
var before = size;
|
||||
ImGuiUtil.FontSizeCombo(label, ref size);
|
||||
if (!size.Equals(before))
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void DrawFontChooser(
|
||||
string label,
|
||||
SingleFontSpec font,
|
||||
bool checkbox,
|
||||
ref bool checkboxValue,
|
||||
Action<SingleFontSpec> set,
|
||||
Action reset,
|
||||
string resetId,
|
||||
Predicate<IFontFamilyId>? exclusion = null,
|
||||
string? preview = null
|
||||
)
|
||||
{
|
||||
var prevCheckbox = checkboxValue;
|
||||
var chooser = ImGuiUtil.FontChooser(
|
||||
label,
|
||||
font,
|
||||
checkbox,
|
||||
ref checkboxValue,
|
||||
exclusion,
|
||||
preview
|
||||
);
|
||||
if (checkbox && checkboxValue != prevCheckbox)
|
||||
Apply();
|
||||
|
||||
// The chooser dialog resolves on a worker thread; marshal the result back
|
||||
// onto the framework thread before touching config + the font atlas.
|
||||
chooser?.ResultTask.ContinueWith(r =>
|
||||
{
|
||||
if (r.IsCompletedSuccessfully)
|
||||
Plugin.Framework.Run(() =>
|
||||
{
|
||||
set(r.Result);
|
||||
Apply();
|
||||
});
|
||||
});
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button($"Reset##{resetId}"))
|
||||
{
|
||||
reset();
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
private static SingleFontSpec DefaultFont(DalamudAsset asset) =>
|
||||
new() { FontId = new DalamudAssetFontAndFamilyId(asset), SizePt = 12.75f };
|
||||
}
|
||||
@@ -11,18 +11,24 @@ internal sealed class AppearanceTab
|
||||
private readonly ColorPicker _color;
|
||||
private readonly LivePreviewPanel _preview;
|
||||
private readonly ThemeImportExportRow _importExport;
|
||||
private readonly FontsSection _fonts;
|
||||
private readonly ChatColourPicker _chatColours;
|
||||
|
||||
public AppearanceTab(
|
||||
ThemePicker picker,
|
||||
ColorPicker color,
|
||||
LivePreviewPanel preview,
|
||||
ThemeImportExportRow importExport
|
||||
ThemeImportExportRow importExport,
|
||||
FontsSection fonts,
|
||||
ChatColourPicker chatColours
|
||||
)
|
||||
{
|
||||
_picker = picker;
|
||||
_color = color;
|
||||
_preview = preview;
|
||||
_importExport = importExport;
|
||||
_fonts = fonts;
|
||||
_chatColours = chatColours;
|
||||
}
|
||||
|
||||
public void Draw()
|
||||
@@ -35,10 +41,15 @@ internal sealed class AppearanceTab
|
||||
if (left.Success)
|
||||
{
|
||||
_picker.Draw();
|
||||
_chatColours.DrawThemeAdoptBanner();
|
||||
ImGui.Spacing();
|
||||
_importExport.Draw();
|
||||
ImGui.Separator();
|
||||
_fonts.Draw();
|
||||
ImGui.Separator();
|
||||
_color.Draw();
|
||||
ImGui.Separator();
|
||||
_chatColours.Draw();
|
||||
}
|
||||
}
|
||||
ImGui.SameLine();
|
||||
|
||||
@@ -38,6 +38,11 @@ internal sealed class ChatTab
|
||||
() => Plugin.Config.HideSameTimestamps,
|
||||
v => Plugin.Config.HideSameTimestamps = v
|
||||
);
|
||||
DrawToggle(
|
||||
"24-hour clock",
|
||||
() => Plugin.Config.Use24HourClock,
|
||||
v => Plugin.Config.Use24HourClock = v
|
||||
);
|
||||
DrawWorldSuffixCombo();
|
||||
DrawNameFormCombo();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,25 @@ internal sealed class WindowTab
|
||||
}
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
DrawToggle(
|
||||
"Show title bar",
|
||||
() => Plugin.Config.ShowTitleBar,
|
||||
v => Plugin.Config.ShowTitleBar = v
|
||||
);
|
||||
DrawToggle(
|
||||
"Show title bar for pop-outs",
|
||||
() => Plugin.Config.ShowPopOutTitleBar,
|
||||
v => Plugin.Config.ShowPopOutTitleBar = v
|
||||
);
|
||||
DrawToggle(
|
||||
"Show hide button",
|
||||
() => Plugin.Config.ShowHideButton,
|
||||
v => Plugin.Config.ShowHideButton = v
|
||||
);
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
DrawSlider(
|
||||
|
||||
@@ -37,6 +37,12 @@ internal sealed class ThemeImportExportRow
|
||||
OpenThemesFolder();
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Export active theme…"))
|
||||
{
|
||||
ExportActive();
|
||||
}
|
||||
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
ImGui.InputTextWithHint(
|
||||
"##theme-import-path",
|
||||
@@ -144,7 +150,7 @@ internal sealed class ThemeImportExportRow
|
||||
Theme? theme;
|
||||
try
|
||||
{
|
||||
theme = ThemeJsonLoader.LoadFromString(json);
|
||||
theme = ThemeJsonLoader.LoadFromString(json, _logger);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
@@ -293,4 +299,43 @@ internal sealed class ThemeImportExportRow
|
||||
_logger.LogWarning(ex, "Could not open themes folder {Dir}", dir);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExportActive()
|
||||
{
|
||||
// Capture the active theme now, not in the async dialog callback — the user
|
||||
// could switch themes while the dialog is open.
|
||||
var theme = _themes.Active;
|
||||
var defaultName = $"{theme.Slug}.json";
|
||||
|
||||
Plugin.FileDialogManager.SaveFileDialog(
|
||||
"Export theme",
|
||||
".json",
|
||||
defaultName,
|
||||
".json",
|
||||
(ok, path) =>
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
ExportTo(theme, path);
|
||||
}
|
||||
},
|
||||
null,
|
||||
isModal: true
|
||||
);
|
||||
}
|
||||
|
||||
private void ExportTo(Theme theme, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = ThemeJsonWriter.Serialize(theme);
|
||||
File.WriteAllText(path, json);
|
||||
_logger.LogInformation("Exported theme {Slug} to {Path}", theme.Slug, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex is IOException or UnauthorizedAccessException or SecurityException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Theme export to {Path} failed", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Numerics;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using HellionChat.Themes;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Ui.Components.Settings;
|
||||
|
||||
// Mini chat-window mockup drawn straight into the WindowDrawList (restored from
|
||||
// 1.5.6 ThemeMockup). No textures, no per-frame allocations — pure rect/text.
|
||||
internal static class ThemeMockup
|
||||
{
|
||||
public static void Draw(Vector2 origin, Vector2 size, Theme theme)
|
||||
{
|
||||
var draw = ImGui.GetWindowDrawList();
|
||||
var c = theme.Colors;
|
||||
|
||||
draw.AddRectFilled(
|
||||
origin,
|
||||
origin + size,
|
||||
ColourUtil.RgbaToAbgr(c.WindowBg | 0xFFu),
|
||||
theme.Layout.WindowRounding
|
||||
);
|
||||
|
||||
var titleHeight = 14f;
|
||||
draw.AddRectFilled(
|
||||
origin,
|
||||
new Vector2(origin.X + size.X, origin.Y + titleHeight),
|
||||
ColourUtil.RgbaToAbgr(c.Identity),
|
||||
theme.Layout.WindowRounding
|
||||
);
|
||||
|
||||
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)
|
||||
{
|
||||
draw.AddRectFilled(
|
||||
new Vector2(tabX, tabY + tabHeight - 2f),
|
||||
new Vector2(tabX + 26f, tabY + tabHeight),
|
||||
ColourUtil.RgbaToAbgr(c.Primary)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
draw.AddRect(
|
||||
origin,
|
||||
origin + size,
|
||||
ColourUtil.RgbaToAbgr(c.Border),
|
||||
theme.Layout.WindowRounding
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ internal sealed class ThemePicker
|
||||
// to enforce coverage. Kept on the static map so the test does not pierce instance state.
|
||||
internal static IEnumerable<string> CategoryMapSlugs => CategoryMap.SelectMany(c => c.Slugs);
|
||||
|
||||
private const float CardHeight = 132f;
|
||||
|
||||
private readonly ThemeRegistry _themes;
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
@@ -51,10 +53,23 @@ internal sealed class ThemePicker
|
||||
: ImGuiTreeNodeFlags.None;
|
||||
if (ImGui.CollapsingHeader(category, flags))
|
||||
{
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
DrawCard(slug);
|
||||
}
|
||||
DrawThemeGrid(Resolve(slugs));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore (1.5.6 Appearance.cs:79-88): list custom themes so forked/
|
||||
// imported themes are selectable, not just built-ins.
|
||||
var customs = _themes.AllCustom().ToList();
|
||||
if (customs.Count > 0)
|
||||
{
|
||||
if (
|
||||
ImGui.CollapsingHeader(
|
||||
$"Custom ({customs.Count})",
|
||||
ImGuiTreeNodeFlags.DefaultOpen
|
||||
)
|
||||
)
|
||||
{
|
||||
DrawThemeGrid(customs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,61 +80,97 @@ internal sealed class ThemePicker
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCard(string slug)
|
||||
private IEnumerable<Theme> Resolve(IEnumerable<string> slugs)
|
||||
{
|
||||
if (!_themes.TryGet(slug, out var theme))
|
||||
{
|
||||
foreach (var slug in slugs)
|
||||
if (_themes.TryGet(slug, out var theme))
|
||||
yield return theme;
|
||||
}
|
||||
|
||||
// Grid of theme cards, each carrying a mini chat mockup (restored from 1.5.6
|
||||
// DrawThemeGrid + ThemeMockup). Column count adapts to the available width.
|
||||
private void DrawThemeGrid(IEnumerable<Theme> themes)
|
||||
{
|
||||
var list = themes.ToList();
|
||||
if (list.Count == 0)
|
||||
return;
|
||||
|
||||
var avail = ImGui.GetContentRegionAvail().X;
|
||||
var columns = avail >= 460f ? 2 : 1;
|
||||
var cardWidth = columns > 1 ? (avail - (columns - 1) * 8f) / columns : avail;
|
||||
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
DrawThemeCard(list[i], cardWidth, CardHeight);
|
||||
if ((i + 1) % columns != 0 && i != list.Count - 1)
|
||||
ImGui.SameLine();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawThemeCard(Theme theme, float w, float h)
|
||||
{
|
||||
ImGui.BeginGroup();
|
||||
|
||||
var isActive = string.Equals(
|
||||
theme.Slug,
|
||||
_themes.Active.Slug,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
);
|
||||
var origin = ImGui.GetCursorScreenPos();
|
||||
var clicked = ImGui.InvisibleButton($"##theme-card-{theme.Slug}", new Vector2(w, h));
|
||||
var hovered = ImGui.IsItemHovered();
|
||||
|
||||
var draw = ImGui.GetWindowDrawList();
|
||||
draw.AddRectFilled(
|
||||
origin,
|
||||
origin + new Vector2(w, h),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.WindowBg | 0xFFu),
|
||||
4f
|
||||
);
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
draw.AddRect(
|
||||
origin,
|
||||
origin + new Vector2(w, h),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.Primary),
|
||||
4f,
|
||||
ImDrawFlags.None,
|
||||
2f
|
||||
);
|
||||
}
|
||||
else if (hovered)
|
||||
{
|
||||
draw.AddRect(
|
||||
origin,
|
||||
origin + new Vector2(w, h),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight & 0xFFFFFF99u),
|
||||
4f,
|
||||
ImDrawFlags.None,
|
||||
1f
|
||||
);
|
||||
}
|
||||
|
||||
var active = _themes.Active.Slug == slug;
|
||||
var label = $"{theme.Name} — {theme.Author}##theme-card-{slug}";
|
||||
ThemeMockup.Draw(origin + new Vector2(12f, 12f), new Vector2(w - 24f, 60f), theme);
|
||||
|
||||
// Selectable uses ImGui's default Header colour, not the theme's Surface.
|
||||
// Swatch overlay below carries the theme cue; v1.7.x-polish if testers flag the mismatch.
|
||||
if (ImGui.Selectable(label, active, ImGuiSelectableFlags.None, new Vector2(0, 40)))
|
||||
draw.AddText(
|
||||
origin + new Vector2(12f, 80f),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary),
|
||||
theme.Name
|
||||
);
|
||||
draw.AddText(
|
||||
origin + new Vector2(12f, 100f),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.TextMuted),
|
||||
theme.Author
|
||||
);
|
||||
|
||||
ImGui.EndGroup();
|
||||
|
||||
if (clicked)
|
||||
{
|
||||
_themes.Switch(slug);
|
||||
Plugin.Config.Theme = slug;
|
||||
_themes.Switch(theme.Slug);
|
||||
Plugin.Config.Theme = theme.Slug;
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
|
||||
// Mini-Preview-Swatch overlay (3 ABGR boxes on the right side of the card).
|
||||
// Selectable owns the hit-box; the DrawList overlay is decorative — full card area
|
||||
// remains the click target, not just the swatch.
|
||||
//
|
||||
// RGBA-vs-ABGR-Disziplin: ThemeColors slots hold uint values in 0xRRGGBBAA layout
|
||||
// (see ThemeColors.cs header comment). ImGui draw calls expect ABGR (native byte order)
|
||||
// — pass theme colours through ColourUtil.RgbaToAbgr before any AddRectFilled / AddText
|
||||
// / AddLine. The repo-wide pattern is "swap at the boundary" (see InputBar swap-at-
|
||||
// boundary pattern). Forgetting the swap renders Red and Blue channels swapped and
|
||||
// shifts the alpha byte into the green slot.
|
||||
var draw = ImGui.GetWindowDrawList();
|
||||
var max = ImGui.GetItemRectMax();
|
||||
var min = ImGui.GetItemRectMin();
|
||||
var swatchY = min.Y + 12;
|
||||
DrawSwatch(
|
||||
draw,
|
||||
new Vector2(max.X - 60, swatchY),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.Surface)
|
||||
);
|
||||
DrawSwatch(
|
||||
draw,
|
||||
new Vector2(max.X - 42, swatchY),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.Primary)
|
||||
);
|
||||
DrawSwatch(
|
||||
draw,
|
||||
new Vector2(max.X - 24, swatchY),
|
||||
ColourUtil.RgbaToAbgr(theme.Colors.Accent)
|
||||
);
|
||||
}
|
||||
|
||||
// Caller contract: `colorAbgr` is already byte-swapped from the ThemeColors RGBA backing
|
||||
// field via ColourUtil.RgbaToAbgr. Passing a raw RGBA value here renders with the wrong
|
||||
// channel order.
|
||||
private static void DrawSwatch(ImDrawListPtr draw, Vector2 topLeft, uint colorAbgr)
|
||||
{
|
||||
draw.AddRectFilled(topLeft, topLeft + new Vector2(14, 14), colorAbgr, 2f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Numerics;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Themes;
|
||||
using HellionChat.Ui.Components.Settings;
|
||||
|
||||
namespace HellionChat.Ui.Components;
|
||||
|
||||
// Restores the 1.5.4 header quick-picker (a46d89c:ChatLogWindow.cs:481-558): a
|
||||
// palette button in the input-bar button row opening a popup that switches the
|
||||
// theme (built-in + custom) and jumps between chat tabs without opening settings.
|
||||
// Switch path mirrors the settings ThemePicker exactly; the tab jump routes
|
||||
// through MainWindow.ActivateTab so tell/unread handling matches a real tab click.
|
||||
internal sealed class ThemeQuickPicker
|
||||
{
|
||||
private const string PopupId = "##hellion-quick-picker";
|
||||
private const float SectionWidth = 220f;
|
||||
private const float RowHeight = 22f;
|
||||
private const float MaxSectionHeight = 200f;
|
||||
|
||||
private readonly ThemeRegistry _themes;
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
public ThemeQuickPicker(ThemeRegistry themes, Plugin plugin)
|
||||
{
|
||||
_themes = themes;
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public void OpenPopup() => ImGui.OpenPopup(PopupId);
|
||||
|
||||
public void Draw()
|
||||
{
|
||||
using var popup = ImRaii.Popup(PopupId);
|
||||
if (!popup)
|
||||
return;
|
||||
|
||||
DrawThemeSection();
|
||||
ImGui.Spacing();
|
||||
DrawTabSection();
|
||||
}
|
||||
|
||||
private void DrawThemeSection()
|
||||
{
|
||||
ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Themes_Header);
|
||||
ImGui.Separator();
|
||||
|
||||
var themes = AllThemes();
|
||||
var height = MathF.Min(themes.Count * RowHeight, MaxSectionHeight);
|
||||
using var child = ImRaii.Child(
|
||||
"##hellion-quick-picker-themes",
|
||||
new Vector2(SectionWidth, height)
|
||||
);
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
var activeSlug = _themes.Active.Slug;
|
||||
foreach (var theme in themes)
|
||||
{
|
||||
var isActive = string.Equals(
|
||||
theme.Slug,
|
||||
activeSlug,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
);
|
||||
DrawGlyph(isActive);
|
||||
if (
|
||||
ImGui.Selectable(
|
||||
$"{theme.Name}##quick-theme-{theme.Slug}",
|
||||
isActive,
|
||||
ImGuiSelectableFlags.DontClosePopups
|
||||
) && !isActive
|
||||
)
|
||||
{
|
||||
_themes.Switch(theme.Slug);
|
||||
Plugin.Config.Theme = theme.Slug;
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTabSection()
|
||||
{
|
||||
ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Tabs_Header);
|
||||
ImGui.Separator();
|
||||
|
||||
// Snapshot so a worker-thread temp-tab strip can't shift the list mid-loop.
|
||||
var tabs = Plugin.Config.Tabs.ToList();
|
||||
var height = MathF.Min(tabs.Count * RowHeight, MaxSectionHeight);
|
||||
using var child = ImRaii.Child(
|
||||
"##hellion-quick-picker-tabs",
|
||||
new Vector2(SectionWidth, height)
|
||||
);
|
||||
if (!child)
|
||||
return;
|
||||
|
||||
var window = _plugin.MainWindow;
|
||||
var active = window?.ActiveTab;
|
||||
for (var i = 0; i < tabs.Count; i++)
|
||||
{
|
||||
var tab = tabs[i];
|
||||
var isActive = ReferenceEquals(tab, active);
|
||||
DrawGlyph(isActive);
|
||||
if (
|
||||
ImGui.Selectable(
|
||||
$"{tab.Name}##quick-tab-{i}",
|
||||
isActive,
|
||||
ImGuiSelectableFlags.DontClosePopups
|
||||
) && !isActive
|
||||
)
|
||||
{
|
||||
window?.ActivateTab(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leading check glyph for the active row; inactive rows reserve an equal-width
|
||||
// blank so labels stay aligned. The FontAwesome font is pushed on its own line
|
||||
// then SameLine() so it doesn't bleed into the body-font label (1.5.4 trick).
|
||||
private void DrawGlyph(bool isActive)
|
||||
{
|
||||
var check = FontAwesomeIcon.Check.ToIconString();
|
||||
using (_plugin.FontManager.FontAwesome.Push())
|
||||
{
|
||||
if (isActive)
|
||||
ImGui.TextUnformatted(check);
|
||||
else
|
||||
ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight()));
|
||||
}
|
||||
ImGui.SameLine();
|
||||
}
|
||||
|
||||
private List<Theme> AllThemes()
|
||||
{
|
||||
var all = new List<Theme>();
|
||||
foreach (var slug in ThemePicker.CategoryMapSlugs)
|
||||
if (_themes.TryGet(slug, out var theme))
|
||||
all.Add(theme);
|
||||
all.AddRange(_themes.AllCustom());
|
||||
return all;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,17 @@ internal sealed class ChannelPopoutWindow : Window
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
public override void PreDraw()
|
||||
{
|
||||
// Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader
|
||||
// carries the close button in-body regardless, so hiding the title bar
|
||||
// never strands the pop-out. Reset from a fresh base each frame so
|
||||
// toggling the bar back on clears NoTitleBar.
|
||||
Flags = Plugin.Config.ShowPopOutTitleBar
|
||||
? ImGuiWindowFlags.None
|
||||
: ImGuiWindowFlags.NoTitleBar;
|
||||
}
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
if (Bound is null)
|
||||
|
||||
@@ -33,6 +33,10 @@ internal sealed class MainWindow : Window
|
||||
|
||||
private Tab? _activeTab;
|
||||
|
||||
// Runtime-only hide: window stays IsOpen but DrawConditions skips it, so the
|
||||
// chat-activation key can restore it (1.5.6 HideState.User parity).
|
||||
private bool _userHidden;
|
||||
|
||||
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
|
||||
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
|
||||
internal unsafe ImGuiViewport* LastViewport;
|
||||
@@ -66,7 +70,9 @@ internal sealed class MainWindow : Window
|
||||
MinimumSize = new Vector2(MinWidth, MinHeight),
|
||||
MaximumSize = new Vector2(float.MaxValue, float.MaxValue),
|
||||
};
|
||||
IsOpen = Plugin.Config.MainWindowOpen;
|
||||
// 1.5.6 parity: the chat always shows on login. The window stays closeable
|
||||
// and hideable within a session, but that state is not carried across starts.
|
||||
IsOpen = true;
|
||||
RespectCloseHotkey = false;
|
||||
}
|
||||
|
||||
@@ -77,19 +83,21 @@ internal sealed class MainWindow : Window
|
||||
internal float ResolveBgAlpha(bool isFocused) =>
|
||||
isFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive;
|
||||
|
||||
// B1-2: rebuild flags from a fresh base every frame so toggling CanMove/
|
||||
// CanResize back on actually CLEARS NoMove/NoResize (not accumulating).
|
||||
// Move/resize toggle logic as 1.5.6 (ChatLogWindow.PreOpenCheck
|
||||
// 1d3b429:703-707); base flags = today's MainWindow set (NoScrollbar|
|
||||
// NoScrollWithMouse — the message list owns its own scroll; 1.5.6's
|
||||
// NoFocusOnAppearing/NoTitleBar are deliberately not restored).
|
||||
internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize)
|
||||
// B1-2 / P7: rebuild flags from a fresh base every frame so toggling
|
||||
// CanMove/CanResize/ShowTitleBar back on actually CLEARS NoMove/NoResize/
|
||||
// NoTitleBar (not accumulating). Move/resize/title-bar logic as 1.5.6
|
||||
// (ChatLogWindow.PreOpenCheck 1d3b429:703-710); base flags = today's
|
||||
// MainWindow set (NoScrollbar|NoScrollWithMouse — the message list owns its
|
||||
// own scroll; 1.5.6's NoFocusOnAppearing is deliberately not restored).
|
||||
internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize, bool showTitleBar)
|
||||
{
|
||||
var flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
|
||||
if (!canMove)
|
||||
flags |= ImGuiWindowFlags.NoMove;
|
||||
if (!canResize)
|
||||
flags |= ImGuiWindowFlags.NoResize;
|
||||
if (!showTitleBar)
|
||||
flags |= ImGuiWindowFlags.NoTitleBar;
|
||||
return flags;
|
||||
}
|
||||
|
||||
@@ -114,7 +122,11 @@ internal sealed class MainWindow : Window
|
||||
BgAlpha = null;
|
||||
}
|
||||
|
||||
Flags = ResolveFlags(Plugin.Config.CanMove, Plugin.Config.CanResize);
|
||||
Flags = ResolveFlags(
|
||||
Plugin.Config.CanMove,
|
||||
Plugin.Config.CanResize,
|
||||
Plugin.Config.ShowTitleBar
|
||||
);
|
||||
}
|
||||
|
||||
public Tab? ActiveTab => _activeTab;
|
||||
@@ -135,6 +147,19 @@ internal sealed class MainWindow : Window
|
||||
TabLifecycleHelpers.OnTabActivated(next, removed);
|
||||
}
|
||||
|
||||
// Programmatic tab activation for the header quick-picker. Mirrors the click
|
||||
// path in TopTabBar/Sidebar exactly (previous → set → OnTabActivated) so a
|
||||
// header pick strips tell-state and resets unread the way a real click does.
|
||||
internal void ActivateTab(Tab tab)
|
||||
{
|
||||
if (ReferenceEquals(_activeTab, tab))
|
||||
return;
|
||||
|
||||
var previous = _activeTab;
|
||||
_activeTab = tab;
|
||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||
}
|
||||
|
||||
// Internal accessors for self-tests so the probes can reach the live
|
||||
// component without exposing them as public surface.
|
||||
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
|
||||
@@ -143,11 +168,33 @@ internal sealed class MainWindow : Window
|
||||
|
||||
internal Components.MessageList GetMessageListForSelfTest() => _messages;
|
||||
|
||||
// new-shadow on Window.Toggle so the open path also writes Config —
|
||||
// OnClose already covers the close path through the base behaviour.
|
||||
public override bool DrawConditions() => !_userHidden;
|
||||
|
||||
internal void UserHide() => _userHidden = true;
|
||||
|
||||
// Chat-activation keybind (Enter) entry point. Field writes only, so it is safe
|
||||
// from the framework thread; the draw path applies focus next frame.
|
||||
internal void ActivateChat()
|
||||
{
|
||||
_userHidden = false;
|
||||
if (!IsOpen)
|
||||
{
|
||||
IsOpen = true;
|
||||
Plugin.Config.MainWindowOpen = true;
|
||||
}
|
||||
BringToFront();
|
||||
_input.Activate = true;
|
||||
}
|
||||
|
||||
// new-shadow on Window.Toggle so the open path also writes Config. A user-hide
|
||||
// counts as "not visible", so /hellion is a reliable one-press recovery even when
|
||||
// the Enter keybind can't fire (DirectChat / a focused game text field).
|
||||
public new void Toggle()
|
||||
{
|
||||
IsOpen = !IsOpen;
|
||||
var visible = IsOpen && !_userHidden;
|
||||
IsOpen = !visible;
|
||||
if (IsOpen)
|
||||
_userHidden = false;
|
||||
Plugin.Config.MainWindowOpen = IsOpen;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Author": "Jon Kazama (Hellion Forge)",
|
||||
"Name": "Hellion Chat",
|
||||
"InternalName": "HellionChat",
|
||||
"AssemblyVersion": "1.8.7.0",
|
||||
"AssemblyVersion": "1.8.8.0",
|
||||
"Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR",
|
||||
"ApplicableVersion": "any",
|
||||
"RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat",
|
||||
@@ -25,7 +25,7 @@
|
||||
"DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip",
|
||||
"TestingAssemblyVersion": "1.8.7.0",
|
||||
"TestingAssemblyVersion": "1.8.8.0",
|
||||
"IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png",
|
||||
"ImageUrls": [
|
||||
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",
|
||||
|
||||
Reference in New Issue
Block a user