feat(settings): wire Appearance tab with import/export row
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
ImGui.TextUnformatted($"[{tabId}] tab content lands in later task");
|
||||
switch (tabId)
|
||||
{
|
||||
case "appearance":
|
||||
_appearance.Draw();
|
||||
break;
|
||||
default:
|
||||
ImGui.TextUnformatted($"[{tabId}] tab content lands in later task");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user