diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f67d683..f2ed0fd 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -203,7 +203,8 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab( sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs new file mode 100644 index 0000000..d4db594 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -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), + }; +} diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index 6905266..e2dda3b 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -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 get, + Action 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 get, + Action 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 get, + Action 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( + uint id, + string label, + string? description, + Func get, + Action set, + Func 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( + uint id, + string label, + string? description, + T[] values, + string[] labels, + Func get, + Action set + ) + where T : struct, Enum + { + EnsureFrame(); + var current = get(); + var selected = 0; + for (var i = 0; i < values.Length; i++) + if (EqualityComparer.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 get, Action set) diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 92391ca..adee5d5 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -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 );