diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9d3661c..9435f82 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -54,6 +54,8 @@ | I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ | | I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ | | I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ | +| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | +| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 26e3ab6..0fbe73d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -595,6 +595,12 @@ public sealed class GameWindow : IDisposable _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; + // L.0 Display tab: keep the GL viewport + camera aspect in sync + // with the window framebuffer. Without this handler, resizing + // the window (or applying a Display-tab Resolution change at + // startup) leaves the viewport pinned to the original size — + // user sees a small render in the corner of a big window. + _window.FramebufferResize += OnFramebufferResize; _window.Run(); } @@ -833,6 +839,17 @@ public sealed class GameWindow : IDisposable } } + // L.0 follow-up — load + apply persisted Display / Audio settings + // BEFORE the DevToolsEnabled block. The settings.json values + // (resolution, vsync, FOV, master volume, etc) are runtime + // settings, not devtools settings — a user running without + // ACDREAM_DEVTOOLS=1 still expects their saved values to take + // effect. The Settings PANEL (editing UI) is gated on devtools; + // the persisted state is not. Caches values into fields so the + // SettingsVM construction in the devtools block reads them + // without re-loading. + LoadAndApplyPersistedSettings(); + // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. @@ -922,8 +939,15 @@ public sealed class GameWindow : IDisposable // the draft. Construction is null-safe vs. the // dispatcher because the dispatcher is built earlier in // the same OnLoad path (see _inputDispatcher field). - if (_inputDispatcher is not null) + if (_inputDispatcher is not null && _settingsStore is not null) { + // L.0 — SettingsStore + persisted-settings load + apply + // happened earlier in OnLoad via + // LoadAndApplyPersistedSettings (settings are runtime + // state, not devtools state — they take effect even + // when ACDREAM_DEVTOOLS=0). Here we just construct the + // Settings PANEL on top of the already-loaded values. + var settingsStore = _settingsStore; _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( persisted: _keyBindings, dispatcher: _inputDispatcher, @@ -942,12 +966,113 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"keybinds: save failed: {ex.Message}"); } + }, + persistedDisplay: _persistedDisplay, + onSaveDisplay: display => + { + try + { + settingsStore.SaveDisplay(display); + Console.WriteLine( + "settings: display saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + // Apply window-level changes that are too + // jarring to live-preview (resolution + + // fullscreen). VSync / FOV / ShowFps + // already track DisplayDraft via the + // per-frame push. + ApplyDisplayWindowState(display); + } + catch (Exception ex) + { + Console.WriteLine($"settings: display save failed: {ex.Message}"); + } + }, + persistedAudio: _persistedAudio, + onSaveAudio: audio => + { + try + { + settingsStore.SaveAudio(audio); + Console.WriteLine( + "settings: audio saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: audio save failed: {ex.Message}"); + } + }, + persistedGameplay: _persistedGameplay, + onSaveGameplay: gameplay => + { + try + { + settingsStore.SaveGameplay(gameplay); + Console.WriteLine( + "settings: gameplay saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + // Local-only this phase. Server-sync packet + // (CharacterOption bitmask) goes in here when + // the protocol round-trip is in place. + } + catch (Exception ex) + { + Console.WriteLine($"settings: gameplay save failed: {ex.Message}"); + } + }, + persistedChat: _persistedChat, + onSaveChat: chat => + { + try + { + settingsStore.SaveChat(chat); + Console.WriteLine( + "settings: chat saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + // Channel filters affect client-side display + // only this phase. ChatPanel will read them + // off SettingsVM.ChatDraft when filtering is + // wired into the chat-line render path. + } + catch (Exception ex) + { + Console.WriteLine($"settings: chat save failed: {ex.Message}"); + } + }, + persistedCharacter: _persistedCharacter, + onSaveCharacter: character => + { + try + { + // _activeToonKey is updated by + // BeginLiveSessionAsync after EnterWorld + // so saving character settings always + // writes under the chosen character's + // name (or "default" pre-login). + settingsStore.SaveCharacter(_activeToonKey, character); + Console.WriteLine( + $"settings: character[{_activeToonKey}] saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: character save failed: {ex.Message}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); } Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)"); + + // L.0 Display tab: seed sensible default positions for + // every registered panel. cond=FirstUseEver means imgui.ini + // takes precedence on subsequent launches — the user's + // dragged positions persist. Without this, the first-run + // experience stacks every panel at (0,0) which looks + // broken. + ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver); } catch (Exception ex) { @@ -1464,6 +1589,20 @@ public sealed class GameWindow : IDisposable _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); + + // L.0 Character tab: swap the SettingsVM's character bag + // from the "default" pre-login bag to the actual chosen + // toon's bag. Every Save from now on writes under the + // chosen toon's name. LoadCharacterContext rebinds BOTH + // persisted + draft so HasUnsavedChanges doesn't flag the + // swap as a pending edit. + _activeToonKey = chosen.Name; + if (_settingsStore is not null && _settingsVm is not null) + { + var toonBag = _settingsStore.LoadCharacter(_activeToonKey); + _settingsVm.LoadCharacterContext(toonBag); + Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); + } // Phase K.2: arm auto-entry. The guard's predicates won't // pass yet — the entity stream hasn't started — but the // OnUpdate tick re-checks every frame and fires once @@ -4260,6 +4399,41 @@ public sealed class GameWindow : IDisposable System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); + // L.0 Audio tab: push the SettingsVM's live AudioDraft into the + // engine each frame, so volume sliders preview audibly while + // the user drags. Cancel reverts the draft and the engine + // catches up on the very next frame; Save persists to + // settings.json without changing engine state (already + // applied). Cheap enough to run unconditionally on every + // tick — four float assignments. + if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null) + { + var a = _settingsVm.AudioDraft; + _audioEngine.MasterVolume = a.Master; + _audioEngine.MusicVolume = a.Music; + _audioEngine.SfxVolume = a.Sfx; + _audioEngine.AmbientVolume = a.Ambient; + } + + // L.0 Display tab: push the live DisplayDraft into the + // active rendering surfaces each frame. FOV is the live- + // preview slider per the brainstorm — dragging it changes + // camera FovY immediately. VSync change-detected to avoid + // spamming the window. Resolution + Fullscreen apply on + // Save (handled by ApplyDisplayWindowState — too jarring + // to live-preview a resize). + if (_settingsVm is not null && _cameraController is not null) + { + var d = _settingsVm.DisplayDraft; + float fovYRad = d.FieldOfView * (MathF.PI / 180f); + _cameraController.Orbit.FovY = fovYRad; + _cameraController.Fly.FovY = fovYRad; + if (_cameraController.Chase is not null) + _cameraController.Chase.FovY = fovYRad; + if (_window is not null && _window.VSync != d.VSync) + _window.VSync = d.VSync; + } + // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) @@ -4510,6 +4684,15 @@ public sealed class GameWindow : IDisposable if (_debugPanel is not null && ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1")) _debugPanel.IsVisible = !_debugPanel.IsVisible; + ImGuiNET.ImGui.Separator(); + // L.0 Display tab: a manual reset for users whose + // imgui.ini has saved a panel position that's now + // off-screen (after a window shrink, monitor swap, + // or a malformed save). Force-resets every panel + // to its default landing position. The same code + // path runs automatically on FramebufferResize. + if (ImGuiNET.ImGui.MenuItem("Reset window layout")) + ResetPanelLayout(ImGuiNET.ImGuiCond.Always); ImGuiNET.ImGui.EndMenu(); } // K-fix2 (2026-04-26): Camera submenu — discoverable @@ -4543,9 +4726,16 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + - $"lb {visibleLandblocks}/{totalLandblocks} visible | " + - $"ent {entityCount} | anim {animatedCount}"; + // L.0 Display tab: ShowFps gates the perf string in the + // title bar. Default is true (matches pre-L.0 behaviour); + // unchecking the toggle in Display tab collapses the title + // to just "acdream" for a cleaner alt-tab experience. + bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true; + _window!.Title = showFps + ? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + + $"ent {entityCount} | anim {animatedCount}" + : "acdream"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -5478,6 +5668,188 @@ public sealed class GameWindow : IDisposable // default; F11 / View → Settings toggles. Null when devtools are off. private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; + // L.0: settings.json store + active toon key. The store is held as + // a field so BeginLiveSessionAsync can re-load the chosen toon's + // bag once we know its name (post-EnterWorld). Toon key starts as + // "default" and gets swapped to the actual character name on the + // first EnterWorld. + private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; + private string _activeToonKey = "default"; + // L.0 follow-up: persisted-settings cache populated by + // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, + // not gated on DevToolsEnabled). The Settings PANEL construction + // — which IS gated on devtools — reads these fields when wiring + // SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings + // overwrites them with values from settings.json (or per-section + // defaults when the file is missing/corrupt). + private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay + = AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default; + private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio + = AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default; + private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay + = AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default; + private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat + = AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default; + private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter + = AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default; + + /// + /// L.0 follow-up: load every section from settings.json + apply the + /// runtime-affecting ones (Display window state + Audio engine + /// volumes) at startup. Runs unconditionally — settings are runtime + /// state, not devtools state. Without this, a user running with + /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions + /// defaults instead of their saved Display/Audio preferences. + /// + private void LoadAndApplyPersistedSettings() + { + _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + _persistedDisplay = _settingsStore.LoadDisplay(); + _persistedAudio = _settingsStore.LoadAudio(); + _persistedGameplay = _settingsStore.LoadGameplay(); + _persistedChat = _settingsStore.LoadChat(); + // _activeToonKey is "default" pre-EnterWorld; the post-login + // branch in BeginLiveSessionAsync swaps to the chosen toon's + // name and re-loads via SettingsVM.LoadCharacterContext. + _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); + + // Apply Display to the Silk.NET window. VSync goes via the + // window property; resolution + fullscreen go through + // ApplyDisplayWindowState which is shared with the on-Save path. + if (_window is not null) + { + if (_window.VSync != _persistedDisplay.VSync) + _window.VSync = _persistedDisplay.VSync; + ApplyDisplayWindowState(_persistedDisplay); + } + + // Apply Audio to the OpenAL engine. Master + Sfx are wired + // through to the engine; Music + Ambient are stored but inert + // until R5 MIDI/ambient-loop engines exist (assigning them is + // harmless — the engine just doesn't read them yet). + if (_audioEngine is not null && _audioEngine.IsAvailable) + { + _audioEngine.MasterVolume = _persistedAudio.Master; + _audioEngine.MusicVolume = _persistedAudio.Music; + _audioEngine.SfxVolume = _persistedAudio.Sfx; + _audioEngine.AmbientVolume = _persistedAudio.Ambient; + } + } + + /// + /// L.0 Display tab: framebuffer-resize handler — update GL viewport + /// + camera aspect when the window is resized (by the user dragging + /// the corner OR by ApplyDisplayWindowState applying a saved + /// Resolution). Without this, the viewport stays pinned at the + /// startup size, producing a small render inside a big window. + /// Also force-resets ImGui panel layout so panels that were + /// previously off the new viewport snap back to default positions. + /// + private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) + { + if (newSize.X <= 0 || newSize.Y <= 0) return; + _gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y); + _cameraController?.SetAspect(newSize.X / (float)newSize.Y); + // Resize is always a force-reset — the alternative ("clamp + // existing positions") would require tracking each panel's + // current pos+size, which ImGuiNET doesn't expose by name. + // Force-reset is acceptable UX because resizing happens rarely + // and the user can always drag panels back where they want. + if (DevToolsEnabled && _imguiBootstrap is not null) + ResetPanelLayout(ImGuiNET.ImGuiCond.Always); + } + + /// + /// L.0 Display tab: position every registered panel to its default + /// landing spot, computed relative to the current window size so + /// the layout adapts to any resolution. Called from: + /// + /// OnFramebufferResize (cond=Always — force-reset on resize). + /// The View → "Reset window layout" menu item (cond=Always). + /// OnLoad after panel registration (cond=FirstUseEver — only + /// applies when imgui.ini has no saved position for that + /// panel; on subsequent launches the saved positions win). + /// + /// + private void ResetPanelLayout(ImGuiNET.ImGuiCond cond) + { + if (_window is null) return; + float w = _window.Size.X; + float h = _window.Size.Y; + // Sane minimums so the math doesn't blow up on a tiny window. + if (w < 480) w = 480; + if (h < 320) h = 320; + + // Panel positions chosen to be classic-MMO discoverable on a + // 1280x720 window: vitals top-left under the menu bar, chat + // bottom-left, debug top-right, settings centered. All sizes + // are reasonable defaults the user can resize from. + SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f), + new System.Numerics.Vector2(220f, 110f), cond); + SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f), + new System.Numerics.Vector2(450f, 300f), cond); + SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f), + new System.Numerics.Vector2(370f, 520f), cond); + SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f), + new System.Numerics.Vector2(700f, 500f), cond); + } + + private static void SetPanelLayout( + string? title, + System.Numerics.Vector2 pos, + System.Numerics.Vector2 size, + ImGuiNET.ImGuiCond cond) + { + if (string.IsNullOrEmpty(title)) return; + // SetWindowPos/SetWindowSize by name work even when the window + // has never been Begin'd — ImGui stores the value for next + // appearance. + ImGuiNET.ImGui.SetWindowPos(title, pos, cond); + ImGuiNET.ImGui.SetWindowSize(title, size, cond); + } + + /// + /// L.0 Display tab: apply the window-state-dependent settings + /// (Resolution + Fullscreen) from a + /// to the live Silk.NET window. Called at startup (with persisted + /// values) and on every Save (with the saved values). Resolution + /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed + /// or unparseable string is silently ignored to avoid crashing the + /// client mid-session. + /// + private void ApplyDisplayWindowState( + AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display) + { + if (_window is null) return; + + // Resolution: parse and resize if changed. + if (TryParseResolution(display.Resolution, out int w, out int h)) + { + if (_window.Size.X != w || _window.Size.Y != h) + _window.Size = new Silk.NET.Maths.Vector2D(w, h); + } + + // Fullscreen: borderless via Silk.NET's WindowState.Fullscreen + // (no exclusive-mode DXGI dance needed). + var desiredState = display.Fullscreen + ? Silk.NET.Windowing.WindowState.Fullscreen + : Silk.NET.Windowing.WindowState.Normal; + if (_window.WindowState != desiredState) + _window.WindowState = desiredState; + } + + private static bool TryParseResolution(string spec, out int width, out int height) + { + width = height = 0; + if (string.IsNullOrWhiteSpace(spec)) return false; + var parts = spec.Split('x', 2); + if (parts.Length != 2) return false; + return int.TryParse(parts[0], out width) + && int.TryParse(parts[1], out height) + && width > 0 + && height > 0; + } // Vitals panel reference cached for the View menu's toggle entry. private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel; diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 1c0cb2c..70696a2 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -235,4 +235,48 @@ public interface IPanelRenderer /// frame the user clicks the item; false otherwise. /// bool MenuItem(string label, string? shortcut = null); + + // -- Tab bar (Settings panel + future tabbed surfaces) --------------- + + /// + /// Open a tab bar inside the current window. Returns true + /// when the bar is visible — only emit + /// calls inside that branch. Always pair with + /// when the call returned true. Retail had + /// tab bars in the Options UIs (gmGameplayOptionsUI etc), so + /// this primitive must be expressible by the future custom + /// retail-look backend. + /// + bool BeginTabBar(string id); + + /// Close the tab bar opened by . + void EndTabBar(); + + /// + /// Begin a single tab inside an open . + /// Returns true when the tab is the currently selected one + /// — only render this tab's content in that branch. Always pair + /// with when the call returned true. + /// + bool BeginTabItem(string label); + + /// Close the tab opened by . + void EndTabItem(); + + /// + /// Render a read-only multi-line text region the user can + /// select with click+drag and copy with Ctrl+C. + /// Matches the typical "click into a textbox to grab text" UX — + /// chat panels, log viewers, etc. use this to make text + /// extractable without the user having to alt-tab + retype. + /// + /// + /// The widget is sized to ; pass + /// (0, 0) for "fill the current content region" semantics + /// (matches ImGui defaults). is the ImGui + /// stable identifier — typically "##chatcopy" or similar + /// hidden-label form. + /// + /// + void TextMultilineReadOnly(string id, string content, Vector2 size); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index f3a4a07..c8ece99 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel // click into another widget. private bool _focusRequested; + // L.0 follow-up: "Copy mode" — when true, render the chat tail as + // a read-only multi-line text widget the user can click+drag to + // select + Ctrl+C to copy. Trades per-line color for selectability; + // user toggles when they want to grab specific text out of the + // log (item names, coordinates, NPC dialogue, etc). + private bool _copyMode; + public ChatPanel(ChatVM vm) { _vm = vm ?? throw new ArgumentNullException(nameof(vm)); @@ -82,6 +90,31 @@ public sealed class ChatPanel : IPanel return; } + // L.0 follow-up: wrap the entire chat panel body in a single + // outer BeginChild so empty-space clicks anywhere in the body + // (Checkbox row, between Separator and input, etc.) are + // absorbed by BeginChild's drag-trap (an InvisibleButton the + // ImGui renderer adds inside every BeginChild). Without this + // wrapper the chat panel was draggable from any empty body + // pixel — only the inner ##chattail area was protected. + if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f))) + { + renderer.EndChild(); + renderer.End(); + return; + } + + // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the + // chat tail rendering swaps to TextMultilineReadOnly so the + // user can mark + Ctrl+C any text. Off (default) preserves the + // colored per-line render with combat highlights. The checkbox + // sits ABOVE the chat tail (not in the footer) so it's always + // visible regardless of scroll position. + bool copyMode = _copyMode; + if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode)) + _copyMode = copyMode; + renderer.Separator(); + // Phase J Tier 3: keep the input field at the bottom of the // window across resizes by reserving footer space and putting // the chat tail in a scrollable child that fills the rest. @@ -95,7 +128,21 @@ public sealed class ChatPanel : IPanel // the plain Text path (visually identical to the I.4 panel). var lines = _vm.RecentLinesDetailed(); - if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) + if (_copyMode) + { + // Copy mode: one big read-only multiline text widget + // holding every visible line, joined with newlines. Loses + // per-line color but lets the user click+drag to select + // arbitrary spans of text + Ctrl+C to copy. Sized to fill + // the available space minus the footer. + string joined = lines.Count == 0 + ? "(no messages yet)" + : string.Join("\n", lines.Select(l => l.Text)); + renderer.TextMultilineReadOnly( + "##chattailcopy", joined, + new System.Numerics.Vector2(0f, -footerHeight)); + } + else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) { if (lines.Count == 0) { @@ -127,7 +174,7 @@ public sealed class ChatPanel : IPanel } _lastRenderedCount = lines.Count; } - renderer.EndChild(); + if (!_copyMode) renderer.EndChild(); // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. @@ -153,6 +200,7 @@ public sealed class ChatPanel : IPanel if (TryHandleClientCommand(trimmed)) { _input = string.Empty; + renderer.EndChild(); // outer ##chatbody renderer.End(); return; } @@ -173,6 +221,7 @@ public sealed class ChatPanel : IPanel _vm.ShowSystemMessage( $"Unknown command: {verb}. Type /help for the list of supported commands."); _input = string.Empty; + renderer.EndChild(); // outer ##chatbody renderer.End(); return; } @@ -192,6 +241,7 @@ public sealed class ChatPanel : IPanel _input = string.Empty; } + renderer.EndChild(); // outer ##chatbody renderer.End(); } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs new file mode 100644 index 0000000..43a1b47 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs @@ -0,0 +1,28 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Audio mixer preferences persisted to settings.json. Drives the +/// existing Phase E.2 OpenAL engine — the host wires these values into +/// OpenAlAudioEngine.MasterVolume / SfxVolume / +/// MusicVolume / AmbientVolume on Save and on startup. +/// +/// +/// Defaults match the engine's hard-coded starting values so a user +/// who never opens the Audio tab gets identical behaviour to the +/// previous env-var-only world. +/// +/// +public sealed record AudioSettings( + float Master, + float Music, + float Sfx, + float Ambient) +{ + /// Values used on first launch. Mirror the engine's + /// constructor-default Volume properties. + public static AudioSettings Default { get; } = new( + Master: 1.0f, + Music: 0.7f, + Sfx: 1.0f, + Ambient: 0.8f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs new file mode 100644 index 0000000..0aa2342 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs @@ -0,0 +1,48 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Per-character preferences persisted to settings.json under +/// character[toonName]. Settings on this tab are scoped to a +/// single toon; switching characters loads a different bag. +/// +/// +/// L.0 scope: local-only. The settings here describe how the +/// client UI behaves for the active toon — they don't yet flow to the +/// server. When server-sync ships, options like +/// would be pushed via the retail Player-Options packet. +/// +/// +/// +/// MVP shape — four settings only. Easy to grow when more per-toon +/// preferences land. Each is value-typed so equality and Cancel-revert +/// behave like the other tabs' records. +/// +/// +public sealed record CharacterSettings( + string DefaultChatChannel, // "Local" / "Allegiance" / "Fellowship" / "General" / etc. + bool AutoAttack, // Tap-to-attack continues swinging until target dies + bool ConfirmSalvage, // Prompt before salvaging valuable items + bool ShowPickupMessages) // "You picked up X" lines in chat +{ + /// Defaults applied to a fresh character (no settings.json + /// entry yet). Conservative — opt-in for AutoAttack, opt-in for + /// confirmation prompts, pickup messages on by default. + public static CharacterSettings Default { get; } = new( + DefaultChatChannel: "Local", + AutoAttack: false, + ConfirmSalvage: true, + ShowPickupMessages: true); + + /// Channel-name presets exposed in the dropdown. Order + /// roughly matches retail's chat-channel routing. + public static System.Collections.Generic.IReadOnlyList AvailableChannels { get; } = new[] + { + "Local", + "Allegiance", + "Fellowship", + "General", + "Trade", + "LFG", + "Roleplay", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs new file mode 100644 index 0000000..74972fc --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -0,0 +1,44 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Chat-related preferences persisted to settings.json. Mixes +/// retail's CharacterOptions2 chat-channel filter bits (Hear*Chat +/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual +/// preferences (font size) that don't have a retail bitfield. +/// See docs/research/named-retail/acclient.h:3451+ for the +/// retail bit values. +/// +/// +/// L.0 scope: local-only like the rest of L.0. The Hear*Chat +/// flags affect client-side display filtering of the existing +/// channels — the server still streams every line; the client decides +/// what to render. Server-sync arrives in a later phase that flips the +/// retail-faithful "tell server which channels I'm subscribed to" +/// switch. +/// +/// +public sealed record ChatSettings( + // CharacterOptions2 (32-bit) channel filters. + bool HearGeneralChat, // 0x100 — General channel + bool HearTradeChat, // 0x200 — Trade channel + bool HearLFGChat, // 0x400 — LFG channel + bool HearRoleplayChat, // 0x800 — RP channel + bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB) + bool AppearOffline, // 0x1000 — hide /who status + bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines + bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter) + // Visual / UX (no retail bitfield). + float FontSize) // chat panel font, 10..20 pt +{ + /// Sensible starting values matching the retail "all on" stance. + public static ChatSettings Default { get; } = new( + HearGeneralChat: true, + HearTradeChat: true, + HearLFGChat: true, + HearRoleplayChat: true, + HearSocietyChat: true, + AppearOffline: false, + ShowTimestamps: true, + FilterProfanity: true, + FontSize: 12f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs new file mode 100644 index 0000000..05438b0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Display-related preferences persisted to settings.json. +/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces +/// the various ACDREAM_* environment variables for resolution + +/// windowed mode with an in-game UI. +/// +/// +/// Records are immutable; mutation goes through +/// which assigns a new instance via +/// with-expressions. +/// +/// +public sealed record DisplaySettings( + string Resolution, + bool Fullscreen, + bool VSync, + float FieldOfView, + float Gamma, + bool ShowFps) +{ + /// Values used on first launch / when settings.json is absent. + /// All defaults pinned to the pre-L.0 runtime state — Resolution + /// matches the WindowOptions startup size (1280×720), FieldOfView + /// matches camera FovY (60°), VSync matches WindowOptions (false), + /// ShowFps preserves the perf string in the title bar. Net effect: + /// opening Display + Save without touching anything is a complete + /// visual no-op. + public static DisplaySettings Default { get; } = new( + Resolution: "1280x720", + Fullscreen: false, + VSync: false, + FieldOfView: 60f, + Gamma: 1.0f, + ShowFps: true); + + /// 16:9 resolution presets offered in the dropdown. + public static IReadOnlyList AvailableResolutions { get; } = new[] + { + "1280x720", + "1366x768", + "1600x900", + "1920x1080", + "2560x1440", + "3840x2160", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs new file mode 100644 index 0000000..4b1d43e --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -0,0 +1,61 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Gameplay-related preferences persisted to settings.json. +/// Mirrors a subset of retail's CharacterOption + CharacterOptions2 +/// bitfield flags (see docs/research/named-retail/acclient.h:3404+). +/// Retail names are kept verbatim so future server-sync packs these +/// into the wire-format bitmask without renaming. +/// +/// +/// L.0 scope: local-only. The brainstorm explicitly deferred +/// server sync — on Save these values are persisted to settings.json +/// only. A later phase will marshal them into the retail +/// CharacterOption packet (0x...) when the protocol work +/// for player-options round-trip is in place. +/// +/// +/// +/// Defaults below are chosen as the typical-user starting point, NOT +/// pinned bit-exact to retail's 0x50C4A54A / 0x948700 +/// masks (those will become the defaults once server-sync ships and +/// the bitmask round-trip is the load-bearing wire format). +/// +/// +public sealed record GameplaySettings( + // CharacterOption (32-bit) subset — most-used gameplay toggles. + bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack + bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit + bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run + bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels + bool ShowTooltips, // 0x100 — show item tooltips on hover + bool VividTargetingIndicator, // 0x8000 — bright targeting reticle + bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked + bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar + bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons + bool AllowGive, // 0x40 — accept items handed by other players + // CharacterOptions2 (32-bit) subset. + bool ShowHelm, // 0x100000 — render helm overlay on character + bool ShowCloak, // 0x800000 — render cloak on character + bool LockUI, // 0x1000000 — disable panel drag/resize + bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags +{ + /// Sensible starting values for first launch. NOT bit-exact + /// to retail's Default_CharacterOption = 0x50C4A54A + + /// Default_CharacterOptions2 = 0x948700 — see class remarks. + public static GameplaySettings Default { get; } = new( + AutoTarget: true, + AutoRepeatAttack: true, + ToggleRun: true, + AdvancedCombatUI: false, + ShowTooltips: true, + VividTargetingIndicator: false, + SideBySideVitals: false, + CoordinatesOnRadar: false, + SpellDuration: true, + AllowGive: true, + ShowHelm: true, + ShowCloak: true, + LockUI: false, + UseMouseTurning: false); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 841b394..a8a8034 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// -/// K.3: in-game Settings panel for click-to-rebind keymap editing. -/// Hidden by default; opens via F11 (which fires the -/// action) or via the -/// View → Settings entry on the main menu bar. +/// In-game Settings panel — F11 toggle (or View → Settings on the main +/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then +/// Display / Audio / Gameplay / Chat / Character (filling in over the +/// L.x sub-phases). /// /// -/// Layout: top row of action buttons (Save / Cancel / Reset all), then -/// a sequence of sections -/// matching the retail keymap categories (Movement / Postures / Camera / -/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a -/// section: action name, current binding(s) summary, "Rebind" button, -/// per-action "Reset" button. When a rebind is in progress the Rebind -/// button label changes to "Press a key... (Esc to cancel)". +/// Top of the panel: Save / Cancel / Reset-all action buttons (global +/// across all tabs). When is +/// non-null, a confirmation prompt is rendered above those buttons +/// (Yes — Reassign / No — Keep existing). /// /// /// -/// When is non-null, a -/// confirmation prompt is rendered ABOVE the rest of the panel (Yes — -/// Reassign / No — Keep existing). +/// Below the action row a tab bar selects between the six categories. +/// Only the Keybinds tab is implemented today; the other five render +/// "Coming soon" placeholders so the structure the user approved in the +/// design brainstorm is visible immediately. /// /// public sealed class SettingsPanel : IPanel @@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel public string Title => "Settings"; /// - /// K.3: hidden by default — opened via F11 / View menu. + /// Hidden by default — opened via F11 / View menu. public bool IsVisible { get; set; } = false; /// @@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); } - // Top action buttons. + // Top action buttons. Global across all tabs. if (renderer.Button("Save changes")) _vm.Save(); renderer.SameLine(); if (renderer.Button("Cancel changes")) _vm.Cancel(); @@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); - // Sections (retail keymap categories). + if (renderer.BeginTabBar("settings.tabs")) + { + if (renderer.BeginTabItem("Keybinds")) + { + RenderKeybindsTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Display")) + { + RenderDisplayTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Audio")) + { + RenderAudioTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Gameplay")) + { + RenderGameplayTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Chat")) + { + RenderChatTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Character")) + { + RenderCharacterTab(renderer); + renderer.EndTabItem(); + } + renderer.EndTabBar(); + } + + renderer.End(); + } + + /// + /// Render the Keybinds tab — eight collapsing-header sections matching + /// the retail keymap categories. Phase K shipped this content; the + /// only thing that changed is the wrapping tab item. + /// + private void RenderKeybindsTab(IPanelRenderer renderer) + { RenderSection(renderer, "Movement", new[] { InputAction.MovementForward, InputAction.MovementBackup, @@ -136,8 +178,272 @@ public sealed class SettingsPanel : IPanel InputAction.Cry, InputAction.Laugh, InputAction.Wave, InputAction.Cheer, InputAction.PointState, }); + } - renderer.End(); + /// + /// Render the Display tab — resolution / fullscreen / vsync / + /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; + /// the others apply on Save (matches the brainstorm UX agreement — + /// resolution change live would be too jarring). + /// + private void RenderDisplayTab(IPanelRenderer renderer) + { + var d = _vm.DisplayDraft; + + // Resolution dropdown. Index falls back to the highest available + // option when the persisted resolution isn't one of the presets + // (e.g. user hand-edited settings.json with a non-standard size). + var resolutions = DisplaySettings.AvailableResolutions.ToArray(); + int idx = System.Array.IndexOf(resolutions, d.Resolution); + if (idx < 0) idx = resolutions.Length - 1; + if (renderer.Combo("Resolution", ref idx, resolutions)) + _vm.SetDisplay(d with { Resolution = resolutions[idx] }); + + bool fullscreen = d.Fullscreen; + if (renderer.Checkbox("Fullscreen", ref fullscreen)) + _vm.SetDisplay(d with { Fullscreen = fullscreen }); + + bool vsync = d.VSync; + if (renderer.Checkbox("V-Sync", ref vsync)) + _vm.SetDisplay(d with { VSync = vsync }); + + float fov = d.FieldOfView; + if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f)) + _vm.SetDisplay(d with { FieldOfView = fov }); + + float gamma = d.Gamma; + if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f)) + _vm.SetDisplay(d with { Gamma = gamma }); + + bool showFps = d.ShowFps; + if (renderer.Checkbox("Show FPS", ref showFps)) + _vm.SetDisplay(d with { ShowFps = showFps }); + + renderer.Spacing(); + renderer.TextWrapped( + "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " + + "preview live as you drag; Cancel reverts to the saved value."); + } + + /// + /// Render the Audio tab — Master + SFX volume sliders (live preview + /// against the running OpenAL engine). Music + Ambient fields exist + /// in and persist round-trip, but their + /// sliders are intentionally hidden here because the underlying + /// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI + /// playback that hasn't shipped yet — exposing the sliders would be + /// "moving a knob that does nothing." When R5 lands, restore the + /// hidden sliders below and the JSON-persisted values will already + /// be in place. + /// + private void RenderAudioTab(IPanelRenderer renderer) + { + var a = _vm.AudioDraft; + + float master = a.Master; + if (renderer.SliderFloat("Master", ref master, 0f, 1f)) + _vm.SetAudio(a with { Master = master }); + + float sfx = a.Sfx; + if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) + _vm.SetAudio(a with { Sfx = sfx }); + + // Music + Ambient hidden until R5 MIDI / ambient-loop engines + // exist. AudioSettings still carries the fields so the JSON + // round-trips and a future client doesn't drop them on save. + // + // float music = a.Music; + // if (renderer.SliderFloat("Music", ref music, 0f, 1f)) + // _vm.SetAudio(a with { Music = music }); + // float ambient = a.Ambient; + // if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f)) + // _vm.SetAudio(a with { Ambient = ambient }); + + renderer.Spacing(); + renderer.TextWrapped( + "Volume changes preview live as you drag. Save persists the " + + "values to settings.json; Cancel reverts to the saved values. " + + "Music + Ambient mixing arrives with R5 MIDI playback."); + } + + /// + /// Render the Gameplay tab — ~14 toggles ported from retail's + /// CharacterOption + CharacterOptions2 bitfields. Local-only this + /// phase (no server sync). Grouped into Combat / Display / Interface + /// for first-run discoverability. + /// + private void RenderGameplayTab(IPanelRenderer renderer) + { + var g = _vm.GameplayDraft; + + renderer.Text("Combat"); + renderer.Separator(); + + bool autoTarget = g.AutoTarget; + if (renderer.Checkbox("Auto-target on attack", ref autoTarget)) + _vm.SetGameplay(g with { AutoTarget = autoTarget }); + + bool autoRepeat = g.AutoRepeatAttack; + if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat)) + _vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat }); + + bool toggleRun = g.ToggleRun; + if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun)) + _vm.SetGameplay(g with { ToggleRun = toggleRun }); + + bool advCombat = g.AdvancedCombatUI; + if (renderer.Checkbox("Show advanced combat UI", ref advCombat)) + _vm.SetGameplay(g with { AdvancedCombatUI = advCombat }); + + bool vivid = g.VividTargetingIndicator; + if (renderer.Checkbox("Vivid targeting indicator", ref vivid)) + _vm.SetGameplay(g with { VividTargetingIndicator = vivid }); + + renderer.Spacing(); + renderer.Text("Display"); + renderer.Separator(); + + bool tooltips = g.ShowTooltips; + if (renderer.Checkbox("Show item tooltips", ref tooltips)) + _vm.SetGameplay(g with { ShowTooltips = tooltips }); + + bool sideBySide = g.SideBySideVitals; + if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide)) + _vm.SetGameplay(g with { SideBySideVitals = sideBySide }); + + bool coords = g.CoordinatesOnRadar; + if (renderer.Checkbox("Show coordinates on radar", ref coords)) + _vm.SetGameplay(g with { CoordinatesOnRadar = coords }); + + bool spellDur = g.SpellDuration; + if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur)) + _vm.SetGameplay(g with { SpellDuration = spellDur }); + + bool helm = g.ShowHelm; + if (renderer.Checkbox("Show helm on character", ref helm)) + _vm.SetGameplay(g with { ShowHelm = helm }); + + bool cloak = g.ShowCloak; + if (renderer.Checkbox("Show cloak on character", ref cloak)) + _vm.SetGameplay(g with { ShowCloak = cloak }); + + renderer.Spacing(); + renderer.Text("Interface"); + renderer.Separator(); + + bool allowGive = g.AllowGive; + if (renderer.Checkbox("Accept items handed by other players", ref allowGive)) + _vm.SetGameplay(g with { AllowGive = allowGive }); + + bool lockUI = g.LockUI; + if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI)) + _vm.SetGameplay(g with { LockUI = lockUI }); + + bool mouseTurn = g.UseMouseTurning; + if (renderer.Checkbox("Use mouse turning", ref mouseTurn)) + _vm.SetGameplay(g with { UseMouseTurning = mouseTurn }); + + renderer.Spacing(); + renderer.TextWrapped( + "Local-only this phase — values persist to settings.json but " + + "don't yet sync to the server. Server sync arrives in a " + + "follow-up phase."); + } + + /// + /// Render the Chat tab — channel filters (Hear*Chat), display + /// preferences (timestamps / profanity filter / appear offline), + /// and a font-size slider. Channel filters affect client-side + /// display only this phase — the server still sends every line, + /// the client decides what to render. + /// + private void RenderChatTab(IPanelRenderer renderer) + { + var c = _vm.ChatDraft; + + renderer.Text("Channel filters"); + renderer.Separator(); + + bool general = c.HearGeneralChat; + if (renderer.Checkbox("General", ref general)) + _vm.SetChat(c with { HearGeneralChat = general }); + + bool trade = c.HearTradeChat; + if (renderer.Checkbox("Trade", ref trade)) + _vm.SetChat(c with { HearTradeChat = trade }); + + bool lfg = c.HearLFGChat; + if (renderer.Checkbox("LFG (looking for group)", ref lfg)) + _vm.SetChat(c with { HearLFGChat = lfg }); + + bool rp = c.HearRoleplayChat; + if (renderer.Checkbox("Roleplay", ref rp)) + _vm.SetChat(c with { HearRoleplayChat = rp }); + + bool society = c.HearSocietyChat; + if (renderer.Checkbox("Society (CD / EW / RB)", ref society)) + _vm.SetChat(c with { HearSocietyChat = society }); + + renderer.Spacing(); + renderer.Text("Display"); + renderer.Separator(); + + bool timestamps = c.ShowTimestamps; + if (renderer.Checkbox("Show timestamps", ref timestamps)) + _vm.SetChat(c with { ShowTimestamps = timestamps }); + + bool profanity = c.FilterProfanity; + if (renderer.Checkbox("Filter profanity", ref profanity)) + _vm.SetChat(c with { FilterProfanity = profanity }); + + bool offline = c.AppearOffline; + if (renderer.Checkbox("Appear offline (hide from /who)", ref offline)) + _vm.SetChat(c with { AppearOffline = offline }); + + float fontSize = c.FontSize; + if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f)) + _vm.SetChat(c with { FontSize = fontSize }); + + renderer.Spacing(); + renderer.TextWrapped( + "Channel filters hide messages from the chat window without " + + "changing your server-side subscriptions. Save persists; " + + "Cancel reverts."); + } + + /// + /// Render the Character tab — per-toon preferences. The host owns + /// the toon-name key; the panel just edits whatever bag the host + /// loaded into . + /// + private void RenderCharacterTab(IPanelRenderer renderer) + { + var c = _vm.CharacterDraft; + + var channels = CharacterSettings.AvailableChannels.ToArray(); + int idx = System.Array.IndexOf(channels, c.DefaultChatChannel); + if (idx < 0) idx = 0; + if (renderer.Combo("Default chat channel", ref idx, channels)) + _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] }); + + bool autoAttack = c.AutoAttack; + if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack)) + _vm.SetCharacter(c with { AutoAttack = autoAttack }); + + bool confirmSalvage = c.ConfirmSalvage; + if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage)) + _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage }); + + bool pickup = c.ShowPickupMessages; + if (renderer.Checkbox("Show pickup messages in chat", ref pickup)) + _vm.SetCharacter(c with { ShowPickupMessages = pickup }); + + renderer.Spacing(); + renderer.TextWrapped( + "Per-character preferences — saved per toon under " + + "settings.json's character[\"\"]. Local-only this " + + "phase; server-sync arrives later when the protocol " + + "round-trip lands."); } private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs new file mode 100644 index 0000000..11264fc --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// JSON-backed persistence for non-keybind settings (Display today; future +/// tabs Audio / Gameplay / Chat / Character will be added to the same +/// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists +/// with keybinds.json, which retains its own +/// path. +/// +/// +/// Schema (current version 1): +/// +/// { +/// "version": 1, +/// "display": { "resolution": "1920x1080", "fullscreen": false, ... } +/// } +/// +/// Unknown top-level keys are preserved on save so future tab additions +/// from a newer client don't get clobbered by an older client writing +/// out only the sections it knows about. +/// +/// +public sealed class SettingsStore +{ + private const int CurrentSchemaVersion = 1; + private readonly string _path; + + public SettingsStore(string path) + { + _path = path ?? throw new ArgumentNullException(nameof(path)); + } + + /// Default path: %LOCALAPPDATA%\acdream\settings.json. + public static string DefaultPath() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "acdream", + "settings.json"); + + /// + /// Load Display settings. Missing file → . + /// Missing individual keys fall back to the corresponding default + /// field, so a partial file (e.g. only resolution is set) is + /// non-fatal. + /// + public DisplaySettings LoadDisplay() + { + if (!File.Exists(_path)) return DisplaySettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("display", out var disp) + || disp.ValueKind != JsonValueKind.Object) + return DisplaySettings.Default; + + var d = DisplaySettings.Default; + return new DisplaySettings( + Resolution: ReadString (disp, "resolution", d.Resolution), + Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), + VSync: ReadBool (disp, "vsync", d.VSync), + FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), + Gamma: ReadFloat (disp, "gamma", d.Gamma), + ShowFps: ReadBool (disp, "showFps", d.ShowFps)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return DisplaySettings.Default; + } + } + + /// + /// Save Display settings, preserving any other top-level keys the file + /// already contains (e.g. an audio section written by a newer + /// client). Unknown keys are round-tripped via raw JSON text so older + /// builds don't silently drop them. + /// + public void SaveDisplay(DisplaySettings display) + => SaveSection("display", BuildDisplayObject(display)); + + /// + /// Load Audio settings. Same fall-back behaviour as + /// : missing file → defaults, missing fields + /// → per-field defaults, corrupt JSON → defaults. + /// + public AudioSettings LoadAudio() + { + if (!File.Exists(_path)) return AudioSettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("audio", out var audio) + || audio.ValueKind != JsonValueKind.Object) + return AudioSettings.Default; + + var d = AudioSettings.Default; + return new AudioSettings( + Master: ReadFloat(audio, "master", d.Master), + Music: ReadFloat(audio, "music", d.Music), + Sfx: ReadFloat(audio, "sfx", d.Sfx), + Ambient: ReadFloat(audio, "ambient", d.Ambient)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return AudioSettings.Default; + } + } + + /// + /// Save Audio settings, preserving every other top-level key + /// (display, future gameplay/chat/character). Same round-trip + /// guarantee as . + /// + public void SaveAudio(AudioSettings audio) + => SaveSection("audio", BuildAudioObject(audio)); + + /// + /// Load Gameplay settings (subset of retail CharacterOption flags). + /// Same fall-back behaviour as . + /// + public GameplaySettings LoadGameplay() + { + if (!File.Exists(_path)) return GameplaySettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("gameplay", out var gp) + || gp.ValueKind != JsonValueKind.Object) + return GameplaySettings.Default; + + var d = GameplaySettings.Default; + return new GameplaySettings( + AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget), + AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack), + ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun), + AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI), + ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips), + VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator), + SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals), + CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar), + SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration), + AllowGive: ReadBool(gp, "allowGive", d.AllowGive), + ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm), + ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak), + LockUI: ReadBool(gp, "lockUI", d.LockUI), + UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return GameplaySettings.Default; + } + } + + /// Save Gameplay settings, preserving all other top-level keys. + public void SaveGameplay(GameplaySettings gameplay) + => SaveSection("gameplay", BuildGameplayObject(gameplay)); + + /// Load Chat settings. Same fall-back behaviour as . + public ChatSettings LoadChat() + { + if (!File.Exists(_path)) return ChatSettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("chat", out var chat) + || chat.ValueKind != JsonValueKind.Object) + return ChatSettings.Default; + + var d = ChatSettings.Default; + return new ChatSettings( + HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat), + HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat), + HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat), + HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat), + HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat), + AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline), + ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps), + FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity), + FontSize: ReadFloat(chat, "fontSize", d.FontSize)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return ChatSettings.Default; + } + } + + /// Save Chat settings, preserving all other top-level keys. + public void SaveChat(ChatSettings chat) + => SaveSection("chat", BuildChatObject(chat)); + + /// + /// Load per-character settings keyed by . + /// Missing file or missing toon entry → . + /// + public CharacterSettings LoadCharacter(string toonKey) + { + if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); + if (!File.Exists(_path)) return CharacterSettings.Default; + try + { + var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; + var toon = root?["character"]?[toonKey] as JsonObject; + if (toon is null) return CharacterSettings.Default; + + var d = CharacterSettings.Default; + return new CharacterSettings( + DefaultChatChannel: toon["defaultChatChannel"]?.GetValue() ?? d.DefaultChatChannel, + AutoAttack: toon["autoAttack"]?.GetValue() ?? d.AutoAttack, + ConfirmSalvage: toon["confirmSalvage"]?.GetValue() ?? d.ConfirmSalvage, + ShowPickupMessages: toon["showPickupMessages"]?.GetValue() ?? d.ShowPickupMessages); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return CharacterSettings.Default; + } + } + + /// + /// Save per-character settings under . + /// Preserves every other toon's settings + every other top-level + /// section. Uses rather than the raw-text + /// preservation pattern of because the + /// per-toon write needs to mutate a nested map, not just replace a + /// top-level key. + /// + public void SaveCharacter(string toonKey, CharacterSettings settings) + { + if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); + if (settings is null) throw new ArgumentNullException(nameof(settings)); + + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // Read existing file as a mutable JsonObject (or start fresh). + JsonObject root; + if (File.Exists(_path)) + { + try + { + root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); + } + catch + { + root = new JsonObject(); + } + } + else + { + root = new JsonObject(); + } + + // Build the toon's payload. + var toonObj = new JsonObject + { + ["autoAttack"] = settings.AutoAttack, + ["confirmSalvage"] = settings.ConfirmSalvage, + ["defaultChatChannel"] = settings.DefaultChatChannel, + ["showPickupMessages"] = settings.ShowPickupMessages, + }; + + // Slot it under character[toonKey], creating the character map if + // necessary. Other toons in the map are preserved. + if (root["character"] is not JsonObject characterMap) + { + characterMap = new JsonObject(); + root["character"] = characterMap; + } + characterMap[toonKey] = toonObj; + root["version"] = CurrentSchemaVersion; + + File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private static SortedDictionary BuildChatObject(ChatSettings c) + => new(StringComparer.Ordinal) + { + ["appearOffline"] = c.AppearOffline, + ["filterProfanity"] = c.FilterProfanity, + ["fontSize"] = c.FontSize, + ["hearGeneralChat"] = c.HearGeneralChat, + ["hearLFGChat"] = c.HearLFGChat, + ["hearRoleplayChat"] = c.HearRoleplayChat, + ["hearSocietyChat"] = c.HearSocietyChat, + ["hearTradeChat"] = c.HearTradeChat, + ["showTimestamps"] = c.ShowTimestamps, + }; + + private static SortedDictionary BuildGameplayObject(GameplaySettings g) + => new(StringComparer.Ordinal) + { + ["advancedCombatUI"] = g.AdvancedCombatUI, + ["allowGive"] = g.AllowGive, + ["autoRepeatAttack"] = g.AutoRepeatAttack, + ["autoTarget"] = g.AutoTarget, + ["coordinatesOnRadar"] = g.CoordinatesOnRadar, + ["lockUI"] = g.LockUI, + ["showCloak"] = g.ShowCloak, + ["showHelm"] = g.ShowHelm, + ["showTooltips"] = g.ShowTooltips, + ["sideBySideVitals"] = g.SideBySideVitals, + ["spellDuration"] = g.SpellDuration, + ["toggleRun"] = g.ToggleRun, + ["useMouseTurning"] = g.UseMouseTurning, + ["vividTargetingIndicator"] = g.VividTargetingIndicator, + }; + + private static SortedDictionary BuildDisplayObject(DisplaySettings d) + => new(StringComparer.Ordinal) + { + ["fieldOfView"] = d.FieldOfView, + ["fullscreen"] = d.Fullscreen, + ["gamma"] = d.Gamma, + ["resolution"] = d.Resolution, + ["showFps"] = d.ShowFps, + ["vsync"] = d.VSync, + }; + + private static SortedDictionary BuildAudioObject(AudioSettings a) + => new(StringComparer.Ordinal) + { + ["ambient"] = a.Ambient, + ["master"] = a.Master, + ["music"] = a.Music, + ["sfx"] = a.Sfx, + }; + + /// + /// Generic atomic-section save: writes the named section and preserves + /// all other top-level keys from the existing file, replacing only the + /// version + the targeted section. Avoids duplication between the + /// per-section Save methods. + /// + private void SaveSection(string sectionName, SortedDictionary sectionPayload) + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // Preserve any non-target top-level keys from the existing file. + var preservedKeys = new SortedDictionary(StringComparer.Ordinal); + if (File.Exists(_path)) + { + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Name == sectionName || prop.Name == "version") continue; + preservedKeys[prop.Name] = prop.Value.GetRawText(); + } + } + catch + { + // Corrupt file → fully overwrite; previous content is lost + // but the user's session continues with the new save. + preservedKeys.Clear(); + } + } + + var sb = new System.Text.StringBuilder(); + sb.Append('{').AppendLine(); + // Preserved keys come first (sorted by name) then the section, then + // version last. Preserves alphabetical-style top-level ordering. + foreach (var kv in preservedKeys) + { + sb.Append(" \"").Append(kv.Key).Append("\": ") + .Append(kv.Value).Append(',').AppendLine(); + } + sb.Append(" \"").Append(sectionName).Append("\": ") + .Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true }) + .Replace("\n", "\n ")) + .Append(',').AppendLine(); + sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); + sb.Append('}').AppendLine(); + + File.WriteAllText(_path, sb.ToString()); + } + + private static string ReadString(JsonElement obj, string name, string fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String + ? (el.GetString() ?? fallback) : fallback; + + private static bool ReadBool(JsonElement obj, string name, bool fallback) + => obj.TryGetProperty(name, out var el) + && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) + ? el.GetBoolean() : fallback; + + private static float ReadFloat(JsonElement obj, string name, float fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number + ? el.GetSingle() : fallback; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 5d33480..f4bcc36 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -30,6 +30,32 @@ public sealed class SettingsVM private readonly InputDispatcher _dispatcher; private readonly Action _onSave; + // L.0 — Display tab. Treated as a single immutable record; mutation + // through SetDisplay clones via with-expressions on the panel side. + private DisplaySettings _displayPersisted; + private DisplaySettings _displayDraft; + private readonly Action _onSaveDisplay; + + // L.0 — Audio tab. Same shape as Display. + private AudioSettings _audioPersisted; + private AudioSettings _audioDraft; + private readonly Action _onSaveAudio; + + // L.0 — Gameplay tab (subset of retail CharacterOption flags). + private GameplaySettings _gameplayPersisted; + private GameplaySettings _gameplayDraft; + private readonly Action _onSaveGameplay; + + // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs). + private ChatSettings _chatPersisted; + private ChatSettings _chatDraft; + private readonly Action _onSaveChat; + + // L.0 — Character tab (per-toon, host-keyed by toon name). + private CharacterSettings _characterPersisted; + private CharacterSettings _characterDraft; + private readonly Action _onSaveCharacter; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -50,14 +76,139 @@ public sealed class SettingsVM /// True iff the draft differs structurally from the /// persisted snapshot. Used to grey out the Save button when no /// rebinds are pending. - public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft); + public bool HasUnsavedChanges + => !KeyBindingsEqual(_persisted, _draft) + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft + || _gameplayPersisted != _gameplayDraft + || _chatPersisted != _chatDraft + || _characterPersisted != _characterDraft; - public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave) + /// The current Display draft. Panel reads from here; + /// mutation goes through . + public DisplaySettings DisplayDraft => _displayDraft; + + /// The current Audio draft. Panel reads from here; + /// mutation goes through . + public AudioSettings AudioDraft => _audioDraft; + + /// The current Gameplay draft. Panel reads from here; + /// mutation goes through . + public GameplaySettings GameplayDraft => _gameplayDraft; + + /// The current Chat draft. Panel reads from here; + /// mutation goes through . + public ChatSettings ChatDraft => _chatDraft; + + /// The current Character draft (per-toon — host owns the + /// toon-name key). Panel reads from here; mutation goes through + /// . + public CharacterSettings CharacterDraft => _characterDraft; + + public SettingsVM( + KeyBindings persisted, + InputDispatcher dispatcher, + Action onSave, + DisplaySettings persistedDisplay, + Action onSaveDisplay, + AudioSettings persistedAudio, + Action onSaveAudio, + GameplaySettings persistedGameplay, + Action onSaveGameplay, + ChatSettings persistedChat, + Action onSaveChat, + CharacterSettings persistedCharacter, + Action onSaveCharacter) { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _draft = CloneBindings(persisted); + _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); + _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); + _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); + _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio)); + _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); + _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay)); + _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay)); + _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat)); + _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat)); + _characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter)); + _onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; + _gameplayDraft = persistedGameplay; + _chatDraft = persistedChat; + _characterDraft = persistedCharacter; + } + + /// + /// Replace the entire Display draft with . + /// Panel calls this with a DisplayDraft with { Field = newValue } + /// so each widget edits exactly one field at a time. + /// + public void SetDisplay(DisplaySettings value) + { + _displayDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Replace the entire Audio draft with . + /// Live audio preview is achieved at the host layer by pushing + /// into the running OpenAL engine each frame + /// — this method only mutates VM state. Cancel reverts the draft and + /// the host's next-frame push restores the pre-edit engine volumes. + /// + public void SetAudio(AudioSettings value) + { + _audioDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Replace the entire Gameplay draft with . + /// Local-only this phase — values persist on Save but don't yet + /// flow to the server. When server-sync ships, the host's + /// onSaveGameplay callback will marshal the draft into the + /// retail CharacterOption wire bitmask. + /// + public void SetGameplay(GameplaySettings value) + { + _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Replace the entire Chat draft with . + /// Local-only this phase — values persist on Save but the Hear*Chat + /// flags affect client-side display filtering, not server-side + /// channel subscriptions. + /// + public void SetChat(ChatSettings value) + { + _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Replace the entire Character draft with . + /// Per-toon — the host knows which toon's bag we're editing because + /// it owned the toonKey when constructing the VM. + /// + public void SetCharacter(CharacterSettings value) + { + _characterDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Replace BOTH the persisted snapshot and the live draft for the + /// Character bag. Used when the active toon changes (e.g. on + /// EnterWorld with a non-default character) — the host loads that + /// toon's settings from disk and pushes them into the VM here so + /// doesn't flag the swap as a + /// pending edit. Differs from , which + /// updates draft only. + /// + public void LoadCharacterContext(CharacterSettings persisted) + { + _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _characterDraft = persisted; } /// @@ -160,32 +311,58 @@ public sealed class SettingsVM } /// - /// Replace the entire draft with . + /// Replace the keybinds draft with + /// AND the display draft with . + /// "Reset all" applies to every tab — it's the user's escape hatch + /// when they've gotten lost. /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; + _gameplayDraft = GameplaySettings.Default; + _chatDraft = ChatSettings.Default; + _characterDraft = CharacterSettings.Default; } /// - /// Commit the draft via the onSave callback supplied at - /// construction. After save the draft becomes the new persisted - /// snapshot — resets to false. + /// Commit both keybinds + display drafts via the onSave callbacks + /// supplied at construction. After save the drafts become the new + /// persisted snapshots — resets to + /// false. Each callback is invoked exactly once per Save; if the + /// caller wants atomicity across both files it has to handle it + /// outside the VM. /// public void Save() { _onSave(_draft); - _persisted = CloneBindings(_draft); + _onSaveDisplay(_displayDraft); + _onSaveAudio(_audioDraft); + _onSaveGameplay(_gameplayDraft); + _onSaveChat(_chatDraft); + _onSaveCharacter(_characterDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; + _gameplayPersisted = _gameplayDraft; + _chatPersisted = _chatDraft; + _characterPersisted = _characterDraft; } /// - /// Revert the draft to the persisted snapshot and clear any + /// Revert all drafts to their persisted snapshots and clear any /// in-flight rebind state. Used by the panel's "Cancel" button and /// when the user closes the settings window without saving. /// public void Cancel() { - _draft = CloneBindings(_persisted); + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; + _gameplayDraft = _gameplayPersisted; + _chatDraft = _chatPersisted; + _characterDraft = _characterPersisted; CancelRebind(); } diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index ec00037..4aa94ae 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -155,12 +155,41 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// public bool BeginChild(string id, Vector2 size, bool border = false) + { // ImGuiChildFlags has changed names across ImGui.NET versions // (Border vs Borders); 0x01 is the stable bit value for "draw // a border". Casting from a numeric literal sidesteps the // version-skew without requiring a hard reference to either // enum spelling. - => ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); + bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); + if (open) + { + // Title-bar-only drag fix (chat tail specifically): empty + // clicks inside a scrollable child fall through to the + // parent window for drag-init, which is exactly what the + // user reported in the chat panel ("clicking anywhere + // moves the window"). An InvisibleButton sized to the + // child's content region absorbs those clicks so they + // don't propagate. Real widgets drawn afterwards still + // claim their own clicks (click priority = "last drawn, + // first checked"). Wheel scrolling is window-level, not + // item-level, so the absorber doesn't interfere with + // the chat tail's auto-scroll. + // + // Scoped to BeginChild only (NOT Begin) because Begin's + // body might host tab bars whose hit-testing competes with + // an absorber on equal terms — adding it at Begin level + // broke Settings tab clicks. + var avail = ImGuiNET.ImGui.GetContentRegionAvail(); + if (avail.X > 0f && avail.Y > 0f) + { + var savedCursor = ImGuiNET.ImGui.GetCursorPos(); + ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail); + ImGuiNET.ImGui.SetCursorPos(savedCursor); + } + } + return open; + } /// public void EndChild() => ImGuiNET.ImGui.EndChild(); @@ -193,4 +222,35 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer => shortcut is null ? ImGuiNET.ImGui.MenuItem(label) : ImGuiNET.ImGui.MenuItem(label, shortcut); + + // -- Tab bar ----------------------------------------------------------- + + /// + public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id); + + /// + public void EndTabBar() => ImGuiNET.ImGui.EndTabBar(); + + /// + public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label); + + /// + public void EndTabItem() => ImGuiNET.ImGui.EndTabItem(); + + // -- Selectable / copyable text --------------------------------------- + + /// + public void TextMultilineReadOnly(string id, string content, Vector2 size) + { + // ImGui's InputTextMultiline takes a `ref string` even with the + // ReadOnly flag — we just hand it a local copy. maxLength caps + // what the user could type if ReadOnly were ever cleared; we + // size it to the current content (+1 for ImGui's internal NUL + // terminator in some bindings). Min of 1 keeps the empty case + // from confusing native bindings. + string buffer = content; + uint maxLen = (uint)System.Math.Max(content.Length + 1, 1); + ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size, + ImGuiInputTextFlags.ReadOnly); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index f1c8c4d..9df8b12 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -198,4 +198,40 @@ internal sealed class FakePanelRenderer : IPanelRenderer Calls.Add(("MenuItem", new object?[] { label, shortcut })); return MenuItemReturns; } + + // -- Tab bar ----------------------------------------------------------- + + /// Pre-set return for . + public bool TabBarReturns { get; set; } = true; + + /// The label of the tab the next + /// call should report as "selected" (return true). All other tab + /// items return false. Defaults to null = the FIRST tab item rendered + /// is the selected one. + public string? ActiveTabLabel { get; set; } + + private string? _firstTabSeen; + + public bool BeginTabBar(string id) + { + Calls.Add(("BeginTabBar", new object?[] { id })); + _firstTabSeen = null; + return TabBarReturns; + } + + public void EndTabBar() => Calls.Add(("EndTabBar", Array.Empty())); + + public bool BeginTabItem(string label) + { + Calls.Add(("BeginTabItem", new object?[] { label })); + _firstTabSeen ??= label; + return ActiveTabLabel is null + ? string.Equals(label, _firstTabSeen, StringComparison.Ordinal) + : string.Equals(label, ActiveTabLabel, StringComparison.Ordinal); + } + + public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty())); + + public void TextMultilineReadOnly(string id, string content, Vector2 size) + => Calls.Add(("TextMultilineReadOnly", new object?[] { id, content, size })); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs index 9c2a04a..28813d3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -33,7 +33,11 @@ public sealed class ChatPanelLayoutTests int beginIdx = methods.IndexOf("Begin"); int beginChildIdx = methods.IndexOf("BeginChild"); int endChildIdx = methods.IndexOf("EndChild"); - int separatorIdx = methods.IndexOf("Separator"); + // L.0 follow-up: Copy-mode toggle adds a Separator above the + // chat tail, so multiple Separators now exist. The footer + // separator (the one we care about for input layout) is the + // LAST one — between EndChild and the input field. + int separatorIdx = methods.LastIndexOf("Separator"); int inputSubmitIdx = methods.IndexOf("InputTextSubmit"); int endIdx = methods.IndexOf("End"); @@ -63,8 +67,13 @@ public sealed class ChatPanelLayoutTests panel.Render(new PanelContext(0.016f, new NoBus()), renderer); - var beginChildCall = renderer.Calls.Single(c => c.Method == "BeginChild"); - var size = (System.Numerics.Vector2)beginChildCall.Args[1]!; + // L.0 follow-up: the chat panel now wraps its body in an outer + // ##chatbody BeginChild (so empty-space clicks can't drag the + // parent window). The inner ##chattail BeginChild is the one + // that reserves the footer; that's what this test asserts. + var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild" + && (string)c.Args[0]! == "##chattail"); + var size = (System.Numerics.Vector2)chattailCall.Args[1]!; // Width 0 = fill available; height < 0 = "fill minus this". // Reserved height should equal FrameHeightWithSpacing + a small // separator pad (~6f) so the input never visually clips the diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs new file mode 100644 index 0000000..42d6f81 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs @@ -0,0 +1,44 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. Defaults must +/// match the OpenAL engine's hard-coded constructor values so a user +/// who has never opened the Audio tab gets identical behaviour to the +/// pre-Phase-L world. +/// +public sealed class AudioSettingsTests +{ + [Fact] + public void Default_values_match_engine_constructor_defaults() + { + // OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0, + // Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs. + var d = AudioSettings.Default; + Assert.Equal(1.0f, d.Master); + Assert.Equal(0.7f, d.Music); + Assert.Equal(1.0f, d.Sfx); + Assert.Equal(0.8f, d.Ambient); + } + + [Fact] + public void Equality_is_value_based() + { + var a = AudioSettings.Default; + var b = AudioSettings.Default with { Master = 0.5f }; + var c = AudioSettings.Default with { Master = 0.5f }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = AudioSettings.Default with { Music = 0.25f }; + Assert.Equal(0.25f, d.Music); + // Other fields untouched. + Assert.Equal(AudioSettings.Default.Master, d.Master); + Assert.Equal(AudioSettings.Default.Sfx, d.Sfx); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs new file mode 100644 index 0000000..7c4adf0 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs @@ -0,0 +1,48 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// L.0: default-pin tests. +public sealed class CharacterSettingsTests +{ + [Fact] + public void Default_values_are_conservative() + { + var d = CharacterSettings.Default; + Assert.Equal("Local", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + Assert.True(d.ConfirmSalvage); + Assert.True(d.ShowPickupMessages); + } + + [Fact] + public void AvailableChannels_includes_retail_routing_targets() + { + var list = CharacterSettings.AvailableChannels; + Assert.Contains("Local", list); + Assert.Contains("Allegiance", list); + Assert.Contains("Fellowship", list); + Assert.Contains("General", list); + Assert.Contains("Trade", list); + Assert.Contains("LFG", list); + Assert.Contains("Roleplay", list); + } + + [Fact] + public void Equality_is_value_based() + { + var a = CharacterSettings.Default; + var b = CharacterSettings.Default with { AutoAttack = true }; + var c = CharacterSettings.Default with { AutoAttack = true }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" }; + Assert.Equal("Allegiance", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs new file mode 100644 index 0000000..12ec900 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs @@ -0,0 +1,43 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. +/// +public sealed class ChatSettingsTests +{ + [Fact] + public void Default_values_are_all_channels_on_with_timestamps_and_filter() + { + var d = ChatSettings.Default; + Assert.True(d.HearGeneralChat); + Assert.True(d.HearTradeChat); + Assert.True(d.HearLFGChat); + Assert.True(d.HearRoleplayChat); + Assert.True(d.HearSocietyChat); + Assert.False(d.AppearOffline); + Assert.True(d.ShowTimestamps); + Assert.True(d.FilterProfanity); + Assert.Equal(12f, d.FontSize); + } + + [Fact] + public void Equality_is_value_based() + { + var a = ChatSettings.Default; + var b = ChatSettings.Default with { HearTradeChat = false }; + var c = ChatSettings.Default with { HearTradeChat = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = ChatSettings.Default with { FontSize = 16f }; + Assert.Equal(16f, d.FontSize); + Assert.True(d.HearGeneralChat); + Assert.True(d.ShowTimestamps); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs new file mode 100644 index 0000000..90b1bb2 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -0,0 +1,76 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: is the immutable record of +/// display-tab preferences. Defaults are pinned here so a regression +/// (e.g. someone changing the default FOV out from under users) +/// surfaces immediately. +/// +public sealed class DisplaySettingsTests +{ + [Fact] + public void Default_values_match_pre_L0_runtime_state() + { + // Defaults pinned to match the actual pre-L.0 startup state: + // · Resolution matches WindowOptions (1280×720 in GameWindow.Run) + // · FieldOfView matches camera FovY (60° = π/3) + // · VSync matches WindowOptions (false during dev) + // · ShowFps true preserves the perf string in the title bar + // Net effect: opening Display + Save with no edits is a visual + // no-op (no window resize, no camera FovY change, no title + // bar change). + var d = DisplaySettings.Default; + Assert.Equal("1280x720", d.Resolution); + Assert.False(d.Fullscreen); + Assert.False(d.VSync); + Assert.Equal(60f, d.FieldOfView); + Assert.Equal(1.0f, d.Gamma); + Assert.True(d.ShowFps); + } + + [Fact] + public void AvailableResolutions_includes_common_16_9_options() + { + var list = DisplaySettings.AvailableResolutions; + Assert.Contains("1280x720", list); + Assert.Contains("1920x1080", list); + Assert.Contains("2560x1440", list); + Assert.Contains("3840x2160", list); + // List should be ascending so the dropdown reads naturally. + for (int i = 1; i < list.Count; i++) + { + int prevW = ParseWidth(list[i - 1]); + int curW = ParseWidth(list[i]); + Assert.True(curW >= prevW, $"Resolutions not sorted: {list[i - 1]} >= {list[i]}"); + } + } + + [Fact] + public void Equality_is_value_based() + { + var a = DisplaySettings.Default; + var b = DisplaySettings.Default with { Fullscreen = true }; + var c = DisplaySettings.Default with { Fullscreen = true }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = DisplaySettings.Default with { FieldOfView = 90f }; + Assert.Equal(90f, d.FieldOfView); + // Other fields untouched. + Assert.Equal("1280x720", d.Resolution); + Assert.False(d.VSync); + Assert.True(d.ShowFps); + } + + private static int ParseWidth(string res) + { + int x = res.IndexOf('x'); + return int.Parse(res.AsSpan(0, x)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs new file mode 100644 index 0000000..b21f444 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs @@ -0,0 +1,54 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests + value-equality +/// guarantees. Defaults are intentionally NOT bit-exact to retail's +/// 0x50C4A54A mask — see GameplaySettings remarks for rationale. +/// +public sealed class GameplaySettingsTests +{ + [Fact] + public void Default_values_are_typical_user_friendly() + { + // These defaults are reviewed in the L.0 brainstorm — typical-user + // starting point, not retail-bitmask. A change to any of these + // should be a deliberate decision, not a drive-by. + var d = GameplaySettings.Default; + Assert.True(d.AutoTarget); + Assert.True(d.AutoRepeatAttack); + Assert.True(d.ToggleRun); + Assert.False(d.AdvancedCombatUI); + Assert.True(d.ShowTooltips); + Assert.False(d.VividTargetingIndicator); + Assert.False(d.SideBySideVitals); + Assert.False(d.CoordinatesOnRadar); + Assert.True(d.SpellDuration); + Assert.True(d.AllowGive); + Assert.True(d.ShowHelm); + Assert.True(d.ShowCloak); + Assert.False(d.LockUI); + Assert.False(d.UseMouseTurning); + } + + [Fact] + public void Equality_is_value_based() + { + var a = GameplaySettings.Default; + var b = GameplaySettings.Default with { AutoTarget = false }; + var c = GameplaySettings.Default with { AutoTarget = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = GameplaySettings.Default with { LockUI = true }; + Assert.True(d.LockUI); + // Other fields untouched. + Assert.Equal(GameplaySettings.Default.AutoTarget, d.AutoTarget); + Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 74b88e7..6d51c51 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -29,7 +29,13 @@ public sealed class SettingsPanelTests persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); var dispatcher = new InputDispatcher(kb, mouse, persisted); - var vm = new SettingsVM(persisted, dispatcher, _ => { }); + var vm = new SettingsVM( + persisted, dispatcher, _ => { }, + DisplaySettings.Default, _ => { }, + AudioSettings.Default, _ => { }, + GameplaySettings.Default, _ => { }, + ChatSettings.Default, _ => { }, + CharacterSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -165,4 +171,326 @@ public sealed class SettingsPanelTests var (panel, _, _, _) = Build(); Assert.Equal("acdream.settings", panel.Id); } + + // -- Tabbed shell ----------------------------------------------------- + + [Fact] + public void Render_opens_tab_bar_with_six_tab_items() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer(); + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + // BeginTabBar exactly once, EndTabBar exactly once. + Assert.Single(r.Calls, c => c.Method == "BeginTabBar"); + Assert.Single(r.Calls, c => c.Method == "EndTabBar"); + + // The six tab labels approved in the design brainstorm. + var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Equal( + new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }, + tabLabels); + } + + [Fact] + public void Keybinds_tab_renders_section_headers_when_active() + { + var (panel, _, _, _) = Build(); + // Default ActiveTabLabel = null → FakePanelRenderer treats the + // first tab item ("Keybinds") as active. + var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Movement", headers); + Assert.Contains("Hotbar", headers); + Assert.Contains("Emotes", headers); + } + + [Fact] + public void Inactive_tabs_do_not_render_keybind_section_headers() + { + var (panel, _, _, _) = Build(); + // Force "Display" to be the active tab — the Keybinds content + // must NOT render. + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Movement", headers); + Assert.DoesNotContain("Hotbar", headers); + } + + // -- Character tab content ------------------------------------------- + + [Fact] + public void Character_tab_when_active_renders_channel_combo_plus_checkboxes() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Default chat channel", combos); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains(checks, l => l.StartsWith("Auto-attack")); + Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging")); + Assert.Contains(checks, l => l.StartsWith("Show pickup messages")); + } + + [Fact] + public void Character_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Default chat channel", combos); + } + + [Fact] + public void Character_tab_channel_combo_uses_AvailableChannels_list() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel"); + var items = (string[])ch.Args[2]!; + Assert.Contains("Local", items); + Assert.Contains("Allegiance", items); + Assert.Contains("Fellowship", items); + } + + [Fact] + public void All_six_tabs_are_now_implemented_no_placeholder_text_remains() + { + // After the L.0 build order finishes, no tab should render the + // "Coming soon" placeholder line. If a future commit re-adds a + // placeholder tab without updating this test, it will fail. + var (panel, _, _, _) = Build(); + + foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }) + { + var r = new FakePanelRenderer { ActiveTabLabel = tabLabel }; + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain(wrapped, t => t.Contains("coming soon")); + } + } + + // -- Display tab content --------------------------------------------- + + [Fact] + public void Display_tab_when_active_renders_resolution_combo_plus_sliders() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); + var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList(); + var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList(); + + Assert.Contains("Resolution", combos); + Assert.Contains("Fullscreen", checks); + Assert.Contains("V-Sync", checks); + Assert.Contains("Show FPS", checks); + Assert.Contains("Field of View", sliders); + Assert.Contains("Gamma", sliders); + } + + [Fact] + public void Display_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Resolution", combos); + } + + [Fact] + public void Display_tab_resolution_combo_uses_AvailableResolutions_list() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution"); + var items = (string[])resCall.Args[2]!; + Assert.Contains("1920x1080", items); + Assert.Contains("3840x2160", items); + } + + // -- Audio tab content ----------------------------------------------- + + [Fact] + public void Audio_tab_when_active_renders_implemented_volume_sliders() + { + // L.0 ships Master + SFX only — Music + Ambient sliders are + // hidden until R5 MIDI / ambient-loop engines exist. The + // AudioSettings record still carries those fields so the + // JSON round-trips, but the panel doesn't surface a slider + // that wouldn't actually do anything. + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Master", sliders); + Assert.Contains("SFX", sliders); + Assert.DoesNotContain("Music", sliders); + Assert.DoesNotContain("Ambient", sliders); + } + + [Fact] + public void Audio_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Master", sliders); + Assert.DoesNotContain("Music", sliders); + } + + // -- Gameplay tab content -------------------------------------------- + + [Fact] + public void Gameplay_tab_when_active_renders_expected_checkboxes() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + // Spot check the major retail-named toggles. Don't assert exact + // count — adding new toggles shouldn't break this test. + Assert.Contains("Auto-target on attack", checks); + Assert.Contains("Auto-repeat attacks", checks); + Assert.Contains("Run mode is toggle (vs hold)", checks); + Assert.Contains("Show item tooltips", checks); + Assert.Contains("Show helm on character", checks); + Assert.Contains("Show cloak on character", checks); + Assert.Contains("Lock UI (disable panel drag/resize)", checks); + Assert.Contains("Use mouse turning", checks); + } + + [Fact] + public void Gameplay_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Auto-target on attack", checks); + Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks); + } + + // -- Chat tab content ------------------------------------------------ + + [Fact] + public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("General", checks); + Assert.Contains("Trade", checks); + Assert.Contains("LFG (looking for group)", checks); + Assert.Contains("Roleplay", checks); + Assert.Contains("Society (CD / EW / RB)", checks); + Assert.Contains("Show timestamps", checks); + Assert.Contains("Filter profanity", checks); + Assert.Contains("Appear offline (hide from /who)", checks); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Font size (pt)", sliders); + } + + [Fact] + public void Chat_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + // The tab labels "General", "Trade" etc only appear inside the + // Chat tab. Confirm none of them rendered. + Assert.DoesNotContain("General", checks); + Assert.DoesNotContain("Trade", checks); + } + + [Fact] + public void Audio_sliders_are_clamped_to_zero_one_range() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master"); + Assert.Equal(0f, (float)masterCall.Args[2]!); + Assert.Equal(1f, (float)masterCall.Args[3]!); + } + + [Fact] + public void Save_Cancel_buttons_render_outside_the_tab_bar() + { + // The global Save / Cancel / Reset-all row must come BEFORE + // BeginTabBar so it stays visible on every tab. Any change that + // accidentally moves the buttons inside a tab item should fail + // here. + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer(); + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + int saveIdx = r.Calls.FindIndex(c => c.Method == "Button" + && (string)c.Args[0]! == "Save changes"); + int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar"); + Assert.True(saveIdx >= 0); + Assert.True(tabBarIdx >= 0); + Assert.True(saveIdx < tabBarIdx, + $"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx})."); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs new file mode 100644 index 0000000..edc24b2 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -0,0 +1,370 @@ +using System.IO; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: reads / writes settings.json. +/// Tests use a temp-file path so they don't touch the user's +/// %LOCALAPPDATA% file. +/// +public sealed class SettingsStoreTests : System.IDisposable +{ + private readonly string _tempPath; + + public SettingsStoreTests() + { + // Unique per-test file under the system temp dir so parallel test + // runners don't clobber each other. + _tempPath = Path.Combine( + Path.GetTempPath(), + $"acdream-settings-test-{System.Guid.NewGuid():N}.json"); + } + + public void Dispose() + { + if (File.Exists(_tempPath)) File.Delete(_tempPath); + } + + [Fact] + public void LoadDisplay_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + var loaded = store.LoadDisplay(); + Assert.Equal(DisplaySettings.Default, loaded); + } + + [Fact] + public void SaveDisplay_then_LoadDisplay_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new DisplaySettings( + Resolution: "2560x1440", + Fullscreen: true, + VSync: false, + FieldOfView: 100f, + Gamma: 1.4f, + ShowFps: true); + + store.SaveDisplay(original); + var loaded = store.LoadDisplay(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadDisplay_falls_back_to_defaults_when_file_is_corrupt() + { + File.WriteAllText(_tempPath, "{ this is not valid json"); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadDisplay(); + + Assert.Equal(DisplaySettings.Default, loaded); + } + + [Fact] + public void LoadDisplay_falls_back_per_field_when_keys_missing() + { + // Partial file — only resolution set; everything else should + // pick up DisplaySettings.Default values. + File.WriteAllText(_tempPath, """ + { + "version": 1, + "display": { "resolution": "1366x768" } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadDisplay(); + + Assert.Equal("1366x768", loaded.Resolution); + Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen); + Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync); + Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView); + } + + [Fact] + public void SaveDisplay_preserves_unknown_top_level_keys() + { + // Forward-compat: a newer client may have written sections we + // don't know about (audio, gameplay). Saving display must not + // delete those, otherwise running an older client would silently + // drop the user's other-tab preferences. + File.WriteAllText(_tempPath, """ + { + "version": 1, + "display": { "resolution": "1280x720" }, + "audio": { "master": 0.5, "music": 0.7 } + } + """); + var store = new SettingsStore(_tempPath); + + store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" }); + + var raw = File.ReadAllText(_tempPath); + Assert.Contains("\"audio\"", raw); + Assert.Contains("\"master\"", raw); + Assert.Contains("0.5", raw); + // And the new display value did get written. + Assert.Contains("1920x1080", raw); + } + + [Fact] + public void DefaultPath_is_under_LocalAppData_acdream() + { + var path = SettingsStore.DefaultPath(); + Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path); + } + + // -- Audio section round-trip ---------------------------------------- + + [Fact] + public void LoadAudio_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(AudioSettings.Default, store.LoadAudio()); + } + + [Fact] + public void SaveAudio_then_LoadAudio_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new AudioSettings(Master: 0.3f, Music: 0.45f, Sfx: 0.9f, Ambient: 0.6f); + + store.SaveAudio(original); + var loaded = store.LoadAudio(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadAudio_falls_back_per_field_when_keys_missing() + { + File.WriteAllText(_tempPath, """ + { + "version": 1, + "audio": { "master": 0.25 } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadAudio(); + + Assert.Equal(0.25f, loaded.Master); + Assert.Equal(AudioSettings.Default.Music, loaded.Music); + Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx); + Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient); + } + + [Fact] + public void SaveAudio_preserves_display_section() + { + // Save display first, then audio — display values must survive. + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.4f }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.4f, store.LoadAudio().Master); + } + + [Fact] + public void SaveDisplay_after_SaveAudio_preserves_audio_section() + { + // Reverse order — audio must survive a subsequent display save. + var store = new SettingsStore(_tempPath); + store.SaveAudio(AudioSettings.Default with { Music = 0.1f }); + store.SaveDisplay(DisplaySettings.Default with { ShowFps = true }); + + Assert.Equal(0.1f, store.LoadAudio().Music); + Assert.True(store.LoadDisplay().ShowFps); + } + + // -- Gameplay section round-trip -------------------------------------- + + [Fact] + public void LoadGameplay_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(GameplaySettings.Default, store.LoadGameplay()); + } + + [Fact] + public void SaveGameplay_then_LoadGameplay_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = GameplaySettings.Default with + { + AutoTarget = false, + AdvancedCombatUI = true, + ShowHelm = false, + LockUI = true, + UseMouseTurning = true, + }; + + store.SaveGameplay(original); + var loaded = store.LoadGameplay(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadGameplay_falls_back_per_field_when_keys_missing() + { + File.WriteAllText(_tempPath, """ + { + "version": 1, + "gameplay": { "lockUI": true } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadGameplay(); + + Assert.True(loaded.LockUI); + Assert.Equal(GameplaySettings.Default.AutoTarget, loaded.AutoTarget); + Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm); + } + + [Fact] + public void All_three_sections_coexist_in_one_settings_json() + { + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); + store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + + // All three load correctly from the same file. + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.5f, store.LoadAudio().Master); + Assert.True(store.LoadGameplay().LockUI); + } + + // -- Chat section round-trip ------------------------------------------ + + [Fact] + public void LoadChat_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(ChatSettings.Default, store.LoadChat()); + } + + [Fact] + public void SaveChat_then_LoadChat_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new ChatSettings( + HearGeneralChat: false, + HearTradeChat: false, + HearLFGChat: false, + HearRoleplayChat: true, + HearSocietyChat: true, + AppearOffline: true, + ShowTimestamps: false, + FilterProfanity: false, + FontSize: 16f); + + store.SaveChat(original); + Assert.Equal(original, store.LoadChat()); + } + + [Fact] + public void All_four_sections_coexist_in_one_settings_json() + { + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); + store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.5f, store.LoadAudio().Master); + Assert.True(store.LoadGameplay().LockUI); + Assert.False(store.LoadChat().HearTradeChat); + Assert.Equal(14f, store.LoadChat().FontSize); + } + + // -- Character section round-trip (per-toon) -------------------------- + + [Fact] + public void LoadCharacter_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(CharacterSettings.Default, store.LoadCharacter("default")); + } + + [Fact] + public void LoadCharacter_returns_defaults_when_toonKey_not_in_file() + { + // File exists with a different toon's data; asking for "+Acdream" + // returns defaults rather than the other toon's data. + var store = new SettingsStore(_tempPath); + store.SaveCharacter("Bob", CharacterSettings.Default with { AutoAttack = true }); + + var loaded = store.LoadCharacter("+Acdream"); + Assert.Equal(CharacterSettings.Default, loaded); + } + + [Fact] + public void SaveCharacter_then_LoadCharacter_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new CharacterSettings( + DefaultChatChannel: "Allegiance", + AutoAttack: true, + ConfirmSalvage: false, + ShowPickupMessages: false); + + store.SaveCharacter("+Acdream", original); + Assert.Equal(original, store.LoadCharacter("+Acdream")); + } + + [Fact] + public void SaveCharacter_preserves_other_toons_within_character_section() + { + // Two different toons, each with distinct settings — saving one + // must not clobber the other. + var store = new SettingsStore(_tempPath); + var alice = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" }; + var bob = CharacterSettings.Default with { DefaultChatChannel = "Fellowship", AutoAttack = true }; + + store.SaveCharacter("Alice", alice); + store.SaveCharacter("Bob", bob); + + Assert.Equal(alice, store.LoadCharacter("Alice")); + Assert.Equal(bob, store.LoadCharacter("Bob")); + } + + [Fact] + public void SaveCharacter_preserves_other_top_level_sections() + { + // Display/audio survive when SaveCharacter writes its nested map. + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.4f }); + store.SaveCharacter("+Acdream", CharacterSettings.Default with { AutoAttack = true }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.4f, store.LoadAudio().Master); + Assert.True(store.LoadCharacter("+Acdream").AutoAttack); + } + + [Fact] + public void All_five_sections_coexist_in_one_settings_json() + { + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); + store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + store.SaveChat(ChatSettings.Default with { HearTradeChat = false }); + store.SaveCharacter("+Acdream", + CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.5f, store.LoadAudio().Master); + Assert.True(store.LoadGameplay().LockUI); + Assert.False(store.LoadChat().HearTradeChat); + Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index f347190..b892cf1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,16 +16,33 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; /// public sealed class SettingsVMTests { - private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory) - Build(KeyBindings? persisted = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List savedCharacterHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); var dispatcher = new InputDispatcher(kb, mouse, persisted); var savedHistory = new System.Collections.Generic.List(); - var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b)); - return (vm, kb, dispatcher, persisted, savedHistory); + var savedDisplayHistory = new System.Collections.Generic.List(); + var savedAudioHistory = new System.Collections.Generic.List(); + var savedGameplayHistory = new System.Collections.Generic.List(); + var savedChatHistory = new System.Collections.Generic.List(); + var savedCharacterHistory = new System.Collections.Generic.List(); + var vm = new SettingsVM( + persisted, dispatcher, + b => savedHistory.Add(b), + persistedDisplay ?? DisplaySettings.Default, + d => savedDisplayHistory.Add(d), + persistedAudio ?? AudioSettings.Default, + a => savedAudioHistory.Add(a), + persistedGameplay ?? GameplaySettings.Default, + g => savedGameplayHistory.Add(g), + persistedChat ?? ChatSettings.Default, + c => savedChatHistory.Add(c), + persistedCharacter ?? CharacterSettings.Default, + ch => savedCharacterHistory.Add(ch)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory); } private static KeyBindings MakeMinimalBindings() @@ -40,7 +57,7 @@ public sealed class SettingsVMTests [Fact] public void Constructor_clones_persisted_into_draft() { - var (vm, _, _, persisted, _) = Build(); + var (vm, _, _, persisted, _, _, _, _, _, _) = Build(); Assert.Equal(persisted.All.Count, vm.Draft.All.Count); Assert.False(vm.HasUnsavedChanges); } @@ -48,7 +65,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_enters_capture_mode() { - var (vm, _, dispatcher, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -61,7 +78,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_chord_with_no_conflict_applies_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -79,7 +96,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_Escape_cancels_with_no_change() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -96,7 +113,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_with_conflict_surfaces_PendingConflict() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); // Bind chord that conflicts with MovementTurnLeft (which has Key.A). @@ -116,7 +133,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -137,7 +154,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_false_cancels_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -159,7 +176,7 @@ public sealed class SettingsVMTests { // Build a draft that's been mutated for MovementForward; ensure // ResetActionToDefault restores W (and Up-arrow per retail). - var (vm, kb, _, _, _) = Build(KeyBindings.RetailDefaults()); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); // F7 is unbound in retail-default (only Ctrl+F7 is acdream debug); @@ -179,7 +196,7 @@ public sealed class SettingsVMTests [Fact] public void ResetAllToDefaults_replaces_entire_draft() { - var (vm, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.ResetAllToDefaults(); // Should now include retail-default size set (~149 bindings). @@ -190,7 +207,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_callback_with_draft() { - var (vm, kb, _, _, savedHistory) = Build(); + var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -206,7 +223,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_reverts_draft_to_persisted() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -222,7 +239,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_during_active_capture_clears_dispatcher_capture_state() { - var (vm, _, dispatcher, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -235,7 +252,373 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + Assert.False(vm.HasUnsavedChanges); + } + + // -- Display tab state ------------------------------------------------ + + [Fact] + public void DisplayDraft_initial_value_matches_persisted() + { + var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + Assert.Equal(custom, vm.DisplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetDisplay_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + // Default ShowFps is true → flip to false to ensure the with- + // expression actually mutates a field. + vm.SetDisplay(vm.DisplayDraft with { ShowFps = false }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_display_callback_with_draft() + { + var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build(); + vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); + + vm.Save(); + + Assert.Single(savedDisplayHistory); + Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution); + Assert.Equal(100f, savedDisplayHistory[0].FieldOfView); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_display_draft_to_persisted() + { + var custom = DisplaySettings.Default with { FieldOfView = 90f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.DisplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_display_to_default() + { + var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(DisplaySettings.Default, vm.DisplayDraft); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_then_Cancel_does_not_revert() + { + // After Save the persisted snapshot equals the draft, so Cancel + // is a no-op. This guards the Save/Cancel ordering — a regression + // would surface as Cancel reverting to pre-Save values. + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); + vm.Save(); + Assert.False(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.True(vm.DisplayDraft.ShowFps); + Assert.False(vm.HasUnsavedChanges); + } + + // -- Audio tab state -------------------------------------------------- + + [Fact] + public void AudioDraft_initial_value_matches_persisted() + { + var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + Assert.Equal(custom, vm.AudioDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetAudio_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_audio_callback_with_draft() + { + var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build(); + vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); + + vm.Save(); + + Assert.Single(savedAudioHistory); + Assert.Equal(0.4f, savedAudioHistory[0].Master); + Assert.Equal(0.6f, savedAudioHistory[0].Sfx); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_audio_draft_to_persisted() + { + var custom = AudioSettings.Default with { Music = 0.2f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.AudioDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_audio_to_default() + { + var custom = AudioSettings.Default with { Master = 0.1f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(AudioSettings.Default, vm.AudioDraft); + Assert.True(vm.HasUnsavedChanges); + } + + // -- Gameplay tab state ----------------------------------------------- + + [Fact] + public void GameplayDraft_initial_value_matches_persisted() + { + var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + Assert.Equal(custom, vm.GameplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetGameplay_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_gameplay_callback_with_draft() + { + var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build(); + vm.SetGameplay(vm.GameplayDraft with + { + AutoTarget = false, + ShowTooltips = false, + UseMouseTurning = true, + }); + + vm.Save(); + + Assert.Single(savedGameplayHistory); + Assert.False(savedGameplayHistory[0].AutoTarget); + Assert.False(savedGameplayHistory[0].ShowTooltips); + Assert.True(savedGameplayHistory[0].UseMouseTurning); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_gameplay_draft_to_persisted() + { + var custom = GameplaySettings.Default with { LockUI = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.GameplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_gameplay_to_default() + { + var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(GameplaySettings.Default, vm.GameplayDraft); + Assert.True(vm.HasUnsavedChanges); + } + + // -- Chat tab state --------------------------------------------------- + + [Fact] + public void ChatDraft_initial_value_matches_persisted() + { + var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + Assert.Equal(custom, vm.ChatDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetChat_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetChat(vm.ChatDraft with { FontSize = 16f }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_chat_callback_with_draft() + { + var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build(); + vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false }); + + vm.Save(); + + Assert.Single(savedChatHistory); + Assert.False(savedChatHistory[0].HearTradeChat); + Assert.False(savedChatHistory[0].ShowTimestamps); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_chat_draft_to_persisted() + { + var custom = ChatSettings.Default with { HearLFGChat = false }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.ChatDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_chat_to_default() + { + var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + Assert.NotEqual(ChatSettings.Default, vm.ChatDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(ChatSettings.Default, vm.ChatDraft); + Assert.True(vm.HasUnsavedChanges); + } + + // -- Character tab state ---------------------------------------------- + + [Fact] + public void CharacterDraft_initial_value_matches_persisted() + { + var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + Assert.Equal(custom, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetCharacter_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_character_callback_with_draft() + { + var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build(); + vm.SetCharacter(vm.CharacterDraft with + { + DefaultChatChannel = "Fellowship", + AutoAttack = true, + ConfirmSalvage = false, + }); + + vm.Save(); + + Assert.Single(savedCharacterHistory); + Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel); + Assert.True(savedCharacterHistory[0].AutoAttack); + Assert.False(savedCharacterHistory[0].ConfirmSalvage); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_character_draft_to_persisted() + { + var custom = CharacterSettings.Default with { AutoAttack = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_character_to_default() + { + var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void LoadCharacterContext_swaps_persisted_and_draft_atomically() + { + // Simulates the post-EnterWorld toon swap — host loads the + // chosen toon's bag from disk and pushes it via + // LoadCharacterContext. BOTH persisted and draft must update + // so HasUnsavedChanges stays false; otherwise the user would + // see a "pending changes" indicator on every login. + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true }; + + vm.LoadCharacterContext(newToonBag); + + Assert.Equal(newToonBag, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void LoadCharacterContext_clears_pending_unsaved_character_edits() + { + // If the user had pending character edits from the previous + // toon (or pre-login session), swapping to a new toon's bag + // must wipe them — Save is per-toon, and bleed-through would + // write the pre-login bag's edits to the new toon's slot. + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); + Assert.True(vm.HasUnsavedChanges); + + vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); + + Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel); + Assert.False(vm.CharacterDraft.AutoAttack); Assert.False(vm.HasUnsavedChanges); } }