using System.Globalization; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; 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") { Plugin = plugin; Flags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking; SizeCondition = ImGuiCond.Appearing; Size = new Vector2(720, 480); SizeConstraints = new WindowSizeConstraints { MinimumSize = new Vector2(600, 400), MaximumSize = new Vector2(float.MaxValue, float.MaxValue), }; } public override void OnClose() { // OnClose fires on explicit X-click and on plugin dispose. We never // 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() { DrawPagination(); ImGui.Spacing(); ImGui.Separator(); ImGui.Spacing(); switch (_state.CurrentStep) { case 1: DrawStepWelcome(); break; case 2: DrawStepPrivacy(); break; case 3: DrawStepPowerSettings(); break; case 4: DrawStepDone(); break; default: _state.CurrentStep = 1; DrawStepWelcome(); break; } } private void DrawPagination() { 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(); // Fox-banner image: the embedded Hellion Forge fox artwork. The card // behind the image gives the dark fox enough contrast against the // plugin's dark UI so the logo reads clearly at a glance. var banner = FoxBannerTexture.Shared.GetWrapOrDefault(); if (banner is not null) { const uint CardColor = 0xFFE8E8E8; // off-white fill so the dark fox pops var imgHeight = 170f * ImGuiHelpers.GlobalScale; var imgWidth = imgHeight * banner.Size.X / banner.Size.Y; var pad = 14f * ImGuiHelpers.GlobalScale; var cardWidth = imgWidth + pad * 2f; var cardHeight = imgHeight + pad * 2f; var rounding = 8f * ImGuiHelpers.GlobalScale; // Centre the card in the content region. Clamp to zero so the card // never shifts left of the window edge on very narrow windows. var offsetX = Math.Max(0f, (ImGui.GetContentRegionAvail().X - cardWidth) * 0.5f); var cardOrigin = ImGui.GetCursorScreenPos() + new Vector2(offsetX, 0f); // Draw the rounded card behind the image, then place the image on top. ImGui .GetWindowDrawList() .AddRectFilled( cardOrigin, cardOrigin + new Vector2(cardWidth, cardHeight), CardColor, rounding ); ImGui.SetCursorScreenPos(cardOrigin + new Vector2(pad, pad)); ImGui.Image(banner.Handle, new Vector2(imgWidth, imgHeight)); // Advance the layout cursor past the full card so the content below // starts at the right position and does not overlap the card. ImGui.SetCursorScreenPos(cardOrigin); ImGui.Dummy(new Vector2(cardWidth, cardHeight)); } 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; // 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 recommendation field here. Other fields remain null // until the user touches the corresponding control. // Spec FR-4: the wizard explicitly recommends // FilterIncludePreviousSessions = true (the Config default is 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.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); // One checkbox, not two. LoadPreviousSession was asked for here, shown // as applied in the summary and written to the config, and no code in // the plugin has ever read it -- so the wizard was collecting a // decision and reporting an effect that never happened. Its partner // does the work on its own. var filterPrev = _state.PendingFilterIncludePreviousSessions ?? true; if ( ImGui.Checkbox( HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label, ref filterPrev ) ) { _state.PendingFilterIncludePreviousSessions = filterPrev; } 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; // 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(); // Summary card. using (var summary = ImRaii.Child("##wizard-summary", new Vector2(-1, 130), true)) { 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) ); // Three states, not two. "Unchanged" is for a step the user // never entered; an unticked box is a decision that gets // committed, and reporting it as unchanged was the same kind of // lie the deleted LoadPreviousSession told. var historyLabel = _state.PendingFilterIncludePreviousSessions switch { true => HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label, false => HellionStrings.Wizard_Step4_Summary_Off, null => 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 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); visualParts.Add(themeName); ImGui.TextWrapped( string.Format( HellionStrings.Wizard_Step4_Summary_Visual, string.Join(", ", visualParts) ) ); } } ImGui.Spacing(); // 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, () => { CommitPending(); Plugin.Config.FirstRunCompleted = true; Plugin.SaveConfig(); IsOpen = false; } ); } // 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() { switch (_state.PendingProfile) { case PrivacyProfile.PrivacyFirst: ApplyPrivacyFirst(); break; case PrivacyProfile.Casual: ApplyCasual(); break; case PrivacyProfile.Roleplay: ApplyRoleplay(); break; case PrivacyProfile.FullHistory: ApplyFullHistory(); break; } 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 (!string.IsNullOrWhiteSpace(_state.PendingTheme)) { Plugin.Config.Theme = _state.PendingTheme; Plugin.ThemeRegistry.Switch(_state.PendingTheme); } } private void ApplyPrivacyFirst() { Plugin.Config.PrivacyFilterEnabled = true; Plugin.Config.PrivacyPersistChannels = [.. PrivacyDefaults.PrivacyFirstWhitelist]; Plugin.Config.PrivacyPersistUnknownChannels = false; Plugin.Config.RetentionEnabled = true; Plugin.Config.RetentionDefaultDays = 30; Plugin.Config.RetentionPerChannelDays = PrivacyDefaults.DefaultRetentionDays.ToDictionary( p => p.Key, p => p.Value ); } private void ApplyCasual() { Plugin.Config.PrivacyFilterEnabled = true; Plugin.Config.PrivacyPersistChannels = [.. PrivacyDefaults.CasualWhitelist]; 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.CasualRetentionOverrides) policy[type] = days; 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, // everything (except battle messages, which Chat 2 itself controls) // accumulates indefinitely. Plugin.Config.PrivacyFilterEnabled = false; Plugin.Config.PrivacyPersistUnknownChannels = true; Plugin.Config.RetentionEnabled = false; // Clear does not throw during enumeration, it just cuts it short -- the // retention sweep would then run on half a policy. lock (Plugin.ConfigMapsLock) 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? PendingFilterIncludePreviousSessions { get; set; } public int? PendingAutoTellTabsHistoryPreload { get; set; } public bool? PendingUseCompactDensity { get; set; } public string? PendingTheme { get; set; } } }