From 7665cdf6429b0c881de9e54801fbd9e2780b532d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 17:39:36 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(ui):=20tabbed=20Settings=20shell=20?= =?UTF-8?q?=E2=80=94=20IPanelRenderer=20tab=20API=20+=206=20placeholder=20?= =?UTF-8?q?tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 — foundation for the complete retail-style Settings interface agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11 panel into a tabbed shell whose first tab wraps the existing keybinds content unchanged; the other five tabs (Display / Audio / Gameplay / Chat / Character) render "Coming soon" placeholders so the shape the user approved is visible immediately and gets filled in over the L.x sub-phases (Display first per Easy-wins build order). Why a tab API extension: retail had distinct Options UIs (gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the PDB at acclient_2013_pseudo_c.txt:170739+) and the existing IPanelRenderer only exposed CollapsingHeader. ImGui maps BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new primitives stay backend-friendly — the future D.2b custom retail-look backend implements them via the retail tab UIs without panel changes. Save / Cancel / Reset-all stay above the tab bar so they remain global across all tabs (Phase K's UX preserved). FakePanelRenderer grows matching tab calls + an ActiveTabLabel knob so tests can target a specific tab's content; default behavior treats the first tab item seen as active so existing tests keep passing without changes. 5 new SettingsPanelTests assertions: tab bar opens once, six expected tab labels emitted in order, Keybinds-tab section headers only render when active, placeholders show "Coming soon" text on inactive-content tabs, and Save/Cancel buttons render BEFORE the tab bar (regression guard against accidentally moving them inside a tab item). dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green (243 Core.Net + 311 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 27 ++++++ .../Panels/Settings/SettingsPanel.cs | 90 +++++++++++++++---- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 14 +++ .../FakePanelRenderer.cs | 33 +++++++ .../Panels/Settings/SettingsPanelTests.cs | 89 ++++++++++++++++++ 5 files changed, 235 insertions(+), 18 deletions(-) diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 1c0cb2c..3656d75 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -235,4 +235,31 @@ 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(); } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 841b394..7def104 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// -/// K.3: in-game Settings panel for click-to-rebind keymap editing. -/// Hidden by default; opens via F11 (which fires the -/// action) or via the -/// View → Settings entry on the main menu bar. +/// In-game Settings panel — F11 toggle (or View → Settings on the main +/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then +/// Display / Audio / Gameplay / Chat / Character (filling in over the +/// L.x sub-phases). /// /// -/// Layout: top row of action buttons (Save / Cancel / Reset all), then -/// a sequence of sections -/// matching the retail keymap categories (Movement / Postures / Camera / -/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a -/// section: action name, current binding(s) summary, "Rebind" button, -/// per-action "Reset" button. When a rebind is in progress the Rebind -/// button label changes to "Press a key... (Esc to cancel)". +/// Top of the panel: Save / Cancel / Reset-all action buttons (global +/// across all tabs). When is +/// non-null, a confirmation prompt is rendered above those buttons +/// (Yes — Reassign / No — Keep existing). /// /// /// -/// When is non-null, a -/// confirmation prompt is rendered ABOVE the rest of the panel (Yes — -/// Reassign / No — Keep existing). +/// Below the action row a tab bar selects between the six categories. +/// Only the Keybinds tab is implemented today; the other five render +/// "Coming soon" placeholders so the structure the user approved in the +/// design brainstorm is visible immediately. /// /// public sealed class SettingsPanel : IPanel @@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel public string Title => "Settings"; /// - /// K.3: hidden by default — opened via F11 / View menu. + /// Hidden by default — opened via F11 / View menu. public bool IsVisible { get; set; } = false; /// @@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); } - // Top action buttons. + // Top action buttons. Global across all tabs. if (renderer.Button("Save changes")) _vm.Save(); renderer.SameLine(); if (renderer.Button("Cancel changes")) _vm.Cancel(); @@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); - // Sections (retail keymap categories). + if (renderer.BeginTabBar("settings.tabs")) + { + if (renderer.BeginTabItem("Keybinds")) + { + RenderKeybindsTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Display")) + { + RenderPlaceholder(renderer, "Display"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Audio")) + { + RenderPlaceholder(renderer, "Audio"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Gameplay")) + { + RenderPlaceholder(renderer, "Gameplay"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Chat")) + { + RenderPlaceholder(renderer, "Chat"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Character")) + { + RenderPlaceholder(renderer, "Character"); + renderer.EndTabItem(); + } + renderer.EndTabBar(); + } + + renderer.End(); + } + + /// + /// Render the Keybinds tab — eight collapsing-header sections matching + /// the retail keymap categories. Phase K shipped this content; the + /// only thing that changed is the wrapping tab item. + /// + private void RenderKeybindsTab(IPanelRenderer renderer) + { RenderSection(renderer, "Movement", new[] { InputAction.MovementForward, InputAction.MovementBackup, @@ -136,8 +178,20 @@ public sealed class SettingsPanel : IPanel InputAction.Cry, InputAction.Laugh, InputAction.Wave, InputAction.Cheer, InputAction.PointState, }); + } - renderer.End(); + /// + /// Placeholder content shown for tabs whose implementation is still + /// pending. Reads as "Coming soon" plus a note about which sub-phase + /// is expected to fill it in. + /// + private static void RenderPlaceholder(IPanelRenderer renderer, string tabName) + { + renderer.TextWrapped($"{tabName} settings coming soon."); + renderer.Spacing(); + renderer.TextWrapped( + "This tab is part of the staged Settings interface rollout. " + + "Build order: Display → Audio → Gameplay → Chat → Character."); } private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index ec00037..d5eb978 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -193,4 +193,18 @@ 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(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index f1c8c4d..3706188 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -198,4 +198,37 @@ 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())); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 74b88e7..2d894ac 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -165,4 +165,93 @@ 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); + } + + [Fact] + public void Placeholder_tabs_render_coming_soon_text_when_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + 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.Contains(wrapped, t => t.Contains("Audio settings coming soon")); + } + + [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})."); + } } From 382f0ad3fa6c106cf0a7b7b2fb821402517d5d08 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 17:46:31 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat(ui):=20Display=20tab=20+=20settings.?= =?UTF-8?q?json=20persistence=20=E2=80=94=20first=20non-keybind=20tab=20la?= =?UTF-8?q?nds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — first concrete tab on the new Settings shell, in the Easy-wins build order agreed in the brainstorm (Display → Audio → Gameplay → Chat → Character). DisplaySettings (immutable record): Resolution / Fullscreen / VSync / FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9 resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on, 75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement. SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json (coexists with keybinds.json — own load/save path stays put, no migration needed). LoadDisplay falls back per-field when keys are missing (partial-file tolerant) and falls back to defaults when the file is corrupt or the JSON is unparseable. SaveDisplay round-trips preserved — unknown top-level keys (e.g. an `audio` section written by a future client) are kept on save so older builds don't silently drop newer-tab data. SettingsVM gains a parallel display-state machine: persistedDisplay + draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both keybinds and display deltas, Save/Cancel/ResetAll cover both atomically from the user's POV (one Save commits everything, one Cancel reverts everything). Constructor signature extends with two new params; existing keybinds-only callers updated. SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder — Combo for resolution, Checkboxes for fullscreen/vsync/show-fps, SliderFloat for FOV + gamma. Live-preview note in the panel body matches the agreed UX: FOV + gamma update visibly while the user drags; resolution / fullscreen / vsync apply on Save (live preview would be too jarring). GameWindow wires SettingsStore into the existing SettingsVM construct site — load on startup, save on each tab Save. Errors print to console and don't crash the panel. 19 new tests: · DisplaySettings record (4) — defaults pinned, value equality, with- expressions, AvailableResolutions sorted ascending · SettingsStore (6) — round trip, missing-file → defaults, corrupt- file → defaults, partial-file → per-field fallback, unknown-key preservation, DefaultPath shape · SettingsVM display (6) — initial draft tracks persisted, SetDisplay marks dirty, Save invokes display callback, Cancel reverts, ResetAllToDefaults covers display, Save-then-Cancel is no-op · SettingsPanel display tab (3) — widgets render only when active, resolution combo uses AvailableResolutions, no Combo emitted on inactive tabs dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green (243 Core.Net + 330 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 22 +++ .../Panels/Settings/DisplaySettings.cs | 44 +++++ .../Panels/Settings/SettingsPanel.cs | 47 +++++- .../Panels/Settings/SettingsStore.cs | 151 ++++++++++++++++++ .../Panels/Settings/SettingsVM.cs | 68 ++++++-- .../Panels/Settings/DisplaySettingsTests.cs | 67 ++++++++ .../Panels/Settings/SettingsPanelTests.cs | 52 +++++- .../Panels/Settings/SettingsStoreTests.cs | 119 ++++++++++++++ .../Panels/Settings/SettingsVMTests.cs | 116 ++++++++++++-- 9 files changed, 653 insertions(+), 33 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 48c0326..b892658 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,6 +901,13 @@ public sealed class GameWindow : IDisposable // the same OnLoad path (see _inputDispatcher field). if (_inputDispatcher is not null) { + // L.0 — settings.json (display + future audio / gameplay / + // chat / character tabs). Coexists with keybinds.json, + // which keeps its own load/save path. + var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var persistedDisplay = settingsStore.LoadDisplay(); + _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( persisted: _keyBindings, dispatcher: _inputDispatcher, @@ -919,6 +926,21 @@ 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()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: display save failed: {ex.Message}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs new file mode 100644 index 0000000..dd89b6c --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -0,0 +1,44 @@ +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. + public static DisplaySettings Default { get; } = new( + Resolution: "1920x1080", + Fullscreen: false, + VSync: true, + FieldOfView: 75f, + Gamma: 1.0f, + ShowFps: false); + + /// 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/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7def104..7af322e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -83,7 +83,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Display")) { - RenderPlaceholder(renderer, "Display"); + RenderDisplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Audio")) @@ -194,6 +194,51 @@ public sealed class SettingsPanel : IPanel + "Build order: Display → Audio → Gameplay → Chat → Character."); } + /// + /// 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."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs new file mode 100644 index 0000000..0e961a9 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +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) + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // Preserve any non-display 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 == "display" || 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 displayObj = new SortedDictionary(StringComparer.Ordinal) + { + ["fieldOfView"] = display.FieldOfView, + ["fullscreen"] = display.Fullscreen, + ["gamma"] = display.Gamma, + ["resolution"] = display.Resolution, + ["showFps"] = display.ShowFps, + ["vsync"] = display.VSync, + }; + + // Build the output by hand so preserved-keys keep their raw JSON. + var sb = new System.Text.StringBuilder(); + sb.Append('{').AppendLine(); + sb.Append(" \"display\": ") + .Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true }) + .Replace("\n", "\n ")) + .Append(',').AppendLine(); + foreach (var kv in preservedKeys) + { + sb.Append(" \"").Append(kv.Key).Append("\": ") + .Append(kv.Value).Append(',').AppendLine(); + } + sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); + sb.Append('}').AppendLine(); + + File.WriteAllText(_path, sb.ToString()); + } + + private static string ReadString(JsonElement obj, string name, string fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String + ? (el.GetString() ?? fallback) : fallback; + + private static bool ReadBool(JsonElement obj, string name, bool fallback) + => obj.TryGetProperty(name, out var el) + && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) + ? el.GetBoolean() : fallback; + + private static float ReadFloat(JsonElement obj, string name, float fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number + ? el.GetSingle() : fallback; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 5d33480..2cf233b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -30,6 +30,12 @@ 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; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -50,14 +56,38 @@ public sealed class SettingsVM /// True iff the draft differs structurally from the /// persisted snapshot. Used to grey out the Save button when no /// rebinds are pending. - public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft); + public bool HasUnsavedChanges + => !KeyBindingsEqual(_persisted, _draft) + || _displayPersisted != _displayDraft; - public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave) + /// The current Display draft. Panel reads from here; + /// mutation goes through . + public DisplaySettings DisplayDraft => _displayDraft; + + public SettingsVM( + KeyBindings persisted, + InputDispatcher dispatcher, + Action onSave, + DisplaySettings persistedDisplay, + Action onSaveDisplay) { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _draft = CloneBindings(persisted); + _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); + _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); + _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + } + + /// + /// 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)); } /// @@ -160,32 +190,42 @@ public sealed class SettingsVM } /// - /// Replace the entire draft with . + /// Replace the keybinds draft with + /// AND the display draft with . + /// "Reset all" applies to every tab — it's the user's escape hatch + /// when they've gotten lost. /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; } /// - /// Commit the draft via the onSave callback supplied at - /// construction. After save the draft becomes the new persisted - /// snapshot — resets to false. + /// Commit both keybinds + display drafts via the onSave callbacks + /// supplied at construction. After save the drafts become the new + /// persisted snapshots — resets to + /// false. Each callback is invoked exactly once per Save; if the + /// caller wants atomicity across both files it has to handle it + /// outside the VM. /// public void Save() { _onSave(_draft); - _persisted = CloneBindings(_draft); + _onSaveDisplay(_displayDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; } /// - /// Revert the draft to the persisted snapshot and clear any + /// Revert all drafts to their persisted snapshots and clear any /// in-flight rebind state. Used by the panel's "Cancel" button and /// when the user closes the settings window without saving. /// public void Cancel() { - _draft = CloneBindings(_persisted); + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs new file mode 100644 index 0000000..f73db09 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -0,0 +1,67 @@ +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_brainstorm_agreement() + { + var d = DisplaySettings.Default; + Assert.Equal("1920x1080", d.Resolution); + Assert.False(d.Fullscreen); + Assert.True(d.VSync); + Assert.Equal(75f, d.FieldOfView); + Assert.Equal(1.0f, d.Gamma); + Assert.False(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 { ShowFps = true }; + var c = DisplaySettings.Default with { ShowFps = 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("1920x1080", d.Resolution); + Assert.True(d.VSync); + } + + 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/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 2d894ac..ec05c4c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -29,7 +29,9 @@ public sealed class SettingsPanelTests persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); var dispatcher = new InputDispatcher(kb, mouse, persisted); - var vm = new SettingsVM(persisted, dispatcher, _ => { }); + var vm = new SettingsVM( + persisted, dispatcher, _ => { }, + DisplaySettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -234,6 +236,54 @@ public sealed class SettingsPanelTests Assert.Contains(wrapped, t => t.Contains("Audio settings 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); + } + [Fact] public void Save_Cancel_buttons_render_outside_the_tab_bar() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs new file mode 100644 index 0000000..226454c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -0,0 +1,119 @@ +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); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index f347190..595ff38 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,16 +16,21 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; /// public sealed class SettingsVMTests { - private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory) - Build(KeyBindings? persisted = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); var dispatcher = new InputDispatcher(kb, mouse, persisted); var savedHistory = new System.Collections.Generic.List(); - var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b)); - return (vm, kb, dispatcher, persisted, savedHistory); + var savedDisplayHistory = new System.Collections.Generic.List(); + var vm = new SettingsVM( + persisted, dispatcher, + b => savedHistory.Add(b), + persistedDisplay ?? DisplaySettings.Default, + d => savedDisplayHistory.Add(d)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory); } private static KeyBindings MakeMinimalBindings() @@ -40,7 +45,7 @@ public sealed class SettingsVMTests [Fact] public void Constructor_clones_persisted_into_draft() { - var (vm, _, _, persisted, _) = Build(); + var (vm, _, _, persisted, _, _) = Build(); Assert.Equal(persisted.All.Count, vm.Draft.All.Count); Assert.False(vm.HasUnsavedChanges); } @@ -48,7 +53,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_enters_capture_mode() { - var (vm, _, dispatcher, _, _) = Build(); + var (vm, _, dispatcher, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -61,7 +66,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_chord_with_no_conflict_applies_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -79,7 +84,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_Escape_cancels_with_no_change() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -96,7 +101,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_with_conflict_surfaces_PendingConflict() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); // Bind chord that conflicts with MovementTurnLeft (which has Key.A). @@ -116,7 +121,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -137,7 +142,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_false_cancels_rebind() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -159,7 +164,7 @@ public sealed class SettingsVMTests { // Build a draft that's been mutated for MovementForward; ensure // ResetActionToDefault restores W (and Up-arrow per retail). - var (vm, kb, _, _, _) = Build(KeyBindings.RetailDefaults()); + var (vm, kb, _, _, _, _) = Build(KeyBindings.RetailDefaults()); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); // F7 is unbound in retail-default (only Ctrl+F7 is acdream debug); @@ -179,7 +184,7 @@ public sealed class SettingsVMTests [Fact] public void ResetAllToDefaults_replaces_entire_draft() { - var (vm, _, _, _, _) = Build(); + var (vm, _, _, _, _, _) = Build(); vm.ResetAllToDefaults(); // Should now include retail-default size set (~149 bindings). @@ -190,7 +195,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_callback_with_draft() { - var (vm, kb, _, _, savedHistory) = Build(); + var (vm, kb, _, _, savedHistory, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -206,7 +211,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_reverts_draft_to_persisted() { - var (vm, kb, _, _, _) = Build(); + var (vm, kb, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -222,7 +227,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_during_active_capture_clears_dispatcher_capture_state() { - var (vm, _, dispatcher, _, _) = Build(); + var (vm, _, dispatcher, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -235,7 +240,84 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _) = Build(); + var (vm, _, _, _, _, _) = Build(); + Assert.False(vm.HasUnsavedChanges); + } + + // -- Display tab state ------------------------------------------------ + + [Fact] + public void DisplayDraft_initial_value_matches_persisted() + { + var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; + var (vm, _, _, _, _, _) = Build(persistedDisplay: custom); + Assert.Equal(custom, vm.DisplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetDisplay_marks_unsaved_changes() + { + var (vm, _, _, _, _, _) = Build(); + vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); + 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); } } From 53b1878c5c9410e0390495fa98cddb294dff84c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 17:57:00 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(ui):=20Audio=20tab=20=E2=80=94=20liv?= =?UTF-8?q?e=20volume=20sliders=20driving=20OpenAL=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins build order. Audio is the live-preview poster child: dragging a slider is audible immediately, Save persists, Cancel reverts and the engine catches up on the next frame. AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats). Defaults match the OpenAlAudioEngine constructor values exactly so a user who never opens the tab gets identical behaviour to the pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0, Ambient=0.8). SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection helper that consolidates the unknown-top-level-key preservation logic. Display and Audio sections coexist in settings.json: { "version": 1, "display": { ... }, "audio": { ... } } Saving one section preserves the other on disk; a future Gameplay / Chat / Character section drops in the same way without touching existing data. SettingsVM gains a parallel audio state machine (audioPersisted / audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges covers all three buckets now (keybinds + display + audio); Save / Cancel / ResetAll are atomic across all of them. GameWindow wiring is the live-preview mechanism — every render frame pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap (four float assignments) and unconditional. SetListener still applies MasterVolume each frame too via the existing Phase E.2 code path, so listener gain stays in sync. Persisted audio is applied to the engine ONCE at startup before the first frame so the user's saved values take effect before any sound plays — startup-time apply happens during the same SettingsVM construction site that does the LoadDisplay + LoadAudio. SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four SliderFloat calls clamped to [0, 1], plus a footer note explaining the live-preview UX. The "Coming soon" placeholder test was retargeted from "Audio" to "Gameplay" since Audio is no longer a placeholder. 16 new tests: · AudioSettings record (3) — defaults pin engine constants, value equality, with-expressions · SettingsStore audio round-trip (5) — missing-file → defaults, round-trip all fields, partial-file per-field fallback, save-audio- preserves-display, save-display-preserves-audio · SettingsVM audio state (5) — initial draft tracks persisted, SetAudio marks dirty, Save invokes audio callback, Cancel reverts, ResetAllToDefaults covers audio · SettingsPanel audio tab (3) — four sliders render only when active, no SliderFloat emitted on inactive tabs, slider range is [0, 1] dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green (243 Core.Net + 346 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 45 +++++++- .../Panels/Settings/AudioSettings.cs | 28 +++++ .../Panels/Settings/SettingsPanel.cs | 35 +++++- .../Panels/Settings/SettingsStore.cs | 93 ++++++++++++--- .../Panels/Settings/SettingsVM.cs | 35 +++++- .../Panels/Settings/AudioSettingsTests.cs | 44 +++++++ .../Panels/Settings/SettingsPanelTests.cs | 55 ++++++++- .../Panels/Settings/SettingsStoreTests.cs | 64 ++++++++++ .../Panels/Settings/SettingsVMTests.cs | 109 ++++++++++++++---- 9 files changed, 461 insertions(+), 47 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b892658..7d1214f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,12 +901,24 @@ public sealed class GameWindow : IDisposable // the same OnLoad path (see _inputDispatcher field). if (_inputDispatcher is not null) { - // L.0 — settings.json (display + future audio / gameplay / + // L.0 — settings.json (display + audio + future gameplay / // chat / character tabs). Coexists with keybinds.json, // which keeps its own load/save path. var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); var persistedDisplay = settingsStore.LoadDisplay(); + var persistedAudio = settingsStore.LoadAudio(); + + // Apply persisted audio to the engine BEFORE the panel + // host starts pushing per-frame so the first frame uses + // the user's saved values instead of engine defaults. + if (_audioEngine is not null && _audioEngine.IsAvailable) + { + _audioEngine.MasterVolume = persistedAudio.Master; + _audioEngine.MusicVolume = persistedAudio.Music; + _audioEngine.SfxVolume = persistedAudio.Sfx; + _audioEngine.AmbientVolume = persistedAudio.Ambient; + } _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( persisted: _keyBindings, @@ -941,6 +953,21 @@ public sealed class GameWindow : IDisposable { 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}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); @@ -4105,6 +4132,22 @@ 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; + } + // 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) diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs new file mode 100644 index 0000000..43a1b47 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs @@ -0,0 +1,28 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Audio mixer preferences persisted to settings.json. Drives the +/// existing Phase E.2 OpenAL engine — the host wires these values into +/// OpenAlAudioEngine.MasterVolume / SfxVolume / +/// MusicVolume / AmbientVolume on Save and on startup. +/// +/// +/// Defaults match the engine's hard-coded starting values so a user +/// who never opens the Audio tab gets identical behaviour to the +/// previous env-var-only world. +/// +/// +public sealed record AudioSettings( + float Master, + float Music, + float Sfx, + float Ambient) +{ + /// Values used on first launch. Mirror the engine's + /// constructor-default Volume properties. + public static AudioSettings Default { get; } = new( + Master: 1.0f, + Music: 0.7f, + Sfx: 1.0f, + Ambient: 0.8f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7af322e..7d8c78e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -88,7 +88,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Audio")) { - RenderPlaceholder(renderer, "Audio"); + RenderAudioTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Gameplay")) @@ -239,6 +239,39 @@ public sealed class SettingsPanel : IPanel + "preview live as you drag; Cancel reverts to the saved value."); } + /// + /// Render the Audio tab — four volume sliders (Master / Music / SFX / + /// Ambient). Volumes update live: the host pushes the VM's + /// AudioDraft into the running OpenAL engine each frame, so dragging + /// a slider is audible immediately. Cancel reverts the draft and the + /// engine catches up on the next frame. + /// + 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 music = a.Music; + if (renderer.SliderFloat("Music", ref music, 0f, 1f)) + _vm.SetAudio(a with { Music = music }); + + float sfx = a.Sfx; + if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) + _vm.SetAudio(a with { Sfx = sfx }); + + 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."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 0e961a9..ad07c35 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -82,11 +82,79 @@ public sealed class SettingsStore /// 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)); + + 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-display top-level keys from the existing file. + // Preserve any non-target top-level keys from the existing file. var preservedKeys = new SortedDictionary(StringComparer.Ordinal); if (File.Exists(_path)) { @@ -96,7 +164,7 @@ public sealed class SettingsStore var doc = JsonDocument.Parse(stream); foreach (var prop in doc.RootElement.EnumerateObject()) { - if (prop.Name == "display" || prop.Name == "version") continue; + if (prop.Name == sectionName || prop.Name == "version") continue; preservedKeys[prop.Name] = prop.Value.GetRawText(); } } @@ -108,28 +176,19 @@ public sealed class SettingsStore } } - var displayObj = new SortedDictionary(StringComparer.Ordinal) - { - ["fieldOfView"] = display.FieldOfView, - ["fullscreen"] = display.Fullscreen, - ["gamma"] = display.Gamma, - ["resolution"] = display.Resolution, - ["showFps"] = display.ShowFps, - ["vsync"] = display.VSync, - }; - - // Build the output by hand so preserved-keys keep their raw JSON. var sb = new System.Text.StringBuilder(); sb.Append('{').AppendLine(); - sb.Append(" \"display\": ") - .Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true }) - .Replace("\n", "\n ")) - .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(); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 2cf233b..d90bf0a 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -36,6 +36,11 @@ public sealed class SettingsVM 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; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -58,26 +63,36 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft; /// 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; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, Action onSave, DisplaySettings persistedDisplay, - Action onSaveDisplay) + Action onSaveDisplay, + AudioSettings persistedAudio, + Action onSaveAudio) { _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)); _draft = CloneBindings(persisted); _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; } /// @@ -90,6 +105,18 @@ public sealed class SettingsVM _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)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -199,6 +226,7 @@ public sealed class SettingsVM { _draft = KeyBindings.RetailDefaults(); _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; } /// @@ -213,8 +241,10 @@ public sealed class SettingsVM { _onSave(_draft); _onSaveDisplay(_displayDraft); + _onSaveAudio(_audioDraft); _persisted = CloneBindings(_draft); _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; } /// @@ -226,6 +256,7 @@ public sealed class SettingsVM { _draft = CloneBindings(_persisted); _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs new file mode 100644 index 0000000..42d6f81 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs @@ -0,0 +1,44 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. Defaults must +/// match the OpenAL engine's hard-coded constructor values so a user +/// who has never opened the Audio tab gets identical behaviour to the +/// pre-Phase-L world. +/// +public sealed class AudioSettingsTests +{ + [Fact] + public void Default_values_match_engine_constructor_defaults() + { + // OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0, + // Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs. + var d = AudioSettings.Default; + Assert.Equal(1.0f, d.Master); + Assert.Equal(0.7f, d.Music); + Assert.Equal(1.0f, d.Sfx); + Assert.Equal(0.8f, d.Ambient); + } + + [Fact] + public void Equality_is_value_based() + { + var a = AudioSettings.Default; + var b = AudioSettings.Default with { Master = 0.5f }; + var c = AudioSettings.Default with { Master = 0.5f }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = AudioSettings.Default with { Music = 0.25f }; + Assert.Equal(0.25f, d.Music); + // Other fields untouched. + Assert.Equal(AudioSettings.Default.Master, d.Master); + Assert.Equal(AudioSettings.Default.Sfx, d.Sfx); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index ec05c4c..4181f88 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -31,7 +31,8 @@ public sealed class SettingsPanelTests var dispatcher = new InputDispatcher(kb, mouse, persisted); var vm = new SettingsVM( persisted, dispatcher, _ => { }, - DisplaySettings.Default, _ => { }); + DisplaySettings.Default, _ => { }, + AudioSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -226,14 +227,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { + // Gameplay is still a placeholder (next in build order). Display + // and Audio have shipped — they have real widgets, not "coming + // soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; 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.Contains(wrapped, t => t.Contains("Audio settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Gameplay settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -284,6 +288,51 @@ public sealed class SettingsPanelTests Assert.Contains("3840x2160", items); } + // -- Audio tab content ----------------------------------------------- + + [Fact] + public void Audio_tab_when_active_renders_four_volume_sliders() + { + 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("Music", sliders); + Assert.Contains("SFX", sliders); + Assert.Contains("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); + } + + [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() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 226454c..bef09fc 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -116,4 +116,68 @@ public sealed class SettingsStoreTests : System.IDisposable 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); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 595ff38..25e5c69 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -25,12 +25,15 @@ public sealed class SettingsVMTests 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 vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), persistedDisplay ?? DisplaySettings.Default, - d => savedDisplayHistory.Add(d)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory); + d => savedDisplayHistory.Add(d), + persistedAudio ?? AudioSettings.Default, + a => savedAudioHistory.Add(a)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory); } private static KeyBindings MakeMinimalBindings() @@ -45,7 +48,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); } @@ -53,7 +56,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); @@ -66,7 +69,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); @@ -84,7 +87,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); @@ -101,7 +104,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). @@ -121,7 +124,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); @@ -142,7 +145,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); @@ -164,7 +167,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); @@ -184,7 +187,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). @@ -195,7 +198,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); @@ -211,7 +214,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); @@ -227,7 +230,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); @@ -240,7 +243,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -250,7 +253,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -258,7 +261,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -266,7 +269,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -281,7 +284,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -295,7 +298,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -310,7 +313,7 @@ public sealed class SettingsVMTests // 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(); + var (vm, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -320,4 +323,64 @@ public sealed class SettingsVMTests 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); + } } From b7165e5b17ad6090ce5daabd16d33c3efed05774 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 18:05:07 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat(ui):=20Gameplay=20tab=20=E2=80=94=20?= =?UTF-8?q?14=20retail=20CharacterOption-derived=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins build order. Subset of retail's CharacterOption + CharacterOptions2 bitfield flags ported as bools (see acclient.h:3404+ enum). Local- only this phase per the brainstorm — server sync deferred to a later phase that will marshal the draft into the retail CharacterOption packet. GameplaySettings record exposes 14 named flags grouped by usage: · Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI, VividTargetingIndicator · Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar, SpellDuration, ShowHelm, ShowCloak · Interface: AllowGive, LockUI, UseMouseTurning Retail names + bit values are documented in field-level comments so the future server-sync phase has a 1:1 mapping. Defaults are typical-user starting points (NOT bit-exact to retail's 0x50C4A54A / 0x948700 masks); class-level remarks call out that defaults will be re-anchored to retail values once the wire-format is the load-bearing source. SettingsStore grows LoadGameplay / SaveGameplay using the existing SaveSection generic helper (added in the audio commit). All three non-keybind sections (display, audio, gameplay) now coexist in settings.json with non-destructive cross-section saves — verified by a new "all three sections coexist" round-trip test. SettingsVM grows the parallel gameplay state machine (gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay). HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover gameplay too. Constructor signature adds two more params; existing call sites (App startup + tests) updated. SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder — 14 Checkbox calls grouped under three Text+Separator headers, plus a footer note explaining the local-only-this-phase scope. The "Coming soon" placeholder test was retargeted from "Gameplay" to "Chat" since Gameplay is no longer a placeholder. GameWindow construction site loads gameplay on startup + writes via the SettingsStore on Save. Server-sync packet wiring is left as a TODO comment in the onSaveGameplay callback (next phase, after the protocol round-trip is in place). 14 new tests: · GameplaySettings record (3) — defaults pinned, value equality, with-expressions · SettingsStore gameplay (4) — missing-file → defaults, round-trip, partial-file fallback, all-three-sections coexist · SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults covers · SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render only when active dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green (243 Core.Net + 360 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 23 +++- .../Panels/Settings/GameplaySettings.cs | 61 +++++++++ .../Panels/Settings/SettingsPanel.cs | 86 +++++++++++- .../Panels/Settings/SettingsStore.cs | 63 +++++++++ .../Panels/Settings/SettingsVM.cs | 75 ++++++++--- .../Panels/Settings/GameplaySettingsTests.cs | 54 ++++++++ .../Panels/Settings/SettingsPanelTests.cs | 51 ++++++- .../Panels/Settings/SettingsStoreTests.cs | 60 +++++++++ .../Panels/Settings/SettingsVMTests.cs | 125 ++++++++++++++---- 9 files changed, 539 insertions(+), 59 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7d1214f..a9a3e39 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -906,8 +906,9 @@ public sealed class GameWindow : IDisposable // which keeps its own load/save path. var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var persistedDisplay = settingsStore.LoadDisplay(); - var persistedAudio = settingsStore.LoadAudio(); + var persistedDisplay = settingsStore.LoadDisplay(); + var persistedAudio = settingsStore.LoadAudio(); + var persistedGameplay = settingsStore.LoadGameplay(); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -968,6 +969,24 @@ public sealed class GameWindow : IDisposable { 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}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs new file mode 100644 index 0000000..4b1d43e --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -0,0 +1,61 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Gameplay-related preferences persisted to settings.json. +/// Mirrors a subset of retail's CharacterOption + CharacterOptions2 +/// bitfield flags (see docs/research/named-retail/acclient.h:3404+). +/// Retail names are kept verbatim so future server-sync packs these +/// into the wire-format bitmask without renaming. +/// +/// +/// L.0 scope: local-only. The brainstorm explicitly deferred +/// server sync — on Save these values are persisted to settings.json +/// only. A later phase will marshal them into the retail +/// CharacterOption packet (0x...) when the protocol work +/// for player-options round-trip is in place. +/// +/// +/// +/// Defaults below are chosen as the typical-user starting point, NOT +/// pinned bit-exact to retail's 0x50C4A54A / 0x948700 +/// masks (those will become the defaults once server-sync ships and +/// the bitmask round-trip is the load-bearing wire format). +/// +/// +public sealed record GameplaySettings( + // CharacterOption (32-bit) subset — most-used gameplay toggles. + bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack + bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit + bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run + bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels + bool ShowTooltips, // 0x100 — show item tooltips on hover + bool VividTargetingIndicator, // 0x8000 — bright targeting reticle + bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked + bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar + bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons + bool AllowGive, // 0x40 — accept items handed by other players + // CharacterOptions2 (32-bit) subset. + bool ShowHelm, // 0x100000 — render helm overlay on character + bool ShowCloak, // 0x800000 — render cloak on character + bool LockUI, // 0x1000000 — disable panel drag/resize + bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags +{ + /// Sensible starting values for first launch. NOT bit-exact + /// to retail's Default_CharacterOption = 0x50C4A54A + + /// Default_CharacterOptions2 = 0x948700 — see class remarks. + public static GameplaySettings Default { get; } = new( + AutoTarget: true, + AutoRepeatAttack: true, + ToggleRun: true, + AdvancedCombatUI: false, + ShowTooltips: true, + VividTargetingIndicator: false, + SideBySideVitals: false, + CoordinatesOnRadar: false, + SpellDuration: true, + AllowGive: true, + ShowHelm: true, + ShowCloak: true, + LockUI: false, + UseMouseTurning: false); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7d8c78e..6b30883 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -93,7 +93,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Gameplay")) { - RenderPlaceholder(renderer, "Gameplay"); + RenderGameplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Chat")) @@ -272,6 +272,90 @@ public sealed class SettingsPanel : IPanel + "values to settings.json; Cancel reverts to the saved values."); } + /// + /// 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."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index ad07c35..8a25aa4 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -123,6 +123,69 @@ public sealed class SettingsStore 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)); + + 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) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index d90bf0a..4368ca7 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -41,6 +41,11 @@ public sealed class SettingsVM 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; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -63,8 +68,9 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft + || _gameplayPersisted != _gameplayDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -74,6 +80,10 @@ public sealed class SettingsVM /// mutation goes through . public AudioSettings AudioDraft => _audioDraft; + /// The current Gameplay draft. Panel reads from here; + /// mutation goes through . + public GameplaySettings GameplayDraft => _gameplayDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, @@ -81,18 +91,23 @@ public sealed class SettingsVM DisplaySettings persistedDisplay, Action onSaveDisplay, AudioSettings persistedAudio, - Action onSaveAudio) + Action onSaveAudio, + GameplaySettings persistedGameplay, + Action onSaveGameplay) { - _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)); - _draft = CloneBindings(persisted); - _displayDraft = persistedDisplay; - _audioDraft = persistedAudio; + _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)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; + _gameplayDraft = persistedGameplay; } /// @@ -117,6 +132,18 @@ public sealed class SettingsVM _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)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -224,9 +251,10 @@ public sealed class SettingsVM /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; + _gameplayDraft = GameplaySettings.Default; } /// @@ -242,9 +270,11 @@ public sealed class SettingsVM _onSave(_draft); _onSaveDisplay(_displayDraft); _onSaveAudio(_audioDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; + _onSaveGameplay(_gameplayDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; + _gameplayPersisted = _gameplayDraft; } /// @@ -254,9 +284,10 @@ public sealed class SettingsVM /// public void Cancel() { - _draft = CloneBindings(_persisted); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; + _gameplayDraft = _gameplayPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs new file mode 100644 index 0000000..b21f444 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs @@ -0,0 +1,54 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests + value-equality +/// guarantees. Defaults are intentionally NOT bit-exact to retail's +/// 0x50C4A54A mask — see GameplaySettings remarks for rationale. +/// +public sealed class GameplaySettingsTests +{ + [Fact] + public void Default_values_are_typical_user_friendly() + { + // These defaults are reviewed in the L.0 brainstorm — typical-user + // starting point, not retail-bitmask. A change to any of these + // should be a deliberate decision, not a drive-by. + var d = GameplaySettings.Default; + Assert.True(d.AutoTarget); + Assert.True(d.AutoRepeatAttack); + Assert.True(d.ToggleRun); + Assert.False(d.AdvancedCombatUI); + Assert.True(d.ShowTooltips); + Assert.False(d.VividTargetingIndicator); + Assert.False(d.SideBySideVitals); + Assert.False(d.CoordinatesOnRadar); + Assert.True(d.SpellDuration); + Assert.True(d.AllowGive); + Assert.True(d.ShowHelm); + Assert.True(d.ShowCloak); + Assert.False(d.LockUI); + Assert.False(d.UseMouseTurning); + } + + [Fact] + public void Equality_is_value_based() + { + var a = GameplaySettings.Default; + var b = GameplaySettings.Default with { AutoTarget = false }; + var c = GameplaySettings.Default with { AutoTarget = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = GameplaySettings.Default with { LockUI = true }; + Assert.True(d.LockUI); + // Other fields untouched. + Assert.Equal(GameplaySettings.Default.AutoTarget, d.AutoTarget); + Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 4181f88..78450b1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -32,7 +32,8 @@ public sealed class SettingsPanelTests var vm = new SettingsVM( persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, - AudioSettings.Default, _ => { }); + AudioSettings.Default, _ => { }, + GameplaySettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -227,17 +228,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { - // Gameplay is still a placeholder (next in build order). Display - // and Audio have shipped — they have real widgets, not "coming - // soon" text. + // Chat is still a placeholder (next in build order). Display, + // Audio, and Gameplay have shipped — they have real widgets, + // not "coming soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; 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.Contains(wrapped, t => t.Contains("Gameplay settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Chat settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -320,6 +321,44 @@ public sealed class SettingsPanelTests 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); + } + [Fact] public void Audio_sliders_are_clamped_to_zero_one_range() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index bef09fc..cdb0baa 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -180,4 +180,64 @@ public sealed class SettingsStoreTests : System.IDisposable 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); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 25e5c69..940e449 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -26,14 +26,17 @@ public sealed class SettingsVMTests 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 vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), persistedDisplay ?? DisplaySettings.Default, d => savedDisplayHistory.Add(d), persistedAudio ?? AudioSettings.Default, - a => savedAudioHistory.Add(a)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory); + a => savedAudioHistory.Add(a), + persistedGameplay ?? GameplaySettings.Default, + g => savedGameplayHistory.Add(g)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory); } private static KeyBindings MakeMinimalBindings() @@ -48,7 +51,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); } @@ -56,7 +59,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); @@ -69,7 +72,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); @@ -87,7 +90,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); @@ -104,7 +107,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). @@ -124,7 +127,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); @@ -145,7 +148,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); @@ -167,7 +170,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); @@ -187,7 +190,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). @@ -198,7 +201,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); @@ -214,7 +217,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); @@ -230,7 +233,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); @@ -243,7 +246,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -253,7 +256,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -261,7 +264,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -269,7 +272,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory, _) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -284,7 +287,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -298,7 +301,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -313,7 +316,7 @@ public sealed class SettingsVMTests // 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(); + var (vm, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -330,7 +333,7 @@ public sealed class SettingsVMTests public void AudioDraft_initial_value_matches_persisted() { var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; - var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.Equal(custom, vm.AudioDraft); Assert.False(vm.HasUnsavedChanges); } @@ -338,7 +341,7 @@ public sealed class SettingsVMTests [Fact] public void SetAudio_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); Assert.True(vm.HasUnsavedChanges); } @@ -346,7 +349,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_audio_callback_with_draft() { - var (vm, _, _, _, _, _, savedAudioHistory) = Build(); + var (vm, _, _, _, _, _, savedAudioHistory, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); vm.Save(); @@ -361,7 +364,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_audio_draft_to_persisted() { var custom = AudioSettings.Default with { Music = 0.2f }; - var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); Assert.True(vm.HasUnsavedChanges); @@ -375,7 +378,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_audio_to_default() { var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); vm.ResetAllToDefaults(); @@ -383,4 +386,70 @@ public sealed class SettingsVMTests 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); + } } From 356b5f219e1659d36f2910a9d81086abbde4d40f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 18:21:14 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat(ui):=20Chat=20tab=20=E2=80=94=20chan?= =?UTF-8?q?nel=20filters=20+=20display=20prefs=20+=20font=20slider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — fourth tab on the Settings shell. Mixes retail's CharacterOptions2 chat-channel filter bits (Hear*Chat / TimeStamp / FilterLanguage / AppearOffline) with a font-size slider that has no retail bitfield equivalent. ChatSettings record (9 fields): · 5 channel filters: HearGeneralChat, HearTradeChat, HearLFGChat, HearRoleplayChat, HearSocietyChat · 3 display flags: ShowTimestamps, FilterProfanity, AppearOffline · 1 visual: FontSize (10..20 pt) Local-only this phase per the brainstorm — Hear*Chat flags affect client-side display filtering only; the server still streams every channel. Server-sync arrives later when the protocol round-trip is in place. SettingsStore grows LoadChat / SaveChat using the existing generic SaveSection helper. All four non-keybind sections (display, audio, gameplay, chat) now coexist non-destructively in settings.json. SettingsVM grows the parallel chat state machine. HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover chat. Constructor signature adds two more params; existing call sites updated. SettingsPanel.RenderChatTab replaces the L.0-shell placeholder — 8 Checkbox calls grouped under "Channel filters" + "Display" headers, plus a font-size SliderFloat. The "Coming soon" placeholder test was retargeted from "Chat" to "Character" since Chat is no longer a placeholder. GameWindow wires SettingsStore.LoadChat / SaveChat + a TODO comment for the future ChatPanel filter integration (read SettingsVM.ChatDraft when filtering inbound chat lines). 13 new tests: · ChatSettings record (3) — defaults pinned, value equality, with- expressions · SettingsStore chat (3) — missing-file → defaults, round-trip, all four sections coexist · SettingsVM chat (5) — initial draft, SetChat marks dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults covers · SettingsPanel chat tab (2) — checkboxes + slider render only when active dotnet build green (0 warnings); dotnet test 1,289 / 1,289 green (243 Core.Net + 373 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 20 +++ .../Panels/Settings/ChatSettings.cs | 44 ++++++ .../Panels/Settings/SettingsPanel.cs | 63 ++++++++- .../Panels/Settings/SettingsStore.cs | 50 +++++++ .../Panels/Settings/SettingsVM.cs | 34 ++++- .../Panels/Settings/ChatSettingsTests.cs | 43 ++++++ .../Panels/Settings/SettingsPanelTests.cs | 55 +++++++- .../Panels/Settings/SettingsStoreTests.cs | 44 ++++++ .../Panels/Settings/SettingsVMTests.cs | 129 +++++++++++++----- 9 files changed, 440 insertions(+), 42 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a9a3e39..51ca85f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -909,6 +909,7 @@ public sealed class GameWindow : IDisposable var persistedDisplay = settingsStore.LoadDisplay(); var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); + var persistedChat = settingsStore.LoadChat(); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -987,6 +988,25 @@ public sealed class GameWindow : IDisposable { 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}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs new file mode 100644 index 0000000..74972fc --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -0,0 +1,44 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Chat-related preferences persisted to settings.json. Mixes +/// retail's CharacterOptions2 chat-channel filter bits (Hear*Chat +/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual +/// preferences (font size) that don't have a retail bitfield. +/// See docs/research/named-retail/acclient.h:3451+ for the +/// retail bit values. +/// +/// +/// L.0 scope: local-only like the rest of L.0. The Hear*Chat +/// flags affect client-side display filtering of the existing +/// channels — the server still streams every line; the client decides +/// what to render. Server-sync arrives in a later phase that flips the +/// retail-faithful "tell server which channels I'm subscribed to" +/// switch. +/// +/// +public sealed record ChatSettings( + // CharacterOptions2 (32-bit) channel filters. + bool HearGeneralChat, // 0x100 — General channel + bool HearTradeChat, // 0x200 — Trade channel + bool HearLFGChat, // 0x400 — LFG channel + bool HearRoleplayChat, // 0x800 — RP channel + bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB) + bool AppearOffline, // 0x1000 — hide /who status + bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines + bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter) + // Visual / UX (no retail bitfield). + float FontSize) // chat panel font, 10..20 pt +{ + /// Sensible starting values matching the retail "all on" stance. + public static ChatSettings Default { get; } = new( + HearGeneralChat: true, + HearTradeChat: true, + HearLFGChat: true, + HearRoleplayChat: true, + HearSocietyChat: true, + AppearOffline: false, + ShowTimestamps: true, + FilterProfanity: true, + FontSize: 12f); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 6b30883..fef6291 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -98,7 +98,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Chat")) { - RenderPlaceholder(renderer, "Chat"); + RenderChatTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Character")) @@ -356,6 +356,67 @@ public sealed class SettingsPanel : IPanel + "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."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 8a25aa4..277b0d0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -167,6 +167,56 @@ public sealed class SettingsStore 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)); + + 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) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 4368ca7..159f75d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -46,6 +46,11 @@ public sealed class SettingsVM 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; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -70,7 +75,8 @@ public sealed class SettingsVM => !KeyBindingsEqual(_persisted, _draft) || _displayPersisted != _displayDraft || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft; + || _gameplayPersisted != _gameplayDraft + || _chatPersisted != _chatDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -84,6 +90,10 @@ public sealed class SettingsVM /// mutation goes through . public GameplaySettings GameplayDraft => _gameplayDraft; + /// The current Chat draft. Panel reads from here; + /// mutation goes through . + public ChatSettings ChatDraft => _chatDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, @@ -93,7 +103,9 @@ public sealed class SettingsVM AudioSettings persistedAudio, Action onSaveAudio, GameplaySettings persistedGameplay, - Action onSaveGameplay) + Action onSaveGameplay, + ChatSettings persistedChat, + Action onSaveChat) { _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); @@ -104,10 +116,13 @@ public sealed class SettingsVM _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)); _draft = CloneBindings(persisted); _displayDraft = persistedDisplay; _audioDraft = persistedAudio; _gameplayDraft = persistedGameplay; + _chatDraft = persistedChat; } /// @@ -144,6 +159,17 @@ public sealed class SettingsVM _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)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -255,6 +281,7 @@ public sealed class SettingsVM _displayDraft = DisplaySettings.Default; _audioDraft = AudioSettings.Default; _gameplayDraft = GameplaySettings.Default; + _chatDraft = ChatSettings.Default; } /// @@ -271,10 +298,12 @@ public sealed class SettingsVM _onSaveDisplay(_displayDraft); _onSaveAudio(_audioDraft); _onSaveGameplay(_gameplayDraft); + _onSaveChat(_chatDraft); _persisted = CloneBindings(_draft); _displayPersisted = _displayDraft; _audioPersisted = _audioDraft; _gameplayPersisted = _gameplayDraft; + _chatPersisted = _chatDraft; } /// @@ -288,6 +317,7 @@ public sealed class SettingsVM _displayDraft = _displayPersisted; _audioDraft = _audioPersisted; _gameplayDraft = _gameplayPersisted; + _chatDraft = _chatPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs new file mode 100644 index 0000000..12ec900 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs @@ -0,0 +1,43 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests. +/// +public sealed class ChatSettingsTests +{ + [Fact] + public void Default_values_are_all_channels_on_with_timestamps_and_filter() + { + var d = ChatSettings.Default; + Assert.True(d.HearGeneralChat); + Assert.True(d.HearTradeChat); + Assert.True(d.HearLFGChat); + Assert.True(d.HearRoleplayChat); + Assert.True(d.HearSocietyChat); + Assert.False(d.AppearOffline); + Assert.True(d.ShowTimestamps); + Assert.True(d.FilterProfanity); + Assert.Equal(12f, d.FontSize); + } + + [Fact] + public void Equality_is_value_based() + { + var a = ChatSettings.Default; + var b = ChatSettings.Default with { HearTradeChat = false }; + var c = ChatSettings.Default with { HearTradeChat = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = ChatSettings.Default with { FontSize = 16f }; + Assert.Equal(16f, d.FontSize); + Assert.True(d.HearGeneralChat); + Assert.True(d.ShowTimestamps); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 78450b1..72a1ba4 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -33,7 +33,8 @@ public sealed class SettingsPanelTests persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, AudioSettings.Default, _ => { }, - GameplaySettings.Default, _ => { }); + GameplaySettings.Default, _ => { }, + ChatSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -228,17 +229,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { - // Chat is still a placeholder (next in build order). Display, - // Audio, and Gameplay have shipped — they have real widgets, - // not "coming soon" text. + // Character is still a placeholder (last on the build order). + // Display / Audio / Gameplay / Chat have shipped — they have + // real widgets, not "coming soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; 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.Contains(wrapped, t => t.Contains("Chat settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Character settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -359,6 +360,48 @@ public sealed class SettingsPanelTests 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() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index cdb0baa..dac57aa 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -240,4 +240,48 @@ public sealed class SettingsStoreTests : System.IDisposable 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); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 940e449..14b2341 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -27,6 +27,7 @@ public sealed class SettingsVMTests 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 vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), @@ -35,8 +36,10 @@ public sealed class SettingsVMTests persistedAudio ?? AudioSettings.Default, a => savedAudioHistory.Add(a), persistedGameplay ?? GameplaySettings.Default, - g => savedGameplayHistory.Add(g)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory); + g => savedGameplayHistory.Add(g), + persistedChat ?? ChatSettings.Default, + c => savedChatHistory.Add(c)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory); } private static KeyBindings MakeMinimalBindings() @@ -51,7 +54,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); } @@ -59,7 +62,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); @@ -72,7 +75,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); @@ -90,7 +93,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); @@ -107,7 +110,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). @@ -127,7 +130,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); @@ -148,7 +151,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); @@ -170,7 +173,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); @@ -190,7 +193,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). @@ -201,7 +204,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); @@ -217,7 +220,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); @@ -233,7 +236,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); @@ -246,7 +249,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -256,7 +259,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -264,7 +267,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -272,7 +275,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory, _, _) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -287,7 +290,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -301,7 +304,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -316,7 +319,7 @@ public sealed class SettingsVMTests // 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(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -333,7 +336,7 @@ public sealed class SettingsVMTests public void AudioDraft_initial_value_matches_persisted() { var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.Equal(custom, vm.AudioDraft); Assert.False(vm.HasUnsavedChanges); } @@ -341,7 +344,7 @@ public sealed class SettingsVMTests [Fact] public void SetAudio_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); Assert.True(vm.HasUnsavedChanges); } @@ -349,7 +352,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_audio_callback_with_draft() { - var (vm, _, _, _, _, _, savedAudioHistory, _) = Build(); + var (vm, _, _, _, _, _, savedAudioHistory, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); vm.Save(); @@ -364,7 +367,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_audio_draft_to_persisted() { var custom = AudioSettings.Default with { Music = 0.2f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); Assert.True(vm.HasUnsavedChanges); @@ -378,7 +381,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_audio_to_default() { var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); vm.ResetAllToDefaults(); @@ -393,7 +396,7 @@ public sealed class SettingsVMTests public void GameplayDraft_initial_value_matches_persisted() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.Equal(custom, vm.GameplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -401,7 +404,7 @@ public sealed class SettingsVMTests [Fact] public void SetGameplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); Assert.True(vm.HasUnsavedChanges); } @@ -409,7 +412,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_gameplay_callback_with_draft() { - var (vm, _, _, _, _, _, _, savedGameplayHistory) = Build(); + var (vm, _, _, _, _, _, _, savedGameplayHistory, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { AutoTarget = false, @@ -430,7 +433,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_gameplay_draft_to_persisted() { var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); Assert.True(vm.HasUnsavedChanges); @@ -444,7 +447,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_gameplay_to_default() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); vm.ResetAllToDefaults(); @@ -452,4 +455,64 @@ public sealed class SettingsVMTests 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); + } } From 73749d176ac1cf3aeba3dd2b9f295e429d55b048 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 18:27:07 +0200 Subject: [PATCH 06/14] =?UTF-8?q?feat(ui):=20Character=20tab=20=E2=80=94?= =?UTF-8?q?=20per-toon=20settings;=20Phase=20L.0=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (final) — last tab on the Settings shell. Per-toon preferences keyed by toon name in settings.json under character[]. With this commit the L.0 build order finishes and every approved tab is implemented. CharacterSettings record (4 fields): · DefaultChatChannel (string — Local / Allegiance / Fellowship / etc) · AutoAttack (bool — continue swinging until target dies) · ConfirmSalvage (bool — prompt before salvaging valuable items) · ShowPickupMessages (bool — pickup lines in chat) AvailableChannels static list exposes the 7 retail-routing targets for the dropdown. SettingsStore grows LoadCharacter(toonKey) / SaveCharacter(toonKey) using JsonNode/JsonObject for the nested-toon write — the existing SaveSection raw-text-preservation pattern handles top-level keys but doesn't fit the nested per-toon mutation. The character map preserves every other toon's settings on save, and other top-level sections (display / audio / gameplay / chat) are preserved too. SettingsVM grows the parallel character state machine. The host owns the toonKey (currently hard-coded to "default" in GameWindow because we don't have a current-character source plumbed yet) — the VM just edits whatever bag the host loaded. SettingsPanel.RenderCharacterTab replaces the L.0-shell placeholder — a Combo for default chat channel + 3 Checkboxes for AutoAttack / ConfirmSalvage / ShowPickupMessages. The RenderPlaceholder helper is now removed (no callers); the old "Placeholder_tabs_render_coming_soon_text_when_active" test is replaced by an "all six tabs are implemented" guard test that fails if any future commit adds a placeholder back. GameWindow loads/saves character settings under toonKey "default" with a TODO comment to swap in the real toon name once CharacterList plumbing exposes a currentCharacter source. 18 new tests: · CharacterSettings record (4) — defaults pinned, AvailableChannels list shape, value equality, with-expressions · SettingsStore character (6) — missing-file / toon-not-in-file → defaults, round-trip, multi-toon preservation, preserves other top-level sections, all five sections coexist · SettingsVM character (5) — initial draft, SetCharacter marks dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults covers · SettingsPanel character tab (3 net, after removing the placeholder test) — combo+checkboxes render only when active, channel combo uses AvailableChannels, all six tabs are now non-placeholder Phase L.0 final tally: · 5 commits on feature/settings-retail (shell + 5 tabs) · 6 tabs: Keybinds (Phase K) + Display + Audio + Gameplay + Chat + Character · 5 settings sections in settings.json (display/audio/gameplay/chat/character), coexisting non-destructively + a sixth file (keybinds.json) on the side. dotnet build green (0 warnings); dotnet test 1,307 / 1,307 green (243 Core.Net + 391 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 22 +++ .../Panels/Settings/CharacterSettings.cs | 48 ++++++ .../Panels/Settings/SettingsPanel.cs | 51 ++++-- .../Panels/Settings/SettingsStore.cs | 85 ++++++++++ .../Panels/Settings/SettingsVM.cs | 102 +++++++----- .../Panels/Settings/CharacterSettingsTests.cs | 48 ++++++ .../Panels/Settings/SettingsPanelTests.cs | 67 +++++++- .../Panels/Settings/SettingsStoreTests.cs | 83 ++++++++++ .../Panels/Settings/SettingsVMTests.cs | 145 +++++++++++++----- 9 files changed, 555 insertions(+), 96 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 51ca85f..6e102a5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -910,6 +910,13 @@ public sealed class GameWindow : IDisposable var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); var persistedChat = settingsStore.LoadChat(); + // Per-toon character settings keyed by name. We don't + // know which toon the user will pick until after + // CharacterList lands, so use a "default" bag for now. + // Future: swap to the actual toon name once a + // currentCharacter source is plumbed. + const string toonKey = "default"; + var persistedCharacter = settingsStore.LoadCharacter(toonKey); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -1007,6 +1014,21 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"settings: chat save failed: {ex.Message}"); } + }, + persistedCharacter: persistedCharacter, + onSaveCharacter: character => + { + try + { + settingsStore.SaveCharacter(toonKey, character); + Console.WriteLine( + $"settings: character[{toonKey}] 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); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs new file mode 100644 index 0000000..0aa2342 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs @@ -0,0 +1,48 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Per-character preferences persisted to settings.json under +/// character[toonName]. Settings on this tab are scoped to a +/// single toon; switching characters loads a different bag. +/// +/// +/// L.0 scope: local-only. The settings here describe how the +/// client UI behaves for the active toon — they don't yet flow to the +/// server. When server-sync ships, options like +/// would be pushed via the retail Player-Options packet. +/// +/// +/// +/// MVP shape — four settings only. Easy to grow when more per-toon +/// preferences land. Each is value-typed so equality and Cancel-revert +/// behave like the other tabs' records. +/// +/// +public sealed record CharacterSettings( + string DefaultChatChannel, // "Local" / "Allegiance" / "Fellowship" / "General" / etc. + bool AutoAttack, // Tap-to-attack continues swinging until target dies + bool ConfirmSalvage, // Prompt before salvaging valuable items + bool ShowPickupMessages) // "You picked up X" lines in chat +{ + /// Defaults applied to a fresh character (no settings.json + /// entry yet). Conservative — opt-in for AutoAttack, opt-in for + /// confirmation prompts, pickup messages on by default. + public static CharacterSettings Default { get; } = new( + DefaultChatChannel: "Local", + AutoAttack: false, + ConfirmSalvage: true, + ShowPickupMessages: true); + + /// Channel-name presets exposed in the dropdown. Order + /// roughly matches retail's chat-channel routing. + public static System.Collections.Generic.IReadOnlyList AvailableChannels { get; } = new[] + { + "Local", + "Allegiance", + "Fellowship", + "General", + "Trade", + "LFG", + "Roleplay", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index fef6291..b0fec97 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -103,7 +103,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Character")) { - RenderPlaceholder(renderer, "Character"); + RenderCharacterTab(renderer); renderer.EndTabItem(); } renderer.EndTabBar(); @@ -180,20 +180,6 @@ public sealed class SettingsPanel : IPanel }); } - /// - /// Placeholder content shown for tabs whose implementation is still - /// pending. Reads as "Coming soon" plus a note about which sub-phase - /// is expected to fill it in. - /// - private static void RenderPlaceholder(IPanelRenderer renderer, string tabName) - { - renderer.TextWrapped($"{tabName} settings coming soon."); - renderer.Spacing(); - renderer.TextWrapped( - "This tab is part of the staged Settings interface rollout. " - + "Build order: Display → Audio → Gameplay → Chat → Character."); - } - /// /// Render the Display tab — resolution / fullscreen / vsync / /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; @@ -417,6 +403,41 @@ public sealed class SettingsPanel : IPanel + "Cancel reverts."); } + /// + /// Render the Character tab — per-toon preferences. The host owns + /// the toon-name key; the panel just edits whatever bag the host + /// loaded into . + /// + private void RenderCharacterTab(IPanelRenderer renderer) + { + var c = _vm.CharacterDraft; + + var channels = CharacterSettings.AvailableChannels.ToArray(); + int idx = System.Array.IndexOf(channels, c.DefaultChatChannel); + if (idx < 0) idx = 0; + if (renderer.Combo("Default chat channel", ref idx, channels)) + _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] }); + + bool autoAttack = c.AutoAttack; + if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack)) + _vm.SetCharacter(c with { AutoAttack = autoAttack }); + + bool confirmSalvage = c.ConfirmSalvage; + if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage)) + _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage }); + + bool pickup = c.ShowPickupMessages; + if (renderer.Checkbox("Show pickup messages in chat", ref pickup)) + _vm.SetCharacter(c with { ShowPickupMessages = pickup }); + + renderer.Spacing(); + renderer.TextWrapped( + "Per-character preferences — saved per toon under " + + "settings.json's character[\"\"]. Local-only this " + + "phase; server-sync arrives later when the protocol " + + "round-trip lands."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 277b0d0..11264fc 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Text.Json.Nodes; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -203,6 +204,90 @@ public sealed class SettingsStore 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) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 159f75d..c32fade 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -51,6 +51,11 @@ public sealed class SettingsVM 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; } @@ -73,10 +78,11 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft - || _chatPersisted != _chatDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft + || _gameplayPersisted != _gameplayDraft + || _chatPersisted != _chatDraft + || _characterPersisted != _characterDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -94,6 +100,11 @@ public sealed class SettingsVM /// 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, @@ -105,24 +116,29 @@ public sealed class SettingsVM GameplaySettings persistedGameplay, Action onSaveGameplay, ChatSettings persistedChat, - Action onSaveChat) + Action onSaveChat, + CharacterSettings persistedCharacter, + Action onSaveCharacter) { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _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)); - _draft = CloneBindings(persisted); - _displayDraft = persistedDisplay; - _audioDraft = persistedAudio; - _gameplayDraft = persistedGameplay; - _chatDraft = persistedChat; + _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; } /// @@ -170,6 +186,16 @@ public sealed class SettingsVM _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)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -277,11 +303,12 @@ public sealed class SettingsVM /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; - _gameplayDraft = GameplaySettings.Default; - _chatDraft = ChatSettings.Default; + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; + _gameplayDraft = GameplaySettings.Default; + _chatDraft = ChatSettings.Default; + _characterDraft = CharacterSettings.Default; } /// @@ -299,11 +326,13 @@ public sealed class SettingsVM _onSaveAudio(_audioDraft); _onSaveGameplay(_gameplayDraft); _onSaveChat(_chatDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; - _gameplayPersisted = _gameplayDraft; - _chatPersisted = _chatDraft; + _onSaveCharacter(_characterDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; + _gameplayPersisted = _gameplayDraft; + _chatPersisted = _chatDraft; + _characterPersisted = _characterDraft; } /// @@ -313,11 +342,12 @@ public sealed class SettingsVM /// public void Cancel() { - _draft = CloneBindings(_persisted); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; - _gameplayDraft = _gameplayPersisted; - _chatDraft = _chatPersisted; + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; + _gameplayDraft = _gameplayPersisted; + _chatDraft = _chatPersisted; + _characterDraft = _characterPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs new file mode 100644 index 0000000..7c4adf0 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs @@ -0,0 +1,48 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// L.0: default-pin tests. +public sealed class CharacterSettingsTests +{ + [Fact] + public void Default_values_are_conservative() + { + var d = CharacterSettings.Default; + Assert.Equal("Local", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + Assert.True(d.ConfirmSalvage); + Assert.True(d.ShowPickupMessages); + } + + [Fact] + public void AvailableChannels_includes_retail_routing_targets() + { + var list = CharacterSettings.AvailableChannels; + Assert.Contains("Local", list); + Assert.Contains("Allegiance", list); + Assert.Contains("Fellowship", list); + Assert.Contains("General", list); + Assert.Contains("Trade", list); + Assert.Contains("LFG", list); + Assert.Contains("Roleplay", list); + } + + [Fact] + public void Equality_is_value_based() + { + var a = CharacterSettings.Default; + var b = CharacterSettings.Default with { AutoAttack = true }; + var c = CharacterSettings.Default with { AutoAttack = true }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" }; + Assert.Equal("Allegiance", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 72a1ba4..7e8c1ef 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -34,7 +34,8 @@ public sealed class SettingsPanelTests DisplaySettings.Default, _ => { }, AudioSettings.Default, _ => { }, GameplaySettings.Default, _ => { }, - ChatSettings.Default, _ => { }); + ChatSettings.Default, _ => { }, + CharacterSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -226,20 +227,72 @@ public sealed class SettingsPanelTests Assert.DoesNotContain("Hotbar", headers); } + // -- Character tab content ------------------------------------------- + [Fact] - public void Placeholder_tabs_render_coming_soon_text_when_active() + public void Character_tab_when_active_renders_channel_combo_plus_checkboxes() { - // Character is still a placeholder (last on the build order). - // Display / Audio / Gameplay / Chat have shipped — they have - // real widgets, not "coming soon" text. var (panel, _, _, _) = Build(); var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; panel.Render(new PanelContext(0.016f, new NullBus()), r); - var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") + var combos = r.Calls.Where(c => c.Method == "Combo") .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(wrapped, t => t.Contains("Character settings coming soon")); + 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 --------------------------------------------- diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index dac57aa..edc24b2 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -284,4 +284,87 @@ public sealed class SettingsStoreTests : System.IDisposable 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 14b2341..1bedd6c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List savedCharacterHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -28,6 +28,7 @@ public sealed class SettingsVMTests 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), @@ -38,8 +39,10 @@ public sealed class SettingsVMTests persistedGameplay ?? GameplaySettings.Default, g => savedGameplayHistory.Add(g), persistedChat ?? ChatSettings.Default, - c => savedChatHistory.Add(c)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory); + c => savedChatHistory.Add(c), + persistedCharacter ?? CharacterSettings.Default, + ch => savedCharacterHistory.Add(ch)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory); } private static KeyBindings MakeMinimalBindings() @@ -54,7 +57,7 @@ public sealed class SettingsVMTests [Fact] public void Constructor_clones_persisted_into_draft() { - var (vm, _, _, persisted, _, _, _, _, _) = Build(); + var (vm, _, _, persisted, _, _, _, _, _, _) = Build(); Assert.Equal(persisted.All.Count, vm.Draft.All.Count); Assert.False(vm.HasUnsavedChanges); } @@ -62,7 +65,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_enters_capture_mode() { - var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -75,7 +78,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_chord_with_no_conflict_applies_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -93,7 +96,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_Escape_cancels_with_no_change() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -110,7 +113,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_with_conflict_surfaces_PendingConflict() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); // Bind chord that conflicts with MovementTurnLeft (which has Key.A). @@ -130,7 +133,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -151,7 +154,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_false_cancels_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -173,7 +176,7 @@ public sealed class SettingsVMTests { // Build a draft that's been mutated for MovementForward; ensure // ResetActionToDefault restores W (and Up-arrow per retail). - var (vm, kb, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); // F7 is unbound in retail-default (only Ctrl+F7 is acdream debug); @@ -193,7 +196,7 @@ public sealed class SettingsVMTests [Fact] public void ResetAllToDefaults_replaces_entire_draft() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.ResetAllToDefaults(); // Should now include retail-default size set (~149 bindings). @@ -204,7 +207,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_callback_with_draft() { - var (vm, kb, _, _, savedHistory, _, _, _, _) = Build(); + var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -220,7 +223,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_reverts_draft_to_persisted() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -236,7 +239,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_during_active_capture_clears_dispatcher_capture_state() { - var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -249,7 +252,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -259,7 +262,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -267,7 +270,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -275,7 +278,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory, _, _, _) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -290,7 +293,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -304,7 +307,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -319,7 +322,7 @@ public sealed class SettingsVMTests // 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(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -336,7 +339,7 @@ public sealed class SettingsVMTests public void AudioDraft_initial_value_matches_persisted() { var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.Equal(custom, vm.AudioDraft); Assert.False(vm.HasUnsavedChanges); } @@ -344,7 +347,7 @@ public sealed class SettingsVMTests [Fact] public void SetAudio_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); Assert.True(vm.HasUnsavedChanges); } @@ -352,7 +355,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_audio_callback_with_draft() { - var (vm, _, _, _, _, _, savedAudioHistory, _, _) = Build(); + var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); vm.Save(); @@ -367,7 +370,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_audio_draft_to_persisted() { var custom = AudioSettings.Default with { Music = 0.2f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); Assert.True(vm.HasUnsavedChanges); @@ -381,7 +384,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_audio_to_default() { var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); vm.ResetAllToDefaults(); @@ -396,7 +399,7 @@ public sealed class SettingsVMTests public void GameplayDraft_initial_value_matches_persisted() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.Equal(custom, vm.GameplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -404,7 +407,7 @@ public sealed class SettingsVMTests [Fact] public void SetGameplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); Assert.True(vm.HasUnsavedChanges); } @@ -412,7 +415,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_gameplay_callback_with_draft() { - var (vm, _, _, _, _, _, _, savedGameplayHistory, _) = Build(); + var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { AutoTarget = false, @@ -433,7 +436,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_gameplay_draft_to_persisted() { var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); Assert.True(vm.HasUnsavedChanges); @@ -447,7 +450,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_gameplay_to_default() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); vm.ResetAllToDefaults(); @@ -462,7 +465,7 @@ public sealed class SettingsVMTests public void ChatDraft_initial_value_matches_persisted() { var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); Assert.Equal(custom, vm.ChatDraft); Assert.False(vm.HasUnsavedChanges); } @@ -470,7 +473,7 @@ public sealed class SettingsVMTests [Fact] public void SetChat_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetChat(vm.ChatDraft with { FontSize = 16f }); Assert.True(vm.HasUnsavedChanges); } @@ -478,7 +481,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_chat_callback_with_draft() { - var (vm, _, _, _, _, _, _, _, savedChatHistory) = Build(); + var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build(); vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false }); vm.Save(); @@ -493,7 +496,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_chat_draft_to_persisted() { var custom = ChatSettings.Default with { HearLFGChat = false }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true }); Assert.True(vm.HasUnsavedChanges); @@ -507,7 +510,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_chat_to_default() { var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); Assert.NotEqual(ChatSettings.Default, vm.ChatDraft); vm.ResetAllToDefaults(); @@ -515,4 +518,70 @@ public sealed class SettingsVMTests 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); + } } From fc1e1933aa852e520fb83c73db9f8ea0620b44a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 21:18:07 +0200 Subject: [PATCH 07/14] =?UTF-8?q?feat(ui):=20wire=20Display=20GL=20knobs?= =?UTF-8?q?=20+=20per-toon=20Character=20key=20=E2=80=94=20Settings=20goes?= =?UTF-8?q?=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 polish — the Display + Character tabs were persisting to disk but didn't yet drive runtime behavior. This commit flips the live switches. DISPLAY ↔ GL window: · FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase, pushed every frame so dragging is visible immediately. Brainstorm said FOV is a live-preview slider; this delivers it. · VSync → _window.VSync, change-detected per-frame so flipping the checkbox is instant. Applied at startup too so saved-VSync takes effect before the first frame. · Resolution → _window.Size on Save (TryParseResolution parses "WIDTHxHEIGHT"). Live preview would be too jarring; resize is on Save only. · Fullscreen → _window.WindowState (Silk.NET borderless mode), also on Save only. · ShowFps → wraps the title-bar perf string. true → full perf line; false → just "acdream" for a cleaner alt-tab. Default true matches pre-L.0 behavior. Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase FovY = π/3), VSync true→false (matches the previous WindowOptions), ShowFps false→true (preserves the existing perf-in-title behavior). Net effect: a user who never opens Display tab + later opens it + Saves without touching anything sees ZERO visual change. Tests pinned to the new defaults. ApplyDisplayWindowState helper consolidates the window-side mutations. Called from the SettingsVM construction site (apply persisted at startup) and from the onSaveDisplay callback (apply saved on demand). Malformed resolution strings are silently ignored to avoid crashing mid-session if settings.json gets hand-edited. CHARACTER ↔ active toon: · _activeToonKey field replaces the hard-coded "default" — starts as "default" (used for any pre-login Settings interaction), gets swapped to the actual character.Name immediately after EnterWorld in BeginLiveSessionAsync. · onSaveCharacter callback closes over _activeToonKey by reference (lambda captures `this`), so saves always write to the current toon's slot without rebinding the lambda. · After EnterWorld lands the chosen toon's name, the host loads that toon's bag via SettingsStore.LoadCharacter and calls a new SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot AND draft atomically — HasUnsavedChanges stays false on login so the user doesn't see a "pending changes" indicator just because they switched toons. Per-toon storage already worked at the SettingsStore layer (commit 73749d1); this commit just plumbs the actual character name through to the toonKey instead of always using "default". 2 new tests for LoadCharacterContext: atomic persisted+draft swap, and pending edits getting wiped on swap (so pre-login bleed-through can't write to the new toon's slot). dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green (243 Core.Net + 393 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 144 ++++++++++++++++-- .../Panels/Settings/DisplaySettings.cs | 11 +- .../Panels/Settings/SettingsVM.cs | 15 ++ .../Panels/Settings/DisplaySettingsTests.cs | 19 ++- .../Panels/Settings/SettingsVMTests.cs | 39 ++++- 5 files changed, 200 insertions(+), 28 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6e102a5..e6b4f88 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,22 +901,34 @@ public sealed class GameWindow : IDisposable // the same OnLoad path (see _inputDispatcher field). if (_inputDispatcher is not null) { - // L.0 — settings.json (display + audio + future gameplay / - // chat / character tabs). Coexists with keybinds.json, - // which keeps its own load/save path. - var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + // L.0 — settings.json (display + audio + gameplay + chat + // + character). Coexists with keybinds.json, which + // keeps its own load/save path. Field-stored so the + // post-EnterWorld branch can re-load the chosen + // toon's character bag. + _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var settingsStore = _settingsStore; var persistedDisplay = settingsStore.LoadDisplay(); var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); var persistedChat = settingsStore.LoadChat(); - // Per-toon character settings keyed by name. We don't - // know which toon the user will pick until after - // CharacterList lands, so use a "default" bag for now. - // Future: swap to the actual toon name once a - // currentCharacter source is plumbed. - const string toonKey = "default"; - var persistedCharacter = settingsStore.LoadCharacter(toonKey); + // Character bag is loaded against _activeToonKey ("default" + // until BeginLiveSessionAsync swaps in the real name). + var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey); + + // L.0 Display tab — apply persisted window-level + // settings BEFORE the first frame so the user's saved + // VSync / Resolution / Fullscreen take effect from + // the start instead of flashing the WindowOptions + // defaults. FOV and ShowFps come through the + // per-frame push in OnRender so live preview works. + if (_window is not null) + { + if (_window.VSync != persistedDisplay.VSync) + _window.VSync = persistedDisplay.VSync; + ApplyDisplayWindowState(persistedDisplay); + } // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -957,6 +969,12 @@ public sealed class GameWindow : IDisposable 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) { @@ -1020,9 +1038,14 @@ public sealed class GameWindow : IDisposable { try { - settingsStore.SaveCharacter(toonKey, character); + // _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[{toonKey}] saved to " + $"settings: character[{_activeToonKey}] saved to " + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); } catch (Exception ex) @@ -1501,6 +1524,20 @@ public sealed class GameWindow : IDisposable _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); + + // L.0 Character tab: swap the SettingsVM's character bag + // from the "default" pre-login bag to the actual chosen + // toon's bag. Every Save from now on writes under the + // chosen toon's name. LoadCharacterContext rebinds BOTH + // persisted + draft so HasUnsavedChanges doesn't flag the + // swap as a pending edit. + _activeToonKey = chosen.Name; + if (_settingsStore is not null && _settingsVm is not null) + { + var toonBag = _settingsStore.LoadCharacter(_activeToonKey); + _settingsVm.LoadCharacterContext(toonBag); + Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); + } // Phase K.2: arm auto-entry. The guard's predicates won't // pass yet — the entity stream hasn't started — but the // OnUpdate tick re-checks every frame and fires once @@ -4209,6 +4246,25 @@ public sealed class GameWindow : IDisposable _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) @@ -4492,9 +4548,16 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + - $"lb {visibleLandblocks}/{totalLandblocks} visible | " + - $"ent {entityCount} | anim {animatedCount}"; + // L.0 Display tab: ShowFps gates the perf string in the + // title bar. Default is true (matches pre-L.0 behaviour); + // unchecking the toggle in Display tab collapses the title + // to just "acdream" for a cleaner alt-tab experience. + bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true; + _window!.Title = showFps + ? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + + $"ent {entityCount} | anim {animatedCount}" + : "acdream"; _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -5337,6 +5400,55 @@ 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 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/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index dd89b6c..505a2d3 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -22,14 +22,17 @@ public sealed record DisplaySettings( float Gamma, bool ShowFps) { - /// Values used on first launch / when settings.json is absent. + /// Values used on first launch / when settings.json is absent. + /// FieldOfView (60°) and VSync (false) match the camera + window + /// defaults that shipped before L.0, so opening Display + Save + /// without touching anything is a visual no-op. public static DisplaySettings Default { get; } = new( Resolution: "1920x1080", Fullscreen: false, - VSync: true, - FieldOfView: 75f, + VSync: false, + FieldOfView: 60f, Gamma: 1.0f, - ShowFps: false); + ShowFps: true); /// 16:9 resolution presets offered in the dropdown. public static IReadOnlyList AvailableResolutions { get; } = new[] diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index c32fade..f4bcc36 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -196,6 +196,21 @@ public sealed class SettingsVM _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; + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index f73db09..9e4e13d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -11,15 +11,19 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; public sealed class DisplaySettingsTests { [Fact] - public void Default_values_match_brainstorm_agreement() + public void Default_values_match_pre_L0_runtime_state() { + // Defaults pinned to match the camera FovY (60° = π/3) and the + // pre-L.0 window options (VSync off, FPS in title bar). Opening + // Display + Save without touching anything must NOT change the + // user's visual experience. var d = DisplaySettings.Default; Assert.Equal("1920x1080", d.Resolution); Assert.False(d.Fullscreen); - Assert.True(d.VSync); - Assert.Equal(75f, d.FieldOfView); + Assert.False(d.VSync); + Assert.Equal(60f, d.FieldOfView); Assert.Equal(1.0f, d.Gamma); - Assert.False(d.ShowFps); + Assert.True(d.ShowFps); } [Fact] @@ -43,8 +47,8 @@ public sealed class DisplaySettingsTests public void Equality_is_value_based() { var a = DisplaySettings.Default; - var b = DisplaySettings.Default with { ShowFps = true }; - var c = DisplaySettings.Default with { ShowFps = true }; + var b = DisplaySettings.Default with { Fullscreen = true }; + var c = DisplaySettings.Default with { Fullscreen = true }; Assert.NotEqual(a, b); Assert.Equal(b, c); } @@ -56,7 +60,8 @@ public sealed class DisplaySettingsTests Assert.Equal(90f, d.FieldOfView); // Other fields untouched. Assert.Equal("1920x1080", d.Resolution); - Assert.True(d.VSync); + Assert.False(d.VSync); + Assert.True(d.ShowFps); } private static int ParseWidth(string res) diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 1bedd6c..b892cf1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -271,7 +271,9 @@ public sealed class SettingsVMTests public void SetDisplay_marks_unsaved_changes() { var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); + // 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); } @@ -584,4 +586,39 @@ public sealed class SettingsVMTests Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); Assert.True(vm.HasUnsavedChanges); } + + [Fact] + public void LoadCharacterContext_swaps_persisted_and_draft_atomically() + { + // Simulates the post-EnterWorld toon swap — host loads the + // chosen toon's bag from disk and pushes it via + // LoadCharacterContext. BOTH persisted and draft must update + // so HasUnsavedChanges stays false; otherwise the user would + // see a "pending changes" indicator on every login. + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true }; + + vm.LoadCharacterContext(newToonBag); + + Assert.Equal(newToonBag, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void LoadCharacterContext_clears_pending_unsaved_character_edits() + { + // If the user had pending character edits from the previous + // toon (or pre-login session), swapping to a new toon's bag + // must wipe them — Save is per-toon, and bleed-through would + // write the pre-login bag's edits to the new toon's slot. + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); + Assert.True(vm.HasUnsavedChanges); + + vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); + + Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel); + Assert.False(vm.CharacterDraft.AutoAttack); + Assert.False(vm.HasUnsavedChanges); + } } From 4c75ced92bf6bf70e0d364e9aed53608f4bad1dd Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 21:45:39 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(ui):=20chat=20Copy=20mode=20?= =?UTF-8?q?=E2=80=94=20select=20+=20Ctrl+C=20any=20text=20in=20the=20chat?= =?UTF-8?q?=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported wanting to mark text in-game and copy it out (item names, coordinates, NPC dialogue, etc). ImGui doesn't natively let you select across multiple TextColored widgets, but a read-only multi-line InputText is fully click-drag selectable + Ctrl+C copyable. This commit adds a "Copy mode" toggle to ChatPanel that swaps the chat tail's render path between the colored-line view and a single selectable text region. New IPanelRenderer primitive: void TextMultilineReadOnly(string id, string content, Vector2 size); ImGui maps this to InputTextMultiline with the ReadOnly flag — same selection + Ctrl+C UX a user expects from any text-input widget. FakePanelRenderer records the call for tests. The future D.2b custom retail-look backend implements its own equivalent (likely the same widget pattern with retail font/skin). ChatPanel rendering: · A "Copy mode (select text to Ctrl+C)" Checkbox at the top of the panel toggles _copyMode. · Off (default) — current per-line render with colored combat entries. Visually unchanged from before. · On — the chat tail becomes a single TextMultilineReadOnly widget holding every visible line joined with newlines. Loses per-line color, gains arbitrary-span text selection. · Footer (separator + input field) renders identically in both modes so the user can still type while in copy mode. Existing ChatPanelLayoutTests's footer-separator probe was using IndexOf("Separator") — which now matches the new pre-tail separator between the Checkbox and the chat tail. Switched to LastIndexOf which still pins the footer separator (between EndChild and InputTextSubmit). Behaviour and intent unchanged. DisplaySettingsTests' With_expression test was still asserting the old "1920x1080" Default.Resolution; updated to the new "1280x720" that the previous wire-up commit introduced (the earlier commit forgot this one). dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green (243 Core.Net + 393 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 17 +++++++++ .../Panels/Chat/ChatPanel.cs | 37 ++++++++++++++++++- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 17 +++++++++ .../FakePanelRenderer.cs | 3 ++ .../Panels/Chat/ChatPanelLayoutTests.cs | 6 ++- .../Panels/Settings/DisplaySettingsTests.cs | 16 +++++--- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 3656d75..70696a2 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -262,4 +262,21 @@ public interface IPanelRenderer /// Close the tab opened by . void EndTabItem(); + + /// + /// Render a read-only multi-line text region the user can + /// select with click+drag and copy with Ctrl+C. + /// Matches the typical "click into a textbox to grab text" UX — + /// chat panels, log viewers, etc. use this to make text + /// extractable without the user having to alt-tab + retype. + /// + /// + /// The widget is sized to ; pass + /// (0, 0) for "fill the current content region" semantics + /// (matches ImGui defaults). is the ImGui + /// stable identifier — typically "##chatcopy" or similar + /// hidden-label form. + /// + /// + void TextMultilineReadOnly(string id, string content, Vector2 size); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index f3a4a07..28e035b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel // click into another widget. private bool _focusRequested; + // L.0 follow-up: "Copy mode" — when true, render the chat tail as + // a read-only multi-line text widget the user can click+drag to + // select + Ctrl+C to copy. Trades per-line color for selectability; + // user toggles when they want to grab specific text out of the + // log (item names, coordinates, NPC dialogue, etc). + private bool _copyMode; + public ChatPanel(ChatVM vm) { _vm = vm ?? throw new ArgumentNullException(nameof(vm)); @@ -82,6 +90,17 @@ public sealed class ChatPanel : IPanel return; } + // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the + // chat tail rendering swaps to TextMultilineReadOnly so the + // user can mark + Ctrl+C any text. Off (default) preserves the + // colored per-line render with combat highlights. The checkbox + // sits ABOVE the chat tail (not in the footer) so it's always + // visible regardless of scroll position. + bool copyMode = _copyMode; + if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode)) + _copyMode = copyMode; + renderer.Separator(); + // Phase J Tier 3: keep the input field at the bottom of the // window across resizes by reserving footer space and putting // the chat tail in a scrollable child that fills the rest. @@ -95,7 +114,21 @@ public sealed class ChatPanel : IPanel // the plain Text path (visually identical to the I.4 panel). var lines = _vm.RecentLinesDetailed(); - if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) + if (_copyMode) + { + // Copy mode: one big read-only multiline text widget + // holding every visible line, joined with newlines. Loses + // per-line color but lets the user click+drag to select + // arbitrary spans of text + Ctrl+C to copy. Sized to fill + // the available space minus the footer. + string joined = lines.Count == 0 + ? "(no messages yet)" + : string.Join("\n", lines.Select(l => l.Text)); + renderer.TextMultilineReadOnly( + "##chattailcopy", joined, + new System.Numerics.Vector2(0f, -footerHeight)); + } + else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) { if (lines.Count == 0) { @@ -127,7 +160,7 @@ public sealed class ChatPanel : IPanel } _lastRenderedCount = lines.Count; } - renderer.EndChild(); + if (!_copyMode) renderer.EndChild(); // Phase I.4: input field. Backend implementation clears _input // on submit per the IPanelRenderer contract. diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index d5eb978..4396874 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -207,4 +207,21 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// 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 3706188..9df8b12 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -231,4 +231,7 @@ internal sealed class FakePanelRenderer : IPanelRenderer } public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty())); + + public void TextMultilineReadOnly(string id, string content, Vector2 size) + => Calls.Add(("TextMultilineReadOnly", new object?[] { id, content, size })); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs index 9c2a04a..0e80233 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -33,7 +33,11 @@ public sealed class ChatPanelLayoutTests int beginIdx = methods.IndexOf("Begin"); int beginChildIdx = methods.IndexOf("BeginChild"); int endChildIdx = methods.IndexOf("EndChild"); - int separatorIdx = methods.IndexOf("Separator"); + // L.0 follow-up: Copy-mode toggle adds a Separator above the + // chat tail, so multiple Separators now exist. The footer + // separator (the one we care about for input layout) is the + // LAST one — between EndChild and the input field. + int separatorIdx = methods.LastIndexOf("Separator"); int inputSubmitIdx = methods.IndexOf("InputTextSubmit"); int endIdx = methods.IndexOf("End"); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index 9e4e13d..90b1bb2 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -13,12 +13,16 @@ public sealed class DisplaySettingsTests [Fact] public void Default_values_match_pre_L0_runtime_state() { - // Defaults pinned to match the camera FovY (60° = π/3) and the - // pre-L.0 window options (VSync off, FPS in title bar). Opening - // Display + Save without touching anything must NOT change the - // user's visual experience. + // 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("1920x1080", d.Resolution); + Assert.Equal("1280x720", d.Resolution); Assert.False(d.Fullscreen); Assert.False(d.VSync); Assert.Equal(60f, d.FieldOfView); @@ -59,7 +63,7 @@ public sealed class DisplaySettingsTests var d = DisplaySettings.Default with { FieldOfView = 90f }; Assert.Equal(90f, d.FieldOfView); // Other fields untouched. - Assert.Equal("1920x1080", d.Resolution); + Assert.Equal("1280x720", d.Resolution); Assert.False(d.VSync); Assert.True(d.ShowFps); } From 627325559c1dc49305a18e2437cd5a3c142e8d4e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 22:58:05 +0200 Subject: [PATCH 09/14] =?UTF-8?q?fix(ui):=20title-bar-only=20drag=20?= =?UTF-8?q?=E2=80=94=20absorb=20body=20clicks=20via=20InvisibleButton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that clicking anywhere in a panel (chat, settings, etc) started a window drag. ImGui's default window-drag init fires on any body click that doesn't land on an "active" widget — empty space between Text widgets, BeginChild background pad, etc. all qualified. Fix: right after Begin, place an InvisibleButton sized to the full body content region, then reset the cursor so subsequent panel content renders normally. ImGui's click-priority is "last drawn, first checked" — so real widgets drawn afterwards still claim their own clicks. The InvisibleButton catches ONLY clicks on empty body space, marks itself as the active item, and ImGui's window-drag check sees ActiveId != 0 → no drag. Net effect: title bar still drags (ImGui default), body never drags. Applies uniformly to every panel that calls IPanelRenderer.Begin (chat / settings / vitals / debug). dotnet build green (0 warnings). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index 4396874..94f09d4 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -14,7 +14,30 @@ namespace AcDream.UI.ImGui; public sealed class ImGuiPanelRenderer : IPanelRenderer { /// - public bool Begin(string title) => ImGuiNET.ImGui.Begin(title); + public bool Begin(string title) + { + bool open = ImGuiNET.ImGui.Begin(title); + if (open) + { + // Title-bar-only drag: ImGui's default lets the user drag the + // window by clicking on the empty body background (because a + // window-drag is initiated whenever a body click lands without + // any widget being "active"). Filling the body with an + // InvisibleButton absorbs those stray clicks — real widgets + // drawn afterwards still claim their own clicks because click + // priority is "last drawn, first checked", so the button + // catches only empty-space clicks. Net effect: title bar + // still drags (ImGui default), body never does. + var avail = ImGuiNET.ImGui.GetContentRegionAvail(); + if (avail.X > 0f && avail.Y > 0f) + { + var savedCursor = ImGuiNET.ImGui.GetCursorPos(); + ImGuiNET.ImGui.InvisibleButton("##bodydragabsorb", avail); + ImGuiNET.ImGui.SetCursorPos(savedCursor); + } + } + return open; + } /// public void End() => ImGuiNET.ImGui.End(); From 2818fcca8c68bc34077d08daae9f3149bc86d9c2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 23:04:10 +0200 Subject: [PATCH 10/14] =?UTF-8?q?fix(ui):=20scope=20title-bar-only-drag=20?= =?UTF-8?q?absorber=20to=20BeginChild=20=E2=80=94=20Settings=20tabs=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix put the InvisibleButton absorber inside Begin, which covered the entire panel body — and the Settings panel's tab bar has its hit-testing in that same area. Tabs lost click priority to the absorber (their hover/click events were stolen) so the user couldn't switch tabs. Worse, the chat-panel drag the absorber was supposed to fix wasn't actually fixed because chat's body is covered by a BeginChild for the scrollable tail — clicks land in the child window, not the parent body, so the parent absorber never sees them. Right scope: scrollable BeginChild bodies. That's where the chat panel's empty-space clicks actually land, and where the parent- drag fall-through originates. Other panels (Settings, Vitals, Debug) don't use BeginChild for content — their bodies are filled with widgets that already absorb clicks naturally. The fix: · Begin reverts to ImGui default (title bar drags, body of widget- filled panels naturally absorbs through the widgets themselves). · BeginChild grows the InvisibleButton absorber inside, so empty- space clicks inside a scroll region don't fall through to the parent's window-drag init. Net effect: · Chat panel: empty clicks in the scroll tail no longer drag the parent window. · Settings panel: tabs are clickable again. · Vitals, Debug: unchanged. dotnet build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 56 ++++++++++++---------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index 94f09d4..4aa94ae 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -14,30 +14,7 @@ namespace AcDream.UI.ImGui; public sealed class ImGuiPanelRenderer : IPanelRenderer { /// - public bool Begin(string title) - { - bool open = ImGuiNET.ImGui.Begin(title); - if (open) - { - // Title-bar-only drag: ImGui's default lets the user drag the - // window by clicking on the empty body background (because a - // window-drag is initiated whenever a body click lands without - // any widget being "active"). Filling the body with an - // InvisibleButton absorbs those stray clicks — real widgets - // drawn afterwards still claim their own clicks because click - // priority is "last drawn, first checked", so the button - // catches only empty-space clicks. Net effect: title bar - // still drags (ImGui default), body never does. - var avail = ImGuiNET.ImGui.GetContentRegionAvail(); - if (avail.X > 0f && avail.Y > 0f) - { - var savedCursor = ImGuiNET.ImGui.GetCursorPos(); - ImGuiNET.ImGui.InvisibleButton("##bodydragabsorb", avail); - ImGuiNET.ImGui.SetCursorPos(savedCursor); - } - } - return open; - } + public bool Begin(string title) => ImGuiNET.ImGui.Begin(title); /// public void End() => ImGuiNET.ImGui.End(); @@ -178,12 +155,41 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer /// public bool BeginChild(string id, Vector2 size, bool border = false) + { // ImGuiChildFlags has changed names across ImGui.NET versions // (Border vs Borders); 0x01 is the stable bit value for "draw // a border". Casting from a numeric literal sidesteps the // version-skew without requiring a hard reference to either // enum spelling. - => ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); + bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0)); + if (open) + { + // Title-bar-only drag fix (chat tail specifically): empty + // clicks inside a scrollable child fall through to the + // parent window for drag-init, which is exactly what the + // user reported in the chat panel ("clicking anywhere + // moves the window"). An InvisibleButton sized to the + // child's content region absorbs those clicks so they + // don't propagate. Real widgets drawn afterwards still + // claim their own clicks (click priority = "last drawn, + // first checked"). Wheel scrolling is window-level, not + // item-level, so the absorber doesn't interfere with + // the chat tail's auto-scroll. + // + // Scoped to BeginChild only (NOT Begin) because Begin's + // body might host tab bars whose hit-testing competes with + // an absorber on equal terms — adding it at Begin level + // broke Settings tab clicks. + var avail = ImGuiNET.ImGui.GetContentRegionAvail(); + if (avail.X > 0f && avail.Y > 0f) + { + var savedCursor = ImGuiNET.ImGui.GetCursorPos(); + ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail); + ImGuiNET.ImGui.SetCursorPos(savedCursor); + } + } + return open; + } /// public void EndChild() => ImGuiNET.ImGui.EndChild(); From df9f2fd3dab5a60d9e418cabfe61ade1e98ff4cc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 23:10:01 +0200 Subject: [PATCH 11/14] fix(ui): wrap chat panel body in outer BeginChild so drag-trap covers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InvisibleButton drag-trap inside BeginChild only catches clicks inside that specific child. Chat had widgets OUTSIDE the inner ##chattail child (the Copy-mode Checkbox + a Separator at top, the footer Separator + InputTextSubmit at bottom) — empty space around those widgets fell through directly to the parent window's window-drag init. Fix: wrap the entire chat panel body in a single outer ##chatbody BeginChild before drawing any content. The renderer's drag-trap fires inside this outer child too, absorbing every empty-space click in the chat panel body. The inner ##chattail child is now nested inside it, which doesn't change its scroll-tail semantics but does mean it gets its own drag-trap as a bonus. Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight was using Single(BeginChild) — there are now two BeginChild calls (##chatbody outer + ##chattail inner). Switched to Single(... && Args[0] == "##chattail") so the test still pins the footer reserve on the inner call where it lives. dotnet build green; 1,309 / 1,309 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Panels/Chat/ChatPanel.cs | 17 +++++++++++++++++ .../Panels/Chat/ChatPanelLayoutTests.cs | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 28e035b..c8ece99 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -90,6 +90,20 @@ 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 @@ -186,6 +200,7 @@ public sealed class ChatPanel : IPanel if (TryHandleClientCommand(trimmed)) { _input = string.Empty; + renderer.EndChild(); // outer ##chatbody renderer.End(); return; } @@ -206,6 +221,7 @@ public sealed class ChatPanel : IPanel _vm.ShowSystemMessage( $"Unknown command: {verb}. Type /help for the list of supported commands."); _input = string.Empty; + renderer.EndChild(); // outer ##chatbody renderer.End(); return; } @@ -225,6 +241,7 @@ public sealed class ChatPanel : IPanel _input = string.Empty; } + renderer.EndChild(); // outer ##chatbody renderer.End(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs index 0e80233..28813d3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs @@ -67,8 +67,13 @@ public sealed class ChatPanelLayoutTests panel.Render(new PanelContext(0.016f, new NoBus()), renderer); - var beginChildCall = renderer.Calls.Single(c => c.Method == "BeginChild"); - var size = (System.Numerics.Vector2)beginChildCall.Args[1]!; + // L.0 follow-up: the chat panel now wraps its body in an outer + // ##chatbody BeginChild (so empty-space clicks can't drag the + // parent window). The inner ##chattail BeginChild is the one + // that reserves the footer; that's what this test asserts. + var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild" + && (string)c.Args[0]! == "##chattail"); + var size = (System.Numerics.Vector2)chattailCall.Args[1]!; // Width 0 = fill available; height < 0 = "fill minus this". // Reserved height should equal FrameHeightWithSpacing + a small // separator pad (~6f) so the input never visually clips the From 944a0364c50c40165d9429578c3ce453e3859cc6 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 06:19:12 +0200 Subject: [PATCH 12/14] =?UTF-8?q?fix(ui):=20commit=20FramebufferResize=20+?= =?UTF-8?q?=20ResetPanelLayout=20=E2=80=94=20orphaned=20during=20earlier?= =?UTF-8?q?=20cwd/sed=20shenanigans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These changes were referenced by commits fc1e193 / 6273255 / 2818fcc / df9f2fd in their messages but the actual edits sat uncommitted in the working tree — caught by the pre-merge code review pass. Without this commit the merge to main would lose all the panel-layout fixes the user already live-verified. What was orphaned: · _window.FramebufferResize += OnFramebufferResize (Run() wiring) · OnFramebufferResize handler — updates GL viewport + camera aspect on window resize; force-resets panel layout via ResetPanelLayout. · ResetPanelLayout(ImGuiCond) — positions Vitals / Chat / Debug / Settings panels at sensible defaults relative to current window size. Called at startup with FirstUseEver (imgui.ini wins on later launches) and on FramebufferResize / View menu item with Always (force reset). · View → "Reset window layout" menu item. · OnLoad seeding ResetPanelLayout(FirstUseEver) after panel registration so first-launch users don't see all panels stacked at (0,0). · DisplaySettings.Default.Resolution: "1920x1080" → "1280x720" so the default matches the WindowOptions startup size — opening Display + Save without edits is a complete visual no-op (the alternative would have triggered an immediate resize on every first-time Save). dotnet build green; tests unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 95 +++++++++++++++++++ .../Panels/Settings/DisplaySettings.cs | 11 ++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e6b4f88..ef9b749 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -572,6 +572,12 @@ public sealed class GameWindow : IDisposable _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; + // L.0 Display tab: keep the GL viewport + camera aspect in sync + // with the window framebuffer. Without this handler, resizing + // the window (or applying a Display-tab Resolution change at + // startup) leaves the viewport pinned to the original size — + // user sees a small render in the corner of a big window. + _window.FramebufferResize += OnFramebufferResize; _window.Run(); } @@ -1058,6 +1064,14 @@ public sealed class GameWindow : IDisposable } 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) { @@ -4515,6 +4529,15 @@ public sealed class GameWindow : IDisposable if (_debugPanel is not null && ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1")) _debugPanel.IsVisible = !_debugPanel.IsVisible; + ImGuiNET.ImGui.Separator(); + // L.0 Display tab: a manual reset for users whose + // imgui.ini has saved a panel position that's now + // off-screen (after a window shrink, monitor swap, + // or a malformed save). Force-resets every panel + // to its default landing position. The same code + // path runs automatically on FramebufferResize. + if (ImGuiNET.ImGui.MenuItem("Reset window layout")) + ResetPanelLayout(ImGuiNET.ImGuiCond.Always); ImGuiNET.ImGui.EndMenu(); } // K-fix2 (2026-04-26): Camera submenu — discoverable @@ -5408,6 +5431,78 @@ public sealed class GameWindow : IDisposable private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; private string _activeToonKey = "default"; + /// + /// 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 diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 505a2d3..05438b0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -23,11 +23,14 @@ public sealed record DisplaySettings( bool ShowFps) { /// Values used on first launch / when settings.json is absent. - /// FieldOfView (60°) and VSync (false) match the camera + window - /// defaults that shipped before L.0, so opening Display + Save - /// without touching anything is a visual no-op. + /// 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: "1920x1080", + Resolution: "1280x720", Fullscreen: false, VSync: false, FieldOfView: 60f, From a37ebdebff7a68774f703bf536bce1070c0b8c50 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 06:22:35 +0200 Subject: [PATCH 13/14] =?UTF-8?q?fix(ui):=20pre-merge=20code=20review=20?= =?UTF-8?q?=E2=80=94=20apply=20persisted=20settings=20without=20devtools,?= =?UTF-8?q?=20hide=20inert=20sliders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two should-fix items from the pre-merge code review pass: 1. Persisted settings now apply on startup unconditionally (previously gated on ACDREAM_DEVTOOLS=1). 2. Music + Ambient volume sliders are hidden because the underlying engine paths don't exist yet (R5 MIDI playback). == 1. Settings load + apply outside DevToolsEnabled gate == Previous structure put SettingsStore construction, LoadDisplay / LoadAudio / etc, and ApplyDisplayWindowState inside the `if (DevToolsEnabled)` block. A user running with the env var unset silently got WindowOptions defaults (1280x720 / VSync=false / 60° FOV) instead of their saved settings.json values — even though the settings file existed and was valid. Refactored: extracted LoadAndApplyPersistedSettings() that runs unconditionally in OnLoad after _audioEngine is constructed but before the DevToolsEnabled block. Persisted values cached as _persistedDisplay / _persistedAudio / _persistedGameplay / _persistedChat / _persistedCharacter fields. The Settings PANEL construction (devtools-gated, naturally — no UI without ImGui) now reads those fields when wiring SettingsVM. The Settings UI gating is correct (panel needs ImGui devtools); the persisted-runtime-state gating was the bug. == 2. Music + Ambient sliders hidden == OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume properties but they're never read — PlayMusic is a stub for R5 MIDI playback that hasn't shipped, StartAmbient reserves a handle but doesn't start a source. Dragging those sliders moved a number that nothing observed. Hid the Music + Ambient sliders from RenderAudioTab; left the AudioSettings record fields intact so settings.json round-trips the values across phases — when R5 lands and the sliders return, saved values will already be in place. Updated the panel's footer note to call out the limitation. Updated Audio_tab_when_active_renders_implemented_volume_sliders to assert Master + SFX are present AND Music + Ambient are absent. dotnet build green; dotnet test 1,309 / 1,309 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 131 ++++++++++++------ .../Panels/Settings/SettingsPanel.cs | 34 +++-- .../Panels/Settings/SettingsPanelTests.cs | 11 +- 3 files changed, 114 insertions(+), 62 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ef9b749..6b498db 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -816,6 +816,17 @@ public sealed class GameWindow : IDisposable } } + // L.0 follow-up — load + apply persisted Display / Audio settings + // BEFORE the DevToolsEnabled block. The settings.json values + // (resolution, vsync, FOV, master volume, etc) are runtime + // settings, not devtools settings — a user running without + // ACDREAM_DEVTOOLS=1 still expects their saved values to take + // effect. The Settings PANEL (editing UI) is gated on devtools; + // the persisted state is not. Caches values into fields so the + // SettingsVM construction in the devtools block reads them + // without re-loading. + LoadAndApplyPersistedSettings(); + // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. @@ -905,48 +916,15 @@ public sealed class GameWindow : IDisposable // the draft. Construction is null-safe vs. the // dispatcher because the dispatcher is built earlier in // the same OnLoad path (see _inputDispatcher field). - if (_inputDispatcher is not null) + if (_inputDispatcher is not null && _settingsStore is not null) { - // L.0 — settings.json (display + audio + gameplay + chat - // + character). Coexists with keybinds.json, which - // keeps its own load/save path. Field-stored so the - // post-EnterWorld branch can re-load the chosen - // toon's character bag. - _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var settingsStore = _settingsStore; - var persistedDisplay = settingsStore.LoadDisplay(); - var persistedAudio = settingsStore.LoadAudio(); - var persistedGameplay = settingsStore.LoadGameplay(); - var persistedChat = settingsStore.LoadChat(); - // Character bag is loaded against _activeToonKey ("default" - // until BeginLiveSessionAsync swaps in the real name). - var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey); - - // L.0 Display tab — apply persisted window-level - // settings BEFORE the first frame so the user's saved - // VSync / Resolution / Fullscreen take effect from - // the start instead of flashing the WindowOptions - // defaults. FOV and ShowFps come through the - // per-frame push in OnRender so live preview works. - if (_window is not null) - { - if (_window.VSync != persistedDisplay.VSync) - _window.VSync = persistedDisplay.VSync; - ApplyDisplayWindowState(persistedDisplay); - } - - // Apply persisted audio to the engine BEFORE the panel - // host starts pushing per-frame so the first frame uses - // the user's saved values instead of engine defaults. - 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 — 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,7 +944,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"keybinds: save failed: {ex.Message}"); } }, - persistedDisplay: persistedDisplay, + persistedDisplay: _persistedDisplay, onSaveDisplay: display => { try @@ -987,7 +965,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"settings: display save failed: {ex.Message}"); } }, - persistedAudio: persistedAudio, + persistedAudio: _persistedAudio, onSaveAudio: audio => { try @@ -1002,7 +980,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"settings: audio save failed: {ex.Message}"); } }, - persistedGameplay: persistedGameplay, + persistedGameplay: _persistedGameplay, onSaveGameplay: gameplay => { try @@ -1020,7 +998,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"settings: gameplay save failed: {ex.Message}"); } }, - persistedChat: persistedChat, + persistedChat: _persistedChat, onSaveChat: chat => { try @@ -1039,7 +1017,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"settings: chat save failed: {ex.Message}"); } }, - persistedCharacter: persistedCharacter, + persistedCharacter: _persistedCharacter, onSaveCharacter: character => { try @@ -5430,6 +5408,67 @@ public sealed class GameWindow : IDisposable // 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 diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index b0fec97..a8a8034 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -226,11 +226,15 @@ public sealed class SettingsPanel : IPanel } /// - /// Render the Audio tab — four volume sliders (Master / Music / SFX / - /// Ambient). Volumes update live: the host pushes the VM's - /// AudioDraft into the running OpenAL engine each frame, so dragging - /// a slider is audible immediately. Cancel reverts the draft and the - /// engine catches up on the next frame. + /// 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) { @@ -240,22 +244,26 @@ public sealed class SettingsPanel : IPanel if (renderer.SliderFloat("Master", ref master, 0f, 1f)) _vm.SetAudio(a with { Master = master }); - float music = a.Music; - if (renderer.SliderFloat("Music", ref music, 0f, 1f)) - _vm.SetAudio(a with { Music = music }); - float sfx = a.Sfx; if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) _vm.SetAudio(a with { Sfx = sfx }); - float ambient = a.Ambient; - if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f)) - _vm.SetAudio(a with { Ambient = ambient }); + // 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."); + + "values to settings.json; Cancel reverts to the saved values. " + + "Music + Ambient mixing arrives with R5 MIDI playback."); } /// diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 7e8c1ef..6d51c51 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -346,8 +346,13 @@ public sealed class SettingsPanelTests // -- Audio tab content ----------------------------------------------- [Fact] - public void Audio_tab_when_active_renders_four_volume_sliders() + 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" }; @@ -356,9 +361,9 @@ public sealed class SettingsPanelTests var sliders = r.Calls.Where(c => c.Method == "SliderFloat") .Select(c => (string)c.Args[0]!).ToList(); Assert.Contains("Master", sliders); - Assert.Contains("Music", sliders); Assert.Contains("SFX", sliders); - Assert.Contains("Ambient", sliders); + Assert.DoesNotContain("Music", sliders); + Assert.DoesNotContain("Ambient", sliders); } [Fact] From 23aa01738f5bcc122e38db0cade98c9c0e6498ce Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 06:24:24 +0200 Subject: [PATCH 14/14] docs(roadmap): mark Phase K + Phase L.0 shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K shipped previously (commit f42c164) but never got a row in the "Phases already shipped" table — only the per-sub-piece K.3 callout in the Phase K section. Adding the K row here for completeness. L.0 — full retail-style Settings interface — shipped this session. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-04-11-roadmap.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9d3661c..9435f82 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -54,6 +54,8 @@ | I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ | | I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ | | I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ | +| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | +| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost