feat(ui): tabbed Settings shell — IPanelRenderer tab API + 6 placeholder tabs

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 17:39:36 +02:00
parent 5145938d06
commit 7665cdf642
5 changed files with 235 additions and 18 deletions

View file

@ -235,4 +235,31 @@ public interface IPanelRenderer
/// frame the user clicks the item; false otherwise.
/// </summary>
bool MenuItem(string label, string? shortcut = null);
// -- Tab bar (Settings panel + future tabbed surfaces) ---------------
/// <summary>
/// Open a tab bar inside the current window. Returns <c>true</c>
/// when the bar is visible — only emit <see cref="BeginTabItem"/>
/// calls inside that branch. Always pair with
/// <see cref="EndTabBar"/> when the call returned true. Retail had
/// tab bars in the Options UIs (<c>gmGameplayOptionsUI</c> etc), so
/// this primitive must be expressible by the future custom
/// retail-look backend.
/// </summary>
bool BeginTabBar(string id);
/// <summary>Close the tab bar opened by <see cref="BeginTabBar"/>.</summary>
void EndTabBar();
/// <summary>
/// Begin a single tab inside an open <see cref="BeginTabBar"/>.
/// Returns <c>true</c> when the tab is the currently selected one
/// — only render this tab's content in that branch. Always pair
/// with <see cref="EndTabItem"/> when the call returned true.
/// </summary>
bool BeginTabItem(string label);
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
void EndTabItem();
}

View file

@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// K.3: in-game Settings panel for click-to-rebind keymap editing.
/// Hidden by default; opens via <c>F11</c> (which fires the
/// <see cref="InputAction.ToggleOptionsPanel"/> 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).
///
/// <para>
/// Layout: top row of action buttons (Save / Cancel / Reset all), then
/// a sequence of <see cref="IPanelRenderer.CollapsingHeader"/> 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 <see cref="SettingsVM.PendingConflict"/> is
/// non-null, a confirmation prompt is rendered above those buttons
/// (Yes — Reassign / No — Keep existing).
/// </para>
///
/// <para>
/// When <see cref="SettingsVM.PendingConflict"/> 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.
/// </para>
/// </summary>
public sealed class SettingsPanel : IPanel
@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel
public string Title => "Settings";
/// <inheritdoc />
/// <remarks>K.3: hidden by default — opened via F11 / View menu.</remarks>
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
public bool IsVisible { get; set; } = false;
/// <inheritdoc />
@ -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();
}
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>
/// 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.
/// </summary>
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)

View file

@ -193,4 +193,18 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
=> shortcut is null
? ImGuiNET.ImGui.MenuItem(label)
: ImGuiNET.ImGui.MenuItem(label, shortcut);
// -- Tab bar -----------------------------------------------------------
/// <inheritdoc />
public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
/// <inheritdoc />
public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
/// <inheritdoc />
public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
/// <inheritdoc />
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
}