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<ThemeRegistry>(),
|
||||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
|
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(
|
services.AddSingleton(sp => new Ui.Components.StatusBar(
|
||||||
sp.GetRequiredService<ThemeRegistry>(),
|
sp.GetRequiredService<ThemeRegistry>(),
|
||||||
sp.GetRequiredService<FontManager>()
|
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 Dalamud.Utility;
|
||||||
using HellionChat.Resources;
|
using HellionChat.Resources;
|
||||||
using HellionChat.Ui.Components.Settings;
|
using HellionChat.Ui.Components.Settings;
|
||||||
|
using HellionChat.Ui.Components.Settings.Tabs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Windows;
|
namespace HellionChat.Ui.Windows;
|
||||||
@@ -18,6 +19,7 @@ internal sealed class SettingsWindow : Window
|
|||||||
private readonly ThemePicker _themePicker;
|
private readonly ThemePicker _themePicker;
|
||||||
private readonly ColorPicker _colorPicker;
|
private readonly ColorPicker _colorPicker;
|
||||||
private readonly LivePreviewPanel _livePreview;
|
private readonly LivePreviewPanel _livePreview;
|
||||||
|
private readonly AppearanceTab _appearance;
|
||||||
|
|
||||||
public SettingsWindow(
|
public SettingsWindow(
|
||||||
Plugin plugin,
|
Plugin plugin,
|
||||||
@@ -26,6 +28,7 @@ internal sealed class SettingsWindow : Window
|
|||||||
ThemePicker themePicker,
|
ThemePicker themePicker,
|
||||||
ColorPicker colorPicker,
|
ColorPicker colorPicker,
|
||||||
LivePreviewPanel livePreview,
|
LivePreviewPanel livePreview,
|
||||||
|
AppearanceTab appearance,
|
||||||
ILoggerFactory loggerFactory
|
ILoggerFactory loggerFactory
|
||||||
)
|
)
|
||||||
: base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
|
: base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
|
||||||
@@ -36,6 +39,7 @@ internal sealed class SettingsWindow : Window
|
|||||||
_themePicker = themePicker;
|
_themePicker = themePicker;
|
||||||
_colorPicker = colorPicker;
|
_colorPicker = colorPicker;
|
||||||
_livePreview = livePreview;
|
_livePreview = livePreview;
|
||||||
|
_appearance = appearance;
|
||||||
_ = loggerFactory;
|
_ = loggerFactory;
|
||||||
|
|
||||||
Size = new Vector2(720, 540);
|
Size = new Vector2(720, 540);
|
||||||
@@ -62,8 +66,14 @@ internal sealed class SettingsWindow : Window
|
|||||||
|
|
||||||
private void RenderActiveTab(string tabId)
|
private void RenderActiveTab(string tabId)
|
||||||
{
|
{
|
||||||
// M6-M12 fill these branches; skeleton renders a placeholder per tab so
|
switch (tabId)
|
||||||
// the smoke check confirms tab switching works.
|
{
|
||||||
ImGui.TextUnformatted($"[{tabId}] tab content lands in later task");
|
case "appearance":
|
||||||
|
_appearance.Draw();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ImGui.TextUnformatted($"[{tabId}] tab content lands in later task");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user