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