feat(settings): convert the window tab to the styled widgets

The first tab that actually looks different. Everything here was stock ImGui:
framed collapsing bars, checkboxes with the label trailing on the right, and
sliders glued to the left edge with their name behind them.

Now: section headers with an accent bar, rows with the label on the left and the
control right-aligned in its own column, sliding switches instead of checkboxes,
and a segmented control where two radio buttons used to pretend to be two
settings when they are one.

Three of the four widgets built in this cycle had no call site outside the debug
gallery. That is the exact defect that triggered v1.10.0 -- five tools built and
never wired up -- and the spec rule written afterwards says no widget without a
call site in the same cycle. This closes that on the pilot tab; the remaining
five follow one at a time.

The row helpers live in SettingsWidgets so the other tabs get them unchanged,
and the theme lookups go through SettingsPalette, cached per frame: twenty rows
would otherwise resolve the same five tokens twenty times over.

Section keys are u8 literals rather than the visible titles. ImGui's own storage
keys collapsing headers off the label, which would reset every section's open
state on a language change and merge two sections whose titles translate alike.

Note WindowTab's constructor gained a parameter, so this needs the DI smoke pass
before the next tab follows.
This commit is contained in:
2026-08-18 17:07:39 +02:00
parent 18d43f9afa
commit 5a4c3b6707
4 changed files with 320 additions and 33 deletions
+2 -1
View File
@@ -203,7 +203,8 @@ internal static class PluginHostFactory
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab(
sp.GetRequiredService<Plugin>()
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab(
sp.GetRequiredService<Plugin>()
@@ -0,0 +1,59 @@
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Ui.StyleEngine.Widgets;
namespace HellionChat.Ui.Components.Settings;
// Every styled settings control needs the same four or five theme slots, and
// each widget wants them in its own colour struct. Building those inline turned
// each call site into six lines of plumbing around one line of intent.
//
// Rebuilt per frame rather than cached: the active theme changes while the
// window is open -- that is what the appearance tab is for.
internal sealed class SettingsPalette
{
private readonly WidgetPalette _palette;
internal SettingsPalette(TokenResolver resolver)
{
_palette = new WidgetPalette(resolver);
}
internal SettingRowColors Row(ThemeColors c) =>
new()
{
LabelAbgr = _palette.Abgr(Token.Text, c),
DescriptionAbgr = _palette.Abgr(Token.TextMuted, c),
SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c),
BorderAbgr = _palette.Abgr(Token.Border, c),
};
internal ToggleSwitchColors Toggle(ThemeColors c) =>
new()
{
TrackOffAbgr = _palette.Abgr(Token.SurfaceBase, c),
TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c),
KnobAbgr = _palette.Abgr(Token.Text, c),
};
internal SectionHeaderColors Section(ThemeColors c) =>
new()
{
TitleAbgr = _palette.Abgr(Token.Text, c),
DescriptionAbgr = _palette.Abgr(Token.TextMuted, c),
AccentAbgr = _palette.Abgr(Token.AccentPrimary, c),
BorderAbgr = _palette.Abgr(Token.Border, c),
HoverAbgr = _palette.Abgr(Token.SurfaceHover, c),
};
internal SegmentedControlColors Segmented(ThemeColors c) =>
new()
{
TrackAbgr = _palette.Abgr(Token.SurfaceBase, c),
SelectedAbgr = _palette.Abgr(Token.AccentPrimary, c),
HoverAbgr = _palette.Abgr(Token.SurfaceHover, c),
LabelAbgr = _palette.Abgr(Token.TextMuted, c),
SelectedLabelAbgr = _palette.Abgr(Token.Text, c),
BorderAbgr = _palette.Abgr(Token.Border, c),
};
}
@@ -1,4 +1,8 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Ui.StyleEngine.Widgets;
namespace HellionChat.Ui.Components.Settings;
@@ -27,9 +31,201 @@ internal sealed class SettingsWidgets
// Always sliced to the value count when passed on -- see EnumCombo.
private string[] _labelScratch = new string[8];
internal SettingsWidgets(Plugin plugin)
private readonly SettingsPalette? _colors;
// Cached per frame: twenty rows would otherwise re-resolve the same five
// theme tokens twenty times, and the active theme cannot change mid-frame.
private int _frame = -1;
private SettingRowColors _row;
private ToggleSwitchColors _toggle;
private SectionHeaderColors _section;
private SegmentedControlColors _segmented;
internal SettingsWidgets(Plugin plugin, SettingsPalette? colors = null)
{
_plugin = plugin;
_colors = colors;
}
// Tabs that have not been converted yet pass no palette and keep using the
// plain ImGui helpers below.
private void EnsureFrame()
{
if (_colors is null || _frame == ImGui.GetFrameCount())
return;
_frame = ImGui.GetFrameCount();
var c = _plugin.ThemeRegistry.Active.Colors;
_row = _colors.Row(c);
_toggle = _colors.Toggle(c);
_section = _colors.Section(c);
_segmented = _colors.Segmented(c);
}
internal bool Section(uint key, string title, string? description = null, bool open = true)
{
EnsureFrame();
return SectionHeader.Draw(key, title, description, _section, defaultOpen: open);
}
// The whole row toggles, label included. The switch itself gets its own
// invisible button because SettingRow's hit area stops at the label column,
// and clicking the control is what a user tries first.
internal void ToggleRow(
uint id,
string label,
string? description,
Func<bool> get,
Action<bool> set
)
{
EnsureFrame();
var value = get();
var hit = false;
var rowClicked = SettingRow.Draw(
id,
label,
description,
_row,
ctx =>
{
var size = ToggleSwitch.CalcSize();
var pos = ctx.AlignRight(size);
ImGui.SetCursorScreenPos(pos);
if (ImGui.InvisibleButton($"##hc-sw-{id}", size))
hit = true;
ToggleSwitch.Draw(id, pos, value, _toggle);
}
);
if (!rowClicked && !hit)
return;
set(!value);
_plugin.SaveConfig();
}
internal void SliderFloatRow(
uint id,
string label,
string? description,
Func<float> get,
Action<float> set,
float min,
float max
)
{
EnsureFrame();
var current = get();
SettingRow.Draw(
id,
label,
description,
_row,
ctx =>
{
ImGui.SetNextItemWidth(ctx.ControlWidth);
if (ImGui.SliderFloat($"##hc-sf-{id}", ref current, min, max, "%.2f"))
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
);
}
internal void SliderIntRow(
uint id,
string label,
string? description,
Func<int> get,
Action<int> set,
int min,
int max
)
{
EnsureFrame();
var current = get();
SettingRow.Draw(
id,
label,
description,
_row,
ctx =>
{
ImGui.SetNextItemWidth(ctx.ControlWidth);
if (ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d"))
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
);
}
internal void EnumComboRow<T>(
uint id,
string label,
string? description,
Func<T> get,
Action<T> set,
Func<T, string> labelFor
)
where T : struct, Enum
{
EnsureFrame();
SettingRow.Draw(
id,
label,
description,
_row,
ctx =>
{
ImGui.SetNextItemWidth(ctx.ControlWidth);
EnumCombo($"##hc-ec-{id}", get, set, labelFor, ctx.ControlWidth);
}
);
}
// One setting, n choices. The control fills the whole control column rather
// than right-aligning, because segments need the room to stay readable.
internal void SegmentRow<T>(
uint id,
string label,
string? description,
T[] values,
string[] labels,
Func<T> get,
Action<T> set
)
where T : struct, Enum
{
EnsureFrame();
var current = get();
var selected = 0;
for (var i = 0; i < values.Length; i++)
if (EqualityComparer<T>.Default.Equals(values[i], current))
selected = i;
var picked = selected;
var colors = _segmented;
SettingRow.Draw(
id,
label,
description,
_row,
ctx =>
{
ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y));
picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, selected, colors);
}
);
if (picked == selected)
return;
set(values[picked]);
_plugin.SaveConfig();
}
internal void Toggle(string label, Func<bool> get, Action<bool> set)
@@ -1,65 +1,86 @@
using Dalamud.Bindings.ImGui;
using HellionChat.Ui.StyleEngine;
namespace HellionChat.Ui.Components.Settings.Tabs;
// First tab converted to the styled widgets. Everything here used to be stock
// ImGui: framed collapsing bars, checkboxes with their label on the right, and
// sliders glued to the left edge with their name trailing behind.
internal sealed class WindowTab
{
private readonly Plugin _plugin;
private readonly SettingsWidgets _w;
public WindowTab(Plugin plugin)
// Held rather than built per frame, and ordered to match the enum values
// passed alongside them.
private static readonly MainWindowLayoutMode[] LayoutValues =
[
MainWindowLayoutMode.Sidebar,
MainWindowLayoutMode.TopTabs,
];
private static readonly string[] LayoutLabels = ["Sidebar", "Top tabs"];
public WindowTab(Plugin plugin, TokenResolver resolver)
{
_plugin = plugin;
_w = new SettingsWidgets(plugin);
_w = new SettingsWidgets(plugin, new SettingsPalette(resolver));
}
public void Draw()
{
if (ImGui.CollapsingHeader("Layout mode", ImGuiTreeNodeFlags.DefaultOpen))
// ASCII literals, not the visible titles: the keys have to survive a
// language change, and u8 literals cost no allocation.
if (_w.Section(ImGui.GetID("window.layout"u8), "Layout mode"))
{
var mode = Plugin.Config.MainWindowLayoutMode;
if (ImGui.RadioButton("Sidebar", mode == MainWindowLayoutMode.Sidebar))
{
Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.Sidebar;
_plugin.SaveConfig();
}
if (ImGui.RadioButton("Top tabs", mode == MainWindowLayoutMode.TopTabs))
{
Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs;
_plugin.SaveConfig();
}
_w.SegmentRow(
ImGui.GetID("window.layout.mode"u8),
"Tab placement",
"Where the tab list sits in the main window.",
LayoutValues,
LayoutLabels,
() => Plugin.Config.MainWindowLayoutMode,
v => Plugin.Config.MainWindowLayoutMode = v
);
}
if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen))
if (_w.Section(ImGui.GetID("window.style"u8), "Window style"))
{
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.style.titlebar"u8),
"Show title bar",
null,
() => Plugin.Config.ShowTitleBar,
v => Plugin.Config.ShowTitleBar = v
);
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.style.popouttitlebar"u8),
"Show title bar for pop-outs",
null,
() => Plugin.Config.ShowPopOutTitleBar,
v => Plugin.Config.ShowPopOutTitleBar = v
);
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.style.hidebutton"u8),
"Show hide button",
null,
() => Plugin.Config.ShowHideButton,
v => Plugin.Config.ShowHideButton = v
);
}
if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen))
if (_w.Section(ImGui.GetID("window.opacity"u8), "Opacity"))
{
_w.SliderFloat(
_w.SliderFloatRow(
ImGui.GetID("window.opacity.active"u8),
"Window opacity",
null,
() => Plugin.Config.WindowOpacity,
v => Plugin.Config.WindowOpacity = v,
0.1f,
1f
);
_w.SliderFloat(
_w.SliderFloatRow(
ImGui.GetID("window.opacity.inactive"u8),
"Inactive opacity",
"Applies while another window has focus.",
() => Plugin.Config.WindowOpacityInactive,
v => Plugin.Config.WindowOpacityInactive = v,
0.1f,
@@ -67,20 +88,26 @@ internal sealed class WindowTab
);
}
if (ImGui.CollapsingHeader("Resize behavior", ImGuiTreeNodeFlags.DefaultOpen))
if (_w.Section(ImGui.GetID("window.resize"u8), "Resize behaviour"))
{
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.resize.move"u8),
"Allow movement",
null,
() => Plugin.Config.CanMove,
v => Plugin.Config.CanMove = v
);
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.resize.resize"u8),
"Allow resize",
null,
() => Plugin.Config.CanResize,
v => Plugin.Config.CanResize = v
);
_w.SliderInt(
"Sidebar auto-switch threshold (px)",
_w.SliderIntRow(
ImGui.GetID("window.resize.threshold"u8),
"Sidebar auto-switch threshold",
"Below this width the sidebar folds into top tabs, in pixels.",
() => Plugin.Config.SidebarAutoSwitchThresholdPx,
v => Plugin.Config.SidebarAutoSwitchThresholdPx = v,
200,
@@ -88,16 +115,20 @@ internal sealed class WindowTab
);
}
if (ImGui.CollapsingHeader("Input preview"))
if (_w.Section(ImGui.GetID("window.preview"u8), "Input preview", open: false))
{
_w.EnumCombo(
_w.EnumComboRow(
ImGui.GetID("window.preview.position"u8),
"Preview position",
null,
() => Plugin.Config.PreviewPosition,
v => Plugin.Config.PreviewPosition = v,
v => v.Name()
);
_w.Toggle(
_w.ToggleRow(
ImGui.GetID("window.preview.onlyif"u8),
"Only show preview when typing",
null,
() => Plugin.Config.OnlyPreviewIf,
v => Plugin.Config.OnlyPreviewIf = v
);