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:
parent
5145938d06
commit
7665cdf642
5 changed files with 235 additions and 18 deletions
|
|
@ -235,4 +235,31 @@ public interface IPanelRenderer
|
||||||
/// frame the user clicks the item; false otherwise.
|
/// frame the user clicks the item; false otherwise.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool MenuItem(string label, string? shortcut = null);
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input;
|
||||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.3: in-game Settings panel for click-to-rebind keymap editing.
|
/// In-game Settings panel — F11 toggle (or View → Settings on the main
|
||||||
/// Hidden by default; opens via <c>F11</c> (which fires the
|
/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then
|
||||||
/// <see cref="InputAction.ToggleOptionsPanel"/> action) or via the
|
/// Display / Audio / Gameplay / Chat / Character (filling in over the
|
||||||
/// View → Settings entry on the main menu bar.
|
/// L.x sub-phases).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Layout: top row of action buttons (Save / Cancel / Reset all), then
|
/// Top of the panel: Save / Cancel / Reset-all action buttons (global
|
||||||
/// a sequence of <see cref="IPanelRenderer.CollapsingHeader"/> sections
|
/// across all tabs). When <see cref="SettingsVM.PendingConflict"/> is
|
||||||
/// matching the retail keymap categories (Movement / Postures / Camera /
|
/// non-null, a confirmation prompt is rendered above those buttons
|
||||||
/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a
|
/// (Yes — Reassign / No — Keep existing).
|
||||||
/// 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)".
|
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When <see cref="SettingsVM.PendingConflict"/> is non-null, a
|
/// Below the action row a tab bar selects between the six categories.
|
||||||
/// confirmation prompt is rendered ABOVE the rest of the panel (Yes —
|
/// Only the Keybinds tab is implemented today; the other five render
|
||||||
/// Reassign / No — Keep existing).
|
/// "Coming soon" placeholders so the structure the user approved in the
|
||||||
|
/// design brainstorm is visible immediately.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SettingsPanel : IPanel
|
public sealed class SettingsPanel : IPanel
|
||||||
|
|
@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel
|
||||||
public string Title => "Settings";
|
public string Title => "Settings";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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;
|
public bool IsVisible { get; set; } = false;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel
|
||||||
renderer.Separator();
|
renderer.Separator();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top action buttons.
|
// Top action buttons. Global across all tabs.
|
||||||
if (renderer.Button("Save changes")) _vm.Save();
|
if (renderer.Button("Save changes")) _vm.Save();
|
||||||
renderer.SameLine();
|
renderer.SameLine();
|
||||||
if (renderer.Button("Cancel changes")) _vm.Cancel();
|
if (renderer.Button("Cancel changes")) _vm.Cancel();
|
||||||
|
|
@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel
|
||||||
|
|
||||||
renderer.Separator();
|
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[]
|
RenderSection(renderer, "Movement", new[]
|
||||||
{
|
{
|
||||||
InputAction.MovementForward, InputAction.MovementBackup,
|
InputAction.MovementForward, InputAction.MovementBackup,
|
||||||
|
|
@ -136,8 +178,20 @@ public sealed class SettingsPanel : IPanel
|
||||||
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
|
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
|
||||||
InputAction.Cheer, InputAction.PointState,
|
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)
|
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
|
||||||
|
|
|
||||||
|
|
@ -193,4 +193,18 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
|
||||||
=> shortcut is null
|
=> shortcut is null
|
||||||
? ImGuiNET.ImGui.MenuItem(label)
|
? ImGuiNET.ImGui.MenuItem(label)
|
||||||
: ImGuiNET.ImGui.MenuItem(label, shortcut);
|
: 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,4 +198,37 @@ internal sealed class FakePanelRenderer : IPanelRenderer
|
||||||
Calls.Add(("MenuItem", new object?[] { label, shortcut }));
|
Calls.Add(("MenuItem", new object?[] { label, shortcut }));
|
||||||
return MenuItemReturns;
|
return MenuItemReturns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Tab bar -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Pre-set return for <see cref="BeginTabBar"/>.</summary>
|
||||||
|
public bool TabBarReturns { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>The label of the tab the next <see cref="BeginTabItem"/>
|
||||||
|
/// 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.</summary>
|
||||||
|
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<object?>()));
|
||||||
|
|
||||||
|
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<object?>()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,4 +165,93 @@ public sealed class SettingsPanelTests
|
||||||
var (panel, _, _, _) = Build();
|
var (panel, _, _, _) = Build();
|
||||||
Assert.Equal("acdream.settings", panel.Id);
|
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}).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue