diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9435f824..9d3661cf 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -54,8 +54,6 @@ | 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 0fbe73d5..26e3ab60 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -595,12 +595,6 @@ 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(); } @@ -839,17 +833,6 @@ 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. @@ -939,15 +922,8 @@ 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 && _settingsStore is not null) + if (_inputDispatcher 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, @@ -966,113 +942,12 @@ 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) { @@ -1589,20 +1464,6 @@ 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 @@ -4399,41 +4260,6 @@ 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) @@ -4684,15 +4510,6 @@ 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 @@ -4726,16 +4543,9 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - // 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"; + _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + + $"ent {entityCount} | anim {animatedCount}"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -5668,188 +5478,6 @@ 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 70696a23..1c0cb2c1 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -235,48 +235,4 @@ 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 c8ece999..f3a4a07c 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -51,13 +50,6 @@ 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)); @@ -90,31 +82,6 @@ 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. @@ -128,21 +95,7 @@ public sealed class ChatPanel : IPanel // the plain Text path (visually identical to the I.4 panel). var lines = _vm.RecentLinesDetailed(); - 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 (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) { if (lines.Count == 0) { @@ -174,7 +127,7 @@ public sealed class ChatPanel : IPanel } _lastRenderedCount = lines.Count; } - if (!_copyMode) renderer.EndChild(); + renderer.EndChild(); // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. @@ -200,7 +153,6 @@ public sealed class ChatPanel : IPanel if (TryHandleClientCommand(trimmed)) { _input = string.Empty; - renderer.EndChild(); // outer ##chatbody renderer.End(); return; } @@ -221,7 +173,6 @@ 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; } @@ -241,7 +192,6 @@ 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 deleted file mode 100644 index 43a1b474..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 0aa23427..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 74972fc9..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 05438b0a..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 4b1d43e4..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs +++ /dev/null @@ -1,61 +0,0 @@ -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 a8a80346..841b3948 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -5,23 +5,25 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// -/// 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). +/// 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. /// /// -/// 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). +/// 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)". /// /// /// -/// 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. +/// When is non-null, a +/// confirmation prompt is rendered ABOVE the rest of the panel (Yes — +/// Reassign / No — Keep existing). /// /// public sealed class SettingsPanel : IPanel @@ -40,7 +42,7 @@ public sealed class SettingsPanel : IPanel public string Title => "Settings"; /// - /// Hidden by default — opened via F11 / View menu. + /// K.3: hidden by default — opened via F11 / View menu. public bool IsVisible { get; set; } = false; /// @@ -65,7 +67,7 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); } - // Top action buttons. Global across all tabs. + // Top action buttons. if (renderer.Button("Save changes")) _vm.Save(); renderer.SameLine(); if (renderer.Button("Cancel changes")) _vm.Cancel(); @@ -74,51 +76,7 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); - 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) - { + // Sections (retail keymap categories). RenderSection(renderer, "Movement", new[] { InputAction.MovementForward, InputAction.MovementBackup, @@ -178,272 +136,8 @@ public sealed class SettingsPanel : IPanel InputAction.Cry, InputAction.Laugh, InputAction.Wave, InputAction.Cheer, InputAction.PointState, }); - } - /// - /// 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."); + renderer.End(); } 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 deleted file mode 100644 index 11264fcb..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ /dev/null @@ -1,408 +0,0 @@ -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 f4bcc36d..5d334806 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -30,32 +30,6 @@ 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; } @@ -76,139 +50,14 @@ 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) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft - || _chatPersisted != _chatDraft - || _characterPersisted != _characterDraft; + public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft); - /// 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) + public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave) { - _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; + _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); + _draft = CloneBindings(persisted); } /// @@ -311,58 +160,32 @@ public sealed class SettingsVM } /// - /// 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. + /// Replace the entire draft with . /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; - _gameplayDraft = GameplaySettings.Default; - _chatDraft = ChatSettings.Default; - _characterDraft = CharacterSettings.Default; + _draft = KeyBindings.RetailDefaults(); } /// - /// 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. + /// Commit the draft via the onSave callback supplied at + /// construction. After save the draft becomes the new persisted + /// snapshot — resets to false. /// public void Save() { _onSave(_draft); - _onSaveDisplay(_displayDraft); - _onSaveAudio(_audioDraft); - _onSaveGameplay(_gameplayDraft); - _onSaveChat(_chatDraft); - _onSaveCharacter(_characterDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; - _gameplayPersisted = _gameplayDraft; - _chatPersisted = _chatDraft; - _characterPersisted = _characterDraft; + _persisted = CloneBindings(_draft); } /// - /// Revert all drafts to their persisted snapshots and clear any + /// Revert the draft to the persisted snapshot 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); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; - _gameplayDraft = _gameplayPersisted; - _chatDraft = _chatPersisted; - _characterDraft = _characterPersisted; + _draft = CloneBindings(_persisted); CancelRebind(); } diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index 4aa94ae6..ec000370 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -155,41 +155,12 @@ 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. - 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; - } + => ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); /// public void EndChild() => ImGuiNET.ImGui.EndChild(); @@ -222,35 +193,4 @@ 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 9df8b123..f1c8c4d9 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -198,40 +198,4 @@ 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 28813d34..9c2a04a0 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -33,11 +33,7 @@ public sealed class ChatPanelLayoutTests int beginIdx = methods.IndexOf("Begin"); int beginChildIdx = methods.IndexOf("BeginChild"); int endChildIdx = methods.IndexOf("EndChild"); - // 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 separatorIdx = methods.IndexOf("Separator"); int inputSubmitIdx = methods.IndexOf("InputTextSubmit"); int endIdx = methods.IndexOf("End"); @@ -67,13 +63,8 @@ public sealed class ChatPanelLayoutTests panel.Render(new PanelContext(0.016f, new NoBus()), renderer); - // 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]!; + var beginChildCall = renderer.Calls.Single(c => c.Method == "BeginChild"); + var size = (System.Numerics.Vector2)beginChildCall.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 deleted file mode 100644 index 42d6f817..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 7c4adf0e..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 12ec900b..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 90b1bb25..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index b21f444a..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -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 6d51c515..74b88e7b 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -29,13 +29,7 @@ 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, _ => { }, - DisplaySettings.Default, _ => { }, - AudioSettings.Default, _ => { }, - GameplaySettings.Default, _ => { }, - ChatSettings.Default, _ => { }, - CharacterSettings.Default, _ => { }); + var vm = new SettingsVM(persisted, dispatcher, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -171,326 +165,4 @@ 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 deleted file mode 100644 index edc24b23..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ /dev/null @@ -1,370 +0,0 @@ -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 b892cf1c..f347190f 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,33 +16,16 @@ 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, 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) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory) + Build(KeyBindings? persisted = 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 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); + var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b)); + return (vm, kb, dispatcher, persisted, savedHistory); } private static KeyBindings MakeMinimalBindings() @@ -57,7 +40,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); } @@ -65,7 +48,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); @@ -78,7 +61,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); @@ -96,7 +79,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); @@ -113,7 +96,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). @@ -133,7 +116,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); @@ -154,7 +137,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); @@ -176,7 +159,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); @@ -196,7 +179,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). @@ -207,7 +190,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); @@ -223,7 +206,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); @@ -239,7 +222,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); @@ -252,373 +235,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - 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); + var (vm, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } }