From 7665cdf6429b0c881de9e54801fbd9e2780b532d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 17:39:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20tabbed=20Settings=20shell=20?= =?UTF-8?q?=E2=80=94=20IPanelRenderer=20tab=20API=20+=206=20placeholder=20?= =?UTF-8?q?tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 — foundation for the complete retail-style Settings interface agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11 panel into a tabbed shell whose first tab wraps the existing keybinds content unchanged; the other five tabs (Display / Audio / Gameplay / Chat / Character) render "Coming soon" placeholders so the shape the user approved is visible immediately and gets filled in over the L.x sub-phases (Display first per Easy-wins build order). Why a tab API extension: retail had distinct Options UIs (gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the PDB at acclient_2013_pseudo_c.txt:170739+) and the existing IPanelRenderer only exposed CollapsingHeader. ImGui maps BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new primitives stay backend-friendly — the future D.2b custom retail-look backend implements them via the retail tab UIs without panel changes. Save / Cancel / Reset-all stay above the tab bar so they remain global across all tabs (Phase K's UX preserved). FakePanelRenderer grows matching tab calls + an ActiveTabLabel knob so tests can target a specific tab's content; default behavior treats the first tab item seen as active so existing tests keep passing without changes. 5 new SettingsPanelTests assertions: tab bar opens once, six expected tab labels emitted in order, Keybinds-tab section headers only render when active, placeholders show "Coming soon" text on inactive-content tabs, and Save/Cancel buttons render BEFORE the tab bar (regression guard against accidentally moving them inside a tab item). dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green (243 Core.Net + 311 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 27 ++++++ .../Panels/Settings/SettingsPanel.cs | 90 +++++++++++++++---- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 14 +++ .../FakePanelRenderer.cs | 33 +++++++ .../Panels/Settings/SettingsPanelTests.cs | 89 ++++++++++++++++++ 5 files changed, 235 insertions(+), 18 deletions(-) diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 1c0cb2c..3656d75 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -235,4 +235,31 @@ public interface IPanelRenderer /// frame the user clicks the item; false otherwise. /// bool MenuItem(string label, string? shortcut = null); + + // -- Tab bar (Settings panel + future tabbed surfaces) --------------- + + /// + /// Open a tab bar inside the current window. Returns true + /// when the bar is visible — only emit + /// calls inside that branch. Always pair with + /// when the call returned true. Retail had + /// tab bars in the Options UIs (gmGameplayOptionsUI etc), so + /// this primitive must be expressible by the future custom + /// retail-look backend. + /// + bool BeginTabBar(string id); + + /// Close the tab bar opened by . + void EndTabBar(); + + /// + /// Begin a single tab inside an open . + /// Returns true when the tab is the currently selected one + /// — only render this tab's content in that branch. Always pair + /// with when the call returned true. + /// + bool BeginTabItem(string label); + + /// Close the tab opened by . + void EndTabItem(); } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 841b394..7def104 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// -/// K.3: in-game Settings panel for click-to-rebind keymap editing. -/// Hidden by default; opens via F11 (which fires the -/// action) or via the -/// View → Settings entry on the main menu bar. +/// In-game Settings panel — F11 toggle (or View → Settings on the main +/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then +/// Display / Audio / Gameplay / Chat / Character (filling in over the +/// L.x sub-phases). /// /// -/// Layout: top row of action buttons (Save / Cancel / Reset all), then -/// a sequence of sections -/// matching the retail keymap categories (Movement / Postures / Camera / -/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a -/// section: action name, current binding(s) summary, "Rebind" button, -/// per-action "Reset" button. When a rebind is in progress the Rebind -/// button label changes to "Press a key... (Esc to cancel)". +/// Top of the panel: Save / Cancel / Reset-all action buttons (global +/// across all tabs). When is +/// non-null, a confirmation prompt is rendered above those buttons +/// (Yes — Reassign / No — Keep existing). /// /// /// -/// When is non-null, a -/// confirmation prompt is rendered ABOVE the rest of the panel (Yes — -/// Reassign / No — Keep existing). +/// Below the action row a tab bar selects between the six categories. +/// Only the Keybinds tab is implemented today; the other five render +/// "Coming soon" placeholders so the structure the user approved in the +/// design brainstorm is visible immediately. /// /// public sealed class SettingsPanel : IPanel @@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel public string Title => "Settings"; /// - /// K.3: hidden by default — opened via F11 / View menu. + /// Hidden by default — opened via F11 / View menu. public bool IsVisible { get; set; } = false; /// @@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); } - // Top action buttons. + // Top action buttons. Global across all tabs. if (renderer.Button("Save changes")) _vm.Save(); renderer.SameLine(); if (renderer.Button("Cancel changes")) _vm.Cancel(); @@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel renderer.Separator(); - // Sections (retail keymap categories). + if (renderer.BeginTabBar("settings.tabs")) + { + if (renderer.BeginTabItem("Keybinds")) + { + RenderKeybindsTab(renderer); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Display")) + { + RenderPlaceholder(renderer, "Display"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Audio")) + { + RenderPlaceholder(renderer, "Audio"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Gameplay")) + { + RenderPlaceholder(renderer, "Gameplay"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Chat")) + { + RenderPlaceholder(renderer, "Chat"); + renderer.EndTabItem(); + } + if (renderer.BeginTabItem("Character")) + { + RenderPlaceholder(renderer, "Character"); + renderer.EndTabItem(); + } + renderer.EndTabBar(); + } + + renderer.End(); + } + + /// + /// Render the Keybinds tab — eight collapsing-header sections matching + /// the retail keymap categories. Phase K shipped this content; the + /// only thing that changed is the wrapping tab item. + /// + private void RenderKeybindsTab(IPanelRenderer renderer) + { RenderSection(renderer, "Movement", new[] { InputAction.MovementForward, InputAction.MovementBackup, @@ -136,8 +178,20 @@ public sealed class SettingsPanel : IPanel InputAction.Cry, InputAction.Laugh, InputAction.Wave, InputAction.Cheer, InputAction.PointState, }); + } - renderer.End(); + /// + /// Placeholder content shown for tabs whose implementation is still + /// pending. Reads as "Coming soon" plus a note about which sub-phase + /// is expected to fill it in. + /// + private static void RenderPlaceholder(IPanelRenderer renderer, string tabName) + { + renderer.TextWrapped($"{tabName} settings coming soon."); + renderer.Spacing(); + renderer.TextWrapped( + "This tab is part of the staged Settings interface rollout. " + + "Build order: Display → Audio → Gameplay → Chat → Character."); } private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index ec00037..d5eb978 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -193,4 +193,18 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer => shortcut is null ? ImGuiNET.ImGui.MenuItem(label) : ImGuiNET.ImGui.MenuItem(label, shortcut); + + // -- Tab bar ----------------------------------------------------------- + + /// + public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id); + + /// + public void EndTabBar() => ImGuiNET.ImGui.EndTabBar(); + + /// + public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label); + + /// + public void EndTabItem() => ImGuiNET.ImGui.EndTabItem(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs index f1c8c4d..3706188 100644 --- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -198,4 +198,37 @@ internal sealed class FakePanelRenderer : IPanelRenderer Calls.Add(("MenuItem", new object?[] { label, shortcut })); return MenuItemReturns; } + + // -- Tab bar ----------------------------------------------------------- + + /// Pre-set return for . + public bool TabBarReturns { get; set; } = true; + + /// The label of the tab the next + /// call should report as "selected" (return true). All other tab + /// items return false. Defaults to null = the FIRST tab item rendered + /// is the selected one. + public string? ActiveTabLabel { get; set; } + + private string? _firstTabSeen; + + public bool BeginTabBar(string id) + { + Calls.Add(("BeginTabBar", new object?[] { id })); + _firstTabSeen = null; + return TabBarReturns; + } + + public void EndTabBar() => Calls.Add(("EndTabBar", Array.Empty())); + + public bool BeginTabItem(string label) + { + Calls.Add(("BeginTabItem", new object?[] { label })); + _firstTabSeen ??= label; + return ActiveTabLabel is null + ? string.Equals(label, _firstTabSeen, StringComparison.Ordinal) + : string.Equals(label, ActiveTabLabel, StringComparison.Ordinal); + } + + public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty())); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 74b88e7..2d894ac 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -165,4 +165,93 @@ public sealed class SettingsPanelTests var (panel, _, _, _) = Build(); Assert.Equal("acdream.settings", panel.Id); } + + // -- Tabbed shell ----------------------------------------------------- + + [Fact] + public void Render_opens_tab_bar_with_six_tab_items() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer(); + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + // BeginTabBar exactly once, EndTabBar exactly once. + Assert.Single(r.Calls, c => c.Method == "BeginTabBar"); + Assert.Single(r.Calls, c => c.Method == "EndTabBar"); + + // The six tab labels approved in the design brainstorm. + var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Equal( + new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }, + tabLabels); + } + + [Fact] + public void Keybinds_tab_renders_section_headers_when_active() + { + var (panel, _, _, _) = Build(); + // Default ActiveTabLabel = null → FakePanelRenderer treats the + // first tab item ("Keybinds") as active. + var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Movement", headers); + Assert.Contains("Hotbar", headers); + Assert.Contains("Emotes", headers); + } + + [Fact] + public void Inactive_tabs_do_not_render_keybind_section_headers() + { + var (panel, _, _, _) = Build(); + // Force "Display" to be the active tab — the Keybinds content + // must NOT render. + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Movement", headers); + Assert.DoesNotContain("Hotbar", headers); + } + + [Fact] + public void Placeholder_tabs_render_coming_soon_text_when_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains(wrapped, t => t.Contains("Audio settings coming soon")); + } + + [Fact] + public void Save_Cancel_buttons_render_outside_the_tab_bar() + { + // The global Save / Cancel / Reset-all row must come BEFORE + // BeginTabBar so it stays visible on every tab. Any change that + // accidentally moves the buttons inside a tab item should fail + // here. + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer(); + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + int saveIdx = r.Calls.FindIndex(c => c.Method == "Button" + && (string)c.Args[0]! == "Save changes"); + int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar"); + Assert.True(saveIdx >= 0); + Assert.True(tabBarIdx >= 0); + Assert.True(saveIdx < tabBarIdx, + $"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx})."); + } }