feat(settings): wire Appearance tab with import/export row

This commit is contained in:
2026-05-26 18:51:24 +02:00
parent 12bf83b826
commit 26c395419a
4 changed files with 366 additions and 3 deletions
+10
View File
@@ -158,6 +158,16 @@ internal static class PluginHostFactory
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ThemeImportExportRow(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<ILogger<Ui.Components.Settings.ThemeImportExportRow>>()
));
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>()
));
services.AddSingleton(sp => new Ui.Components.StatusBar(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
@@ -0,0 +1,47 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Ui.Components.Settings;
namespace HellionChat.Ui.Components.Settings.Tabs;
internal sealed class AppearanceTab
{
private readonly ThemePicker _picker;
private readonly ColorPicker _color;
private readonly LivePreviewPanel _preview;
private readonly ThemeImportExportRow _importExport;
public AppearanceTab(
ThemePicker picker,
ColorPicker color,
LivePreviewPanel preview,
ThemeImportExportRow importExport
)
{
_picker = picker;
_color = color;
_preview = preview;
_importExport = importExport;
}
public void Draw()
{
var availableX = ImGui.GetContentRegionAvail().X;
var leftWidth = MathF.Max(0, availableX - 290);
using (var left = ImRaii.Child("##appearance-left", new Vector2(leftWidth, 0)))
{
if (left.Success)
{
_picker.Draw();
ImGui.Spacing();
_importExport.Draw();
ImGui.Separator();
_color.Draw();
}
}
ImGui.SameLine();
_preview.Draw();
}
}
@@ -0,0 +1,296 @@
using System.Diagnostics;
using System.Security;
using Dalamud.Bindings.ImGui;
using HellionChat.Themes;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui.Components.Settings;
internal sealed class ThemeImportExportRow
{
private readonly ThemeRegistry _themes;
private readonly ILogger<ThemeImportExportRow> _logger;
private string _importPath = string.Empty;
public ThemeImportExportRow(ThemeRegistry themes, ILogger<ThemeImportExportRow> logger)
{
_themes = themes;
_logger = logger;
}
public void Draw()
{
if (ImGui.Button("Fork active theme"))
{
ForkActive();
}
ImGui.SameLine();
if (ImGui.Button("Import theme file…"))
{
ImportFromPath(_importPath);
}
ImGui.SameLine();
if (ImGui.Button("Open themes folder"))
{
OpenThemesFolder();
}
ImGui.SetNextItemWidth(-1);
ImGui.InputTextWithHint(
"##theme-import-path",
"Path to JSON file (or drag-and-drop into the folder)",
ref _importPath,
512
);
}
private void ForkActive()
{
var source = _themes.Active;
var suffix = source.IsBuiltIn ? "fork" : "copy";
var newSlug = $"{source.Slug}_{suffix}";
var attempt = 2;
// Bounds the slug-collision search so a buggy TryGet (or a degenerate
// themes directory with 100+ collisions on the same prefix) cannot
// spin the UI thread indefinitely. 100 is the bound for a sensible
// user state — anything past that means the themes folder is broken,
// surfaces as a log warning instead of a frozen frame.
const int MaxAttempts = 100;
while (_themes.TryGet(newSlug, out _))
{
if (attempt > MaxAttempts)
{
_logger.LogWarning(
"ForkActive aborted after {Max} slug-collision attempts on prefix {Prefix}",
MaxAttempts,
$"{source.Slug}_{suffix}"
);
return;
}
newSlug = $"{source.Slug}_{suffix}_{attempt++}";
}
var forked = source with
{
Slug = newSlug,
Name = $"{source.Name} ({suffix})",
IsBuiltIn = false,
};
_themes.BeginEditing(forked);
if (!_themes.SaveEditingBuffer(out var forkedPath))
{
_logger.LogWarning(
"Fork-active save failed for slug {Slug}; editing buffer left untouched",
newSlug
);
}
else
{
_logger.LogInformation("Forked active theme to {Path}", forkedPath);
}
}
// 64 KiB cap so a typo or accidental 500MB-file drop does not pull
// arbitrary bytes into memory before the loader rejects it. HellionArctic
// serialises to ~3 KiB so 64 KiB is generous for legitimate themes.
private const int MaxImportFileBytes = 64 * 1024;
private void ImportFromPath(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
_logger.LogWarning("Import skipped: file not found at {Path}", path);
return;
}
// Extension guard — refuse non-.json before reading any bytes.
// Cost of a typo (or ~/.ssh/id_rsa dropped into the box) is bounded
// before file I/O happens.
if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Import skipped: not a .json file at {Path}", path);
return;
}
// Size guard before ReadAllText so we never pull arbitrary bytes
// into memory or into logger exception messages.
long size;
try
{
size = new FileInfo(path).Length;
}
catch (Exception ex)
when (ex is IOException or UnauthorizedAccessException or SecurityException)
{
_logger.LogWarning(ex, "Import skipped: cannot stat {Path}", path);
return;
}
if (size > MaxImportFileBytes)
{
_logger.LogWarning(
"Import skipped: file {Path} is {Size} bytes, exceeds {Max}",
path,
size,
MaxImportFileBytes
);
return;
}
try
{
var json = File.ReadAllText(path);
Theme? theme;
try
{
theme = ThemeJsonLoader.LoadFromString(json);
}
catch (FormatException)
{
// Swallow the FormatException body deliberately — the loader's
// message can include slices of the input (e.g. unterminated
// string contents). For non-JSON files chosen by mistake that
// could leak file content into the log. Path alone is enough
// to diagnose.
_logger.LogWarning("Import skipped: malformed theme JSON at {Path}", path);
return;
}
if (theme is null)
{
_logger.LogWarning("Import skipped: invalid theme JSON at {Path}", path);
return;
}
// Slug sanitisation BEFORE BeginEditing — SaveEditingBuffer would
// reject too, but rejecting here means an unsafe slug never enters
// the editing buffer. Shared helper ThemeRegistry.IsSafeThemeSlug
// keeps the rule set in sync with F1's save-side guard (see
// ThemeRegistry.IsSafeThemeSlug shared helper).
var importSlug = theme.Slug;
if (!ThemeRegistry.IsSafeThemeSlug(importSlug))
{
_logger.LogWarning(
"Import skipped: theme at {Path} declares unsafe slug {Slug}",
path,
importSlug
);
return;
}
// Pragmatic deviation from §1.6 wording ("File.Copy into themes/"):
// BeginEditing+SaveEditingBuffer produces the same end-state and
// reuses the validated F1 save pipeline. Trade-off: destination
// filename becomes the theme's slug, not the original filename.
//
// Slug-collision handling:
// * Built-in collision -> rename to <slug>_imported. Switch()
// prefers built-ins (see ThemeRegistry.Switch built-in-first
// lookup), so a same-slug custom theme would persist on disk
// but never become active.
// * Custom-vs-custom collision -> rename to <slug>_imported_<N>.
// Silent overwrite is dangerous: if the colliding custom theme
// is active right now, the import would replace the live theme
// with no undo path. Renaming preserves both files; the user
// can delete the imported copy from the themes folder if it
// was truly meant as an overwrite.
var importTheme = theme;
if (_themes.BuiltinSlugs.Contains(importTheme.Slug, StringComparer.OrdinalIgnoreCase))
{
var renamedSlug = $"{importTheme.Slug}_imported";
_logger.LogWarning(
"Imported theme slug {Slug} collides with a built-in; renaming to {Renamed}",
importTheme.Slug,
renamedSlug
);
importTheme = importTheme with { Slug = renamedSlug };
}
else if (
_themes.TryGet(importTheme.Slug, out var existingCustom)
&& !existingCustom.IsBuiltIn
)
{
// Bounded slug-collision search (same rationale as ForkActive
// loop above): 100 attempts max so a pathological themes
// folder cannot spin the UI thread.
var baseSlug = $"{importTheme.Slug}_imported";
var renamedSlug = baseSlug;
var attempt = 2;
const int MaxAttempts = 100;
while (_themes.TryGet(renamedSlug, out _))
{
if (attempt > MaxAttempts)
{
_logger.LogWarning(
"Import aborted after {Max} custom-slug-collision attempts on prefix {Prefix}",
MaxAttempts,
baseSlug
);
return;
}
renamedSlug = $"{baseSlug}_{attempt++}";
}
_logger.LogWarning(
"Imported theme slug {Slug} collides with an existing custom theme; renaming to {Renamed}",
importTheme.Slug,
renamedSlug
);
importTheme = importTheme with { Slug = renamedSlug };
}
_themes.BeginEditing(importTheme);
if (!_themes.SaveEditingBuffer(out var importedPath))
{
_logger.LogWarning(
"Import save failed for slug {Slug} from {Path}",
importTheme.Slug,
path
);
}
else
{
_logger.LogInformation(
"Imported theme {Slug} from {Path} to {DestPath}",
importTheme.Slug,
path,
importedPath
);
}
}
catch (IOException ex)
{
_logger.LogWarning(ex, "I/O error importing theme from {Path}", path);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Access denied importing theme from {Path}", path);
}
}
// dir is sourced from ThemeRegistry.CustomThemesDir, built once in the
// registry ctor from a plugin-managed config path — never from user
// input. Process.Start with UseShellExecute=true is safe under that
// constraint. If a future cycle ever feeds user-supplied path here
// (custom-themes-dir override UI, drag-and-drop folder picker), validate
// it stays inside the plugin's config root BEFORE Process.Start
// (Path.GetFullPath comparison analogous to ThemeRegistry.SaveEditingBuffer's
// path-escape guard). Without that, a poisoned config could point at any
// directory on disk.
private void OpenThemesFolder()
{
var dir = _themes.CustomThemesDir;
if (string.IsNullOrEmpty(dir))
{
return;
}
try
{
Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not open themes folder {Dir}", dir);
}
}
}
+12 -2
View File
@@ -4,6 +4,7 @@ using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using HellionChat.Resources;
using HellionChat.Ui.Components.Settings;
using HellionChat.Ui.Components.Settings.Tabs;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui.Windows;
@@ -18,6 +19,7 @@ internal sealed class SettingsWindow : Window
private readonly ThemePicker _themePicker;
private readonly ColorPicker _colorPicker;
private readonly LivePreviewPanel _livePreview;
private readonly AppearanceTab _appearance;
public SettingsWindow(
Plugin plugin,
@@ -26,6 +28,7 @@ internal sealed class SettingsWindow : Window
ThemePicker themePicker,
ColorPicker colorPicker,
LivePreviewPanel livePreview,
AppearanceTab appearance,
ILoggerFactory loggerFactory
)
: base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
@@ -36,6 +39,7 @@ internal sealed class SettingsWindow : Window
_themePicker = themePicker;
_colorPicker = colorPicker;
_livePreview = livePreview;
_appearance = appearance;
_ = loggerFactory;
Size = new Vector2(720, 540);
@@ -62,8 +66,14 @@ internal sealed class SettingsWindow : Window
private void RenderActiveTab(string tabId)
{
// M6-M12 fill these branches; skeleton renders a placeholder per tab so
// the smoke check confirms tab switching works.
switch (tabId)
{
case "appearance":
_appearance.Draw();
break;
default:
ImGui.TextUnformatted($"[{tabId}] tab content lands in later task");
break;
}
}
}