From 2cc260170ec7fa0284b4d2406e15b3af9b096e81 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 18 May 2026 21:15:27 +0200 Subject: [PATCH] feat(ui): rewrite FirstRunWizard as four-step staged-commit flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-step navigation (Welcome → Privacy → Power Settings → Done) with a nested WizardState holding nullable Pending* fields. Profile picker becomes a 2x2 grid covering all four privacy profiles (PrivacyFirst, Casual ★ recommended, Roleplay new, FullHistory). Power-settings step surfaces six previously-hidden Configuration fields (LoadPreviousSession, FilterIncludePreviousSessions, AutoTellTabsHistoryPreload, UseCompactDensity, PrettierTimestamps, Theme) without introducing new ones. ApplyRoleplay mirrors the existing Apply* methods, CommitPending writes only the non-null fields back so skipping a step preserves existing config. OnClose docstring updated to reflect the actual code path (both Decide-Later and Finish set FirstRunCompleted = true, the wizard does not reopen). --- HellionChat/Ui/FirstRunWizard.cs | 528 +++++++++++++++++++++++++------ 1 file changed, 428 insertions(+), 100 deletions(-) diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index f54b4af..3fb46c5 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; @@ -6,13 +7,29 @@ using HellionChat.Branding; using HellionChat.Code; using HellionChat.Privacy; using HellionChat.Resources; +using HellionChat.Themes; using HellionChat.Util; namespace HellionChat.Ui; +// Multi-step first-run wizard. public sealed because Plugin.cs has a +// public-typed property on this class — narrowing to internal would +// be a build break across the assembly boundary. State lives in a +// nested WizardState record; every step writes nullable Pending* +// fields, and CommitPending() applies only the non-null ones so +// users who skip a step never get their existing config overwritten. public sealed class FirstRunWizard : Window { + // Forge-Bronze (#C2410C). The same constant lives in ThemeRegistry + // and the forge-announce workflow; pinning it locally keeps the + // wizard render path free of registry lookups during draw. + private static readonly Vector4 ForgeBronze = new(0xC2 / 255f, 0x41 / 255f, 0x0C / 255f, 1f); + private static readonly Vector4 ForgeBronzeDim = new(0xC2 / 255f, 0x41 / 255f, 0x0C / 255f, 0.3f); + + private const int TotalSteps = 4; + private readonly Plugin Plugin; + private readonly WizardState _state = new(); internal FirstRunWizard(Plugin plugin) : base($"{HellionStrings.Wizard_Title}###hellion-firstrun") @@ -32,138 +49,407 @@ public sealed class FirstRunWizard : Window public override void OnClose() { // OnClose fires on explicit X-click and on plugin dispose. We never - // implicitly accept the defaults here — the explicit "Later" button - // does that. If the user hasn't picked a profile yet, the wizard - // reopens on the next plugin load. + // implicitly accept the defaults here — both the explicit "Decide + // later" footer link and a successful "Finish ✓" set FirstRunCompleted + // = true, so the wizard does not reopen on the next plugin load + // regardless of which path the user took. } public override void Draw() { - DrawHellionForgeAnchor(); + DrawPagination(); ImGui.Spacing(); ImGui.Separator(); ImGui.Spacing(); - ImGui.TextWrapped(HellionStrings.Wizard_Intro); - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - var avail = ImGui.GetContentRegionAvail(); - var cardWidth = (avail.X - ImGui.GetStyle().ItemSpacing.X * 2) / 3f; - // Reserve room for the footer separator + cancel button below the cards. - var footerReserve = - ImGui.GetStyle().ItemSpacing.Y * 3 - + ImGui.GetTextLineHeight() - + ImGui.GetFrameHeightWithSpacing(); - var cardHeight = avail.Y - footerReserve; - - DrawCard( - "privacy-first", - cardWidth, - cardHeight, - HellionStrings.Wizard_Profile_PrivacyFirst_Heading, - HellionStrings.Wizard_Profile_PrivacyFirst_Description, - null, - HellionStrings.Wizard_Profile_PrivacyFirst_Apply, - ApplyPrivacyFirst - ); - - ImGui.SameLine(); - - DrawCard( - "casual", - cardWidth, - cardHeight, - HellionStrings.Wizard_Profile_Casual_Heading, - HellionStrings.Wizard_Profile_Casual_Description, - null, - HellionStrings.Wizard_Profile_Casual_Apply, - ApplyCasual - ); - - ImGui.SameLine(); - - DrawCard( - "full-history", - cardWidth, - cardHeight, - HellionStrings.Wizard_Profile_FullHistory_Heading, - HellionStrings.Wizard_Profile_FullHistory_Description, - HellionStrings.Wizard_Profile_FullHistory_GdprWarning, - HellionStrings.Wizard_Profile_FullHistory_Apply, - ApplyFullHistory - ); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - if (ImGui.Button(HellionStrings.Wizard_Cancel_Label)) + switch (_state.CurrentStep) { - Plugin.Config.FirstRunCompleted = true; - Plugin.SaveConfig(); - IsOpen = false; + case 1: DrawStepWelcome(); break; + case 2: DrawStepPrivacy(); break; + case 3: DrawStepPowerSettings(); break; + case 4: DrawStepDone(); break; + default: _state.CurrentStep = 1; DrawStepWelcome(); break; } - - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(HellionStrings.Wizard_Cancel_Tooltip); } - private void DrawCard( - string id, - float width, - float height, - string heading, - string description, - string? warning, - string buttonLabel, - Action onApply - ) + private void DrawPagination() { - using var child = ImRaii.Child($"##wizard-card-{id}", new Vector2(width, height), true); + var draw = ImGui.GetWindowDrawList(); + var avail = ImGui.GetContentRegionAvail(); + var cursor = ImGui.GetCursorScreenPos(); + const float radius = 5f; + const float spacing = 16f; + var totalWidth = (TotalSteps - 1) * spacing; + var startX = cursor.X + avail.X - totalWidth - radius; + + for (var i = 0; i < TotalSteps; i++) + { + var color = (i + 1) == _state.CurrentStep ? ForgeBronze : ForgeBronzeDim; + var packed = ImGui.GetColorU32(color); + draw.AddCircleFilled(new Vector2(startX + i * spacing, cursor.Y + radius), radius, packed); + } + + // Reserve vertical space the circles consumed so the next widget starts below them. + ImGui.Dummy(new Vector2(0, radius * 2)); + } + + private void DrawFooter(bool showBack, bool showSkip, string primaryLabel, Action onPrimary) + { + var spacing = ImGui.GetStyle().ItemSpacing.Y; + var primaryWidth = ImGui.CalcTextSize(primaryLabel).X + ImGui.GetStyle().FramePadding.X * 2 + 16f; + var avail = ImGui.GetContentRegionAvail(); + + // Push the footer to the bottom of the window so step contents + // above can size themselves with GetContentRegionAvail(). + var lineHeight = ImGui.GetFrameHeightWithSpacing(); + var pushDown = avail.Y - lineHeight - spacing; + if (pushDown > 0) + ImGui.Dummy(new Vector2(0, pushDown)); + + ImGui.Separator(); + ImGui.Spacing(); + + if (showBack) + { + if (ImGui.Button(HellionStrings.Wizard_Nav_Back)) + _state.CurrentStep = Math.Max(1, _state.CurrentStep - 1); + ImGui.SameLine(); + } + + if (showSkip) + { + if (ImGui.Button(HellionStrings.Wizard_Step1_Skip_Label)) + { + // Skip path = matches today's Cancel path: mark first-run + // complete, save, close. No CommitPending — the user said + // 'decide later', so existing config stays as-is. + Plugin.Config.FirstRunCompleted = true; + Plugin.SaveConfig(); + IsOpen = false; + } + if (ImGui.IsItemHovered()) + ImGuiUtil.Tooltip(HellionStrings.Wizard_Step1_Skip_Tooltip); + } + + // Right-align the primary action button. + var rightX = ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X - primaryWidth; + if (rightX > ImGui.GetCursorPosX()) + ImGui.SameLine(rightX); + + using (ImRaii.PushColor(ImGuiCol.Button, ForgeBronze)) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ForgeBronze)) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, ForgeBronze)) + { + if (ImGui.Button($"{primaryLabel}##wizard-primary")) + onPrimary(); + } + } + + private void DrawStepWelcome() + { + ImGui.TextUnformatted(HellionStrings.Wizard_Step1_Title); + ImGui.Spacing(); + + using (Plugin.Interface.UiBuilder.MonoFontHandle.Push()) + { + // Centre the banner across the available region. Spec Z.96 + // calls for a zentrierten Mono-Font-Block. CalcTextSize must + // run inside the MonoFont push so the measurement matches the + // glyph width actually used for rendering. + var bannerSize = ImGui.CalcTextSize(HellionForgeAscii.FoxBanner); + ImGui.SetCursorPosX((ImGui.GetContentRegionAvail().X - bannerSize.X) * 0.5f); + ImGui.TextUnformatted(HellionForgeAscii.FoxBanner); + } + + ImGui.Spacing(); + ImGui.TextWrapped(HellionStrings.Wizard_Step1_Subtitle); + ImGui.Spacing(); + ImGui.TextWrapped(HellionStrings.Wizard_Step1_Footer_Hint); + + DrawFooter(showBack: false, showSkip: true, HellionStrings.Wizard_Nav_Next, + () => _state.CurrentStep = 2); + } + + private void DrawStepPrivacy() + { + ImGui.TextUnformatted(HellionStrings.Wizard_Step2_Title); + ImGui.Spacing(); + + // Reserve footer height (separator + spacing + button row) so the + // 2x2 grid uses the rest of the window. + var footerReserve = ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y * 3 + ImGui.GetTextLineHeight(); + var grid = ImGui.GetContentRegionAvail(); + var cardWidth = (grid.X - ImGui.GetStyle().ItemSpacing.X) / 2f; + var cardHeight = (grid.Y - footerReserve - ImGui.GetStyle().ItemSpacing.Y) / 2f; + + // Top row. + DrawProfileCard(PrivacyProfile.PrivacyFirst, "🔒", + HellionStrings.Wizard_Profile_PrivacyFirst_Heading, + HellionStrings.Wizard_Profile_PrivacyFirst_Description, + recommended: false, cardWidth, cardHeight); + ImGui.SameLine(); + DrawProfileCard(PrivacyProfile.Casual, "💬", + HellionStrings.Wizard_Profile_Casual_Heading, + HellionStrings.Wizard_Profile_Casual_Description, + recommended: true, cardWidth, cardHeight); + + // Bottom row. + DrawProfileCard(PrivacyProfile.Roleplay, "🎭", + HellionStrings.Wizard_Profile_Roleplay_Heading, + HellionStrings.Wizard_Profile_Roleplay_Description, + recommended: false, cardWidth, cardHeight); + ImGui.SameLine(); + DrawProfileCard(PrivacyProfile.FullHistory, "📚", + HellionStrings.Wizard_Profile_FullHistory_Heading, + HellionStrings.Wizard_Profile_FullHistory_Description, + recommended: false, cardWidth, cardHeight); + + ImGui.Spacing(); + ImGui.TextDisabled(HellionStrings.Wizard_Step2_RecommendedFooter); + + DrawFooter(showBack: true, showSkip: true, HellionStrings.Wizard_Nav_Next, + () => _state.CurrentStep = 3); + } + + private void DrawProfileCard(PrivacyProfile profile, string emoji, string heading, string description, bool recommended, float width, float height) + { + var isSelected = _state.PendingProfile == profile; + // GetStyleColorVec4 returns a pointer to the live style entry in + // Dalamud.Bindings.ImGui, which would require unsafe. Use the U32 + // packed-colour overload of PushColor for the default branch so we + // can stay in safe code while still matching the current border. + var borderColor = isSelected ? ImGui.GetColorU32(ForgeBronze) : ImGui.GetColorU32(ImGuiCol.Border); + + using var _border = ImRaii.PushColor(ImGuiCol.Border, borderColor); + using var child = ImRaii.Child($"##profile-card-{profile}", new Vector2(width, height), true); if (!child.Success) return; - ImGui.TextUnformatted(heading); + // InvisibleButton over the full card area, then SetCursorScreenPos + // back to draw the heading/description content on top. Selectable + // would be semantically wrong here — the card is a standalone + // choice tile, not a list-item inside a list/menu. The button + // takes the click for the entire card area, and IsItemHovered() + // on it (if we wire one up later) would naturally cover the full + // tile. Visual feedback comes from the border colour above. + var startPos = ImGui.GetCursorScreenPos(); + var cardArea = ImGui.GetContentRegionAvail(); + if (ImGui.InvisibleButton($"##profile-hit-{profile}", cardArea)) + _state.PendingProfile = profile; + + ImGui.SetCursorScreenPos(startPos); + + ImGui.TextUnformatted($"{emoji} {heading}{(recommended ? " ★" : string.Empty)}"); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped(description); + } + + private void DrawStepPowerSettings() + { + // Seed only the two recommendation fields here. Other fields remain + // null until the user touches the corresponding control. + // Spec FR-4: the wizard explicitly recommends LoadPreviousSession = + // true and FilterIncludePreviousSessions = true (Config defaults are + // false). The other four fields (AutoTellTabsHistoryPreload, + // UseCompactDensity, PrettierTimestamps, Theme) follow the generic + // null-semantics from Spec Z.176: a null pending means the user did + // not touch that control, so CommitPending must not write back. They + // are read live from Plugin.Config below for the ImGui ref-binding + // but never seeded into Pending* without a user gesture. + _state.PendingLoadPreviousSession ??= true; + _state.PendingFilterIncludePreviousSessions ??= true; + + ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Title); + ImGui.Spacing(); + + // History section. + using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) + ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_History); + + var loadPrev = _state.PendingLoadPreviousSession ?? true; + if (ImGui.Checkbox(HellionStrings.Wizard_Step3_LoadPreviousSession_Label, ref loadPrev)) + { + _state.PendingLoadPreviousSession = loadPrev; + // Mirror the DataManagement coupling: turning load-previous on + // also turns filter-include on (otherwise old messages bypass + // the filter chain), and turning filter-include off forces + // load-previous off. Same idiom as Ui/SettingsTabs/DataManagement.cs:182-200. + if (loadPrev) + _state.PendingFilterIncludePreviousSessions = true; + } + + var filterPrev = _state.PendingFilterIncludePreviousSessions ?? true; + if (ImGui.Checkbox(HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label, ref filterPrev)) + { + _state.PendingFilterIncludePreviousSessions = filterPrev; + if (!filterPrev) + _state.PendingLoadPreviousSession = false; + } + + ImGui.Spacing(); + + // Tell-Tabs section. + using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) + ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_TellTabs); + + var preload = _state.PendingAutoTellTabsHistoryPreload ?? Plugin.Config.AutoTellTabsHistoryPreload; + if (ImGui.SliderInt(HellionStrings.Wizard_Step3_AutoTellTabsHistoryPreload_Label, ref preload, 0, 100)) + _state.PendingAutoTellTabsHistoryPreload = preload; + + ImGui.Spacing(); + + // Visual section. + using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) + ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_Visual); + + var compact = _state.PendingUseCompactDensity ?? Plugin.Config.UseCompactDensity; + if (ImGui.Checkbox(HellionStrings.Wizard_Step3_UseCompactDensity_Label, ref compact)) + _state.PendingUseCompactDensity = compact; + + var pretty = _state.PendingPrettierTimestamps ?? Plugin.Config.PrettierTimestamps; + if (ImGui.Checkbox(HellionStrings.Wizard_Step3_PrettierTimestamps_Label, ref pretty)) + _state.PendingPrettierTimestamps = pretty; + + // Theme dropdown — built-ins only. Custom themes are power-user + // territory and would clutter the first-run flow. + var currentSlug = _state.PendingTheme ?? Plugin.Config.Theme; + var builtIns = Plugin.ThemeRegistry.AllBuiltIns().ToList(); + var currentIndex = builtIns.FindIndex(t => string.Equals(t.Slug, currentSlug, StringComparison.OrdinalIgnoreCase)); + if (currentIndex < 0) + currentIndex = 0; + + using (var combo = ImRaii.Combo(HellionStrings.Wizard_Step3_Theme_Label, builtIns[currentIndex].Name)) + { + if (combo.Success) + { + for (var i = 0; i < builtIns.Count; i++) + { + var isSelected = i == currentIndex; + if (ImGui.Selectable(builtIns[i].Name, isSelected)) + _state.PendingTheme = builtIns[i].Slug; + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + } + } + + DrawFooter(showBack: true, showSkip: true, HellionStrings.Wizard_Nav_Next, + () => _state.CurrentStep = 4); + } + + private void DrawStepDone() + { + ImGui.TextUnformatted(HellionStrings.Wizard_Step4_Title); + ImGui.Spacing(); + + // ✓ symbol, centred-ish via dummy padding. + var checkmark = "✓"; + var checkSize = ImGui.CalcTextSize(checkmark); + var avail = ImGui.GetContentRegionAvail(); + ImGui.Dummy(new Vector2((avail.X - checkSize.X) * 0.5f, 0)); + ImGui.SameLine(); + using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) + ImGui.TextUnformatted(checkmark); + ImGui.Spacing(); ImGui.Separator(); ImGui.Spacing(); - ImGui.TextWrapped(description); - - if (warning is not null) + // Summary card. + using (var summary = ImRaii.Child("##wizard-summary", new Vector2(-1, 130), true)) { - ImGui.Spacing(); - ImGuiUtil.WarningText(warning); + if (summary.Success) + { + ImGui.TextUnformatted(HellionStrings.Wizard_Step4_SummaryHeading); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + var profileLabel = _state.PendingProfile switch + { + PrivacyProfile.PrivacyFirst => HellionStrings.Wizard_Profile_PrivacyFirst_Heading, + PrivacyProfile.Casual => HellionStrings.Wizard_Profile_Casual_Heading, + PrivacyProfile.Roleplay => HellionStrings.Wizard_Profile_Roleplay_Heading, + PrivacyProfile.FullHistory => HellionStrings.Wizard_Profile_FullHistory_Heading, + _ => HellionStrings.Wizard_Step4_Summary_Unchanged, + }; + ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_Summary_Profile, profileLabel)); + + var historyLabel = (_state.PendingLoadPreviousSession ?? false) + ? HellionStrings.Wizard_Step3_LoadPreviousSession_Label + : HellionStrings.Wizard_Step4_Summary_Unchanged; + ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_Summary_History, historyLabel)); + + var preloadValue = _state.PendingAutoTellTabsHistoryPreload ?? Plugin.Config.AutoTellTabsHistoryPreload; + ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_Summary_TellTabs, preloadValue)); + + var compact = _state.PendingUseCompactDensity ?? Plugin.Config.UseCompactDensity; + var pretty = _state.PendingPrettierTimestamps ?? Plugin.Config.PrettierTimestamps; + var themeSlug = _state.PendingTheme ?? Plugin.Config.Theme; + var themeName = Plugin.ThemeRegistry.Get(themeSlug).Name; + var visualParts = new List(); + if (compact) visualParts.Add(HellionStrings.Wizard_Step3_UseCompactDensity_Label); + if (pretty) visualParts.Add(HellionStrings.Wizard_Step3_PrettierTimestamps_Label); + visualParts.Add(themeName); + ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_Summary_Visual, string.Join(", ", visualParts))); + } } - // Push the button to the bottom of the card. - var lineHeight = ImGui.GetFrameHeightWithSpacing(); - var remaining = ImGui.GetContentRegionAvail().Y - lineHeight; - if (remaining > 0) - ImGui.Dummy(new Vector2(0, remaining)); + ImGui.Spacing(); - if (ImGui.Button($"{buttonLabel}##{id}", new Vector2(-1, 0))) + // Inline FR-3 hint with placeholder for preload count. + var preloadForHint = _state.PendingAutoTellTabsHistoryPreload ?? Plugin.Config.AutoTellTabsHistoryPreload; + using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) + ImGui.TextWrapped(string.Format(HellionStrings.Wizard_Step4_TestHint, preloadForHint)); + + ImGui.Spacing(); + ImGui.TextDisabled(HellionStrings.Wizard_Step4_SettingsHint); + + DrawFooter(showBack: true, showSkip: false, HellionStrings.Wizard_Nav_Finish, () => { - onApply(); + CommitPending(); Plugin.Config.FirstRunCompleted = true; Plugin.SaveConfig(); IsOpen = false; - } + }); } - // Collapsible because the full silhouette is taller than the wizard - // window — folded by default so the privacy cards stay the primary - // focus, expandable for whoever wants the "about the makers" anchor. - private void DrawHellionForgeAnchor() + // Writes only non-null pending values back to Config. A null pending + // means the user did not touch that step's control, so the existing + // Config value is preserved. Theme switch goes through ThemeRegistry + // so the active palette updates live for the rest of the session. + internal void CommitPending() { - using var tree = ImRaii.TreeNode("Hellion Forge"); - if (!tree.Success) - return; + switch (_state.PendingProfile) + { + case PrivacyProfile.PrivacyFirst: ApplyPrivacyFirst(); break; + case PrivacyProfile.Casual: ApplyCasual(); break; + case PrivacyProfile.Roleplay: ApplyRoleplay(); break; + case PrivacyProfile.FullHistory: ApplyFullHistory(); break; + } - using (Plugin.Interface.UiBuilder.MonoFontHandle.Push()) - ImGui.TextUnformatted(HellionForgeAscii.FoxBanner); + if (_state.PendingLoadPreviousSession.HasValue) + Plugin.Config.LoadPreviousSession = _state.PendingLoadPreviousSession.Value; + + if (_state.PendingFilterIncludePreviousSessions.HasValue) + Plugin.Config.FilterIncludePreviousSessions = _state.PendingFilterIncludePreviousSessions.Value; + + if (_state.PendingAutoTellTabsHistoryPreload.HasValue) + Plugin.Config.AutoTellTabsHistoryPreload = _state.PendingAutoTellTabsHistoryPreload.Value; + + if (_state.PendingUseCompactDensity.HasValue) + Plugin.Config.UseCompactDensity = _state.PendingUseCompactDensity.Value; + + if (_state.PendingPrettierTimestamps.HasValue) + Plugin.Config.PrettierTimestamps = _state.PendingPrettierTimestamps.Value; + + if (!string.IsNullOrWhiteSpace(_state.PendingTheme)) + { + Plugin.Config.Theme = _state.PendingTheme; + Plugin.ThemeRegistry.Switch(_state.PendingTheme); + } } private void ApplyPrivacyFirst() @@ -194,6 +480,20 @@ public sealed class FirstRunWizard : Window Plugin.Config.RetentionPerChannelDays = policy; } + private void ApplyRoleplay() + { + Plugin.Config.PrivacyFilterEnabled = true; + Plugin.Config.PrivacyPersistChannels = [.. PrivacyDefaults.RoleplayWhitelist]; + Plugin.Config.PrivacyPersistUnknownChannels = false; + + Plugin.Config.RetentionEnabled = true; + Plugin.Config.RetentionDefaultDays = 30; + var policy = PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value); + foreach (var (type, days) in PrivacyDefaults.RoleplayRetentionOverrides) + policy[type] = days; + Plugin.Config.RetentionPerChannelDays = policy; + } + private void ApplyFullHistory() { // Full history = upstream Chat 2 behavior. Filter off, retention off, @@ -205,4 +505,32 @@ public sealed class FirstRunWizard : Window Plugin.Config.RetentionEnabled = false; Plugin.Config.RetentionPerChannelDays.Clear(); } + + // Test-only entry point so SelfTests/WizardStateSmokeStep can advance + // the state machine without spawning ImGui input events. + internal void TestOnly_AdvanceTo(int step) => _state.CurrentStep = Math.Clamp(step, 1, TotalSteps); + + // Test-only setter so the smoke-test can pin a profile selection + // without driving the ImGui card-click path. + internal void TestOnly_SetPendingProfile(PrivacyProfile profile) => _state.PendingProfile = profile; + + internal enum PrivacyProfile + { + PrivacyFirst, + Casual, + Roleplay, + FullHistory, + } + + private sealed class WizardState + { + public int CurrentStep { get; set; } = 1; + public PrivacyProfile? PendingProfile { get; set; } + public bool? PendingLoadPreviousSession { get; set; } + public bool? PendingFilterIncludePreviousSessions { get; set; } + public int? PendingAutoTellTabsHistoryPreload { get; set; } + public bool? PendingUseCompactDensity { get; set; } + public bool? PendingPrettierTimestamps { get; set; } + public string? PendingTheme { get; set; } + } }