diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 83fba5f..3e05ec5 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -160,7 +160,9 @@ internal static class PluginHostFactory () => sp.GetRequiredService().MainWindow.UserHide() )); services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ContentArea( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index ed72173..5ccdd05 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -11,6 +11,7 @@ internal sealed class ContentArea { private readonly ThemeRegistry _themes; private readonly TokenResolver _resolver; + private readonly AmbientParticles _motes = new(); public ContentArea(ThemeRegistry themes, TokenResolver resolver) { @@ -20,19 +21,12 @@ internal sealed class ContentArea public void Draw(string activeTab, Action renderTab) { - // ChildBg was never set, so the pane inherited whatever showed through - // the window -- and the window is translucent by default. Settings text - // ended up sitting on moving scenery, which is legible in a screenshot - // and not while the game is running. - // - // Pushed as a colour rather than painted into the draw list so ImGui - // fills it before the scrollbar and border, and the window's own opacity - // still applies on top. var colors = _themes.Active.Colors; - // Transparent child, gradient painted underneath. ChildBg only takes a - // flat colour, and a flat pane is what made the window read as a form - // dump: nothing told the eye where the surface started. + // Transparent child with the ground painted by hand. ChildBg takes one + // flat colour and nothing else, so a gradient, an accent wash or motes + // are all impossible through it -- and the pane had no ground at all + // before, which left settings text sitting on the moving game world. using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); if (!child.Success) @@ -40,16 +34,30 @@ internal sealed class ContentArea return; } - // GetWindowDrawList inside the child is the child's own list, so this - // lands behind its content without a channel split. + // GetWindowDrawList inside a child is that child's own list, so all of + // this lands behind its content without a channel split. + var dl = ImGui.GetWindowDrawList(); + var min = ImGui.GetWindowPos(); + var max = min + ImGui.GetWindowSize(); var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); - ImGui - .GetWindowDrawList() - .DrawVerticalGradient( - ImGui.GetWindowPos(), - ImGui.GetWindowPos() + ImGui.GetWindowSize(), - surface - ); + var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); + + dl.DrawVerticalGradient(min, max, surface, topLift: 0.20f, bottomDrop: 0.14f); + + // A wash of the accent down from the top edge, fading to nothing by + // roughly a third of the height. Without it the pane is a neutral box + // that happens to sit under a themed window; with it the theme reaches + // the surface the user is actually reading. + dl.AddRectFilledMultiColor( + min, + new Vector2(max.X, min.Y + (max.Y - min.Y) * 0.38f), + ColourUtil.ApplyAlpha(accent, 0.10f), + ColourUtil.ApplyAlpha(accent, 0.05f), + accent & 0x00FFFFFFu, + accent & 0x00FFFFFFu + ); + + _motes.Draw(dl, min, max, accent); renderTab(activeTab); } diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index e040c97..15ea805 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -2,51 +2,162 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings; internal sealed class TabSidebar { private readonly FontManager _fonts; + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; public event Action? OnTabSelected; public string ActiveTab { get; private set; } = "general"; - public TabSidebar(FontManager fonts) + public TabSidebar(FontManager fonts, ThemeRegistry themes, TokenResolver resolver) { _fonts = fonts; + _themes = themes; + _resolver = resolver; } public void Draw() { + using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-tab-sidebar", new Vector2(170, 0), true); if (!child.Success) { return; } - DrawEntry("general", FontAwesomeIcon.SlidersH, "General"); - DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance"); - DrawEntry("chat", FontAwesomeIcon.Comments, "Chat"); - DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window"); - DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels"); - DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy"); - DrawEntry("about", FontAwesomeIcon.InfoCircle, "About"); - } + var colors = _themes.Active.Colors; + var dl = ImGui.GetWindowDrawList(); + var min = ImGui.GetWindowPos(); + var max = min + ImGui.GetWindowSize(); - private void DrawEntry(string id, FontAwesomeIcon icon, string label) - { + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); + var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); + + // Darker than the content pane so the two read as separate planes rather + // than one wide surface with a line drawn down it. + dl.DrawVerticalGradient( + min, + max, + ColourUtil.LerpTowardBlack(surface, 0.25f), + topLift: 0.10f, + bottomDrop: 0.06f + ); + + // The icon font has no ASCII glyphs, so anything textual drawn inside a + // FontAwesome scope comes out blank -- twice bitten in this plugin. The + // handle is taken here and the glyphs are drawn through the draw list + // instead, which keeps the scope off the labels entirely. + ImFontPtr iconFont; + float iconSize; using (_fonts.FontAwesome.Push()) { - ImGui.TextUnformatted(icon.ToIconString()); + iconFont = ImGui.GetFont(); + iconSize = ImGui.GetFontSize(); } - ImGui.SameLine(); + + var palette = new SidebarPalette + { + Text = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.Text, colors)), + TextMuted = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.TextMuted, colors)), + Accent = accent, + Hover = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceHover, colors)), + IconFont = iconFont, + IconSize = iconSize, + }; + + DrawEntry("general", FontAwesomeIcon.SlidersH, "General", palette); + DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance", palette); + DrawEntry("chat", FontAwesomeIcon.Comments, "Chat", palette); + DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window", palette); + DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels", palette); + DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy", palette); + DrawEntry("about", FontAwesomeIcon.InfoCircle, "About", palette); + } + + private readonly record struct SidebarPalette + { + public required uint Text { get; init; } + public required uint TextMuted { get; init; } + public required uint Accent { get; init; } + public required uint Hover { get; init; } + public required ImFontPtr IconFont { get; init; } + public required float IconSize { get; init; } + } + + private void DrawEntry(string id, FontAwesomeIcon icon, string label, SidebarPalette p) + { + var scale = Metrics.Scale; + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var height = ImGui.GetTextLineHeight() + 12f * scale; + var size = new Vector2(width, height); var selected = ActiveTab == id; - if (ImGui.Selectable($" {label}##tab-{id}", selected)) + var clicked = ImGui.InvisibleButton($"##tab-{id}", size); + var hovered = ImGui.IsItemHovered(); + var hoverAmount = HoverState.Query(ImGui.GetID($"sidebar.{id}"), hovered); + + if (clicked) { ActiveTab = id; OnTabSelected?.Invoke(id); } + + var dl = ImGui.GetWindowDrawList(); + + if (selected) + { + // Chamfered plate plus a solid bar on the leading edge. The bar is + // what survives at a glance; the plate gives it something to sit on. + dl.DrawSlipPolygon( + origin, + origin + size, + ColourUtil.RgbaToAbgr(ColourUtil.ApplyAlpha(p.Accent, 0.22f)), + 6f * scale + ); + dl.AddRectFilled( + origin, + new Vector2(origin.X + 2.5f * scale, origin.Y + size.Y), + p.Accent + ); + } + else if (hoverAmount > 0f) + dl.AddRectFilled( + origin, + origin + size, + ColourUtil.ApplyAlpha(p.Hover, hoverAmount * 0.7f) + ); + + var contentColour = selected ? p.Text : ColourUtil.Lerp(p.TextMuted, p.Text, hoverAmount); + + var iconX = origin.X + 12f * scale; + dl.AddText( + p.IconFont, + p.IconSize, + new Vector2(iconX, origin.Y + MetricsMath.Center(height, p.IconSize)), + selected ? p.Accent : contentColour, + icon.ToIconString() + ); + + var labelSize = ImGui.CalcTextSize(label); + dl.AddText( + new Vector2( + iconX + p.IconSize + 9f * scale, + origin.Y + MetricsMath.Center(height, labelSize.Y) + ), + contentColour, + label + ); + + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y + 3f * scale)); } } diff --git a/HellionChat/Ui/StyleEngine/AmbientParticles.cs b/HellionChat/Ui/StyleEngine/AmbientParticles.cs new file mode 100644 index 0000000..c42f21e --- /dev/null +++ b/HellionChat/Ui/StyleEngine/AmbientParticles.cs @@ -0,0 +1,102 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Slow motes drifting up behind a surface. Character Select+ does its fog with +// an image sequence; this is procedural instead, so there are no assets to ship, +// no texture handles to keep alive, and it takes the theme's accent colour +// rather than whatever was baked into a PNG. +// +// Deliberately faint and deliberately slow. The brief was "modern, not +// overloaded" -- at this opacity it reads as depth rather than as motion, and +// nothing here competes with the text on top of it. +internal sealed class AmbientParticles +{ + private readonly struct Mote + { + public required float X { get; init; } // 0..1 across the surface + public required float Speed { get; init; } // fractions of height per second + public required float Radius { get; init; } + public required float Phase { get; init; } // sway offset, keeps them out of lockstep + public required float Sway { get; init; } + } + + private readonly Mote[] _motes; + private readonly float[] _y; + private int _lastFrame = -1; + + internal AmbientParticles(int count = 24, int seed = 0x48454C4C) + { + // Seeded rather than time-based: the layout is identical every session, + // so a screenshot taken today matches one taken tomorrow. + var rng = new Random(seed); + _motes = new Mote[count]; + _y = new float[count]; + + for (var i = 0; i < count; i++) + { + _motes[i] = new Mote + { + X = (float)rng.NextDouble(), + Speed = 0.012f + (float)rng.NextDouble() * 0.022f, + Radius = 1f + (float)rng.NextDouble() * 1.6f, + Phase = (float)rng.NextDouble() * MathF.Tau, + Sway = 0.004f + (float)rng.NextDouble() * 0.010f, + }; + _y[i] = (float)rng.NextDouble(); + } + } + + internal void Draw(ImDrawListPtr dl, Vector2 min, Vector2 max, uint accentAbgr) + { + if (Plugin.Config.ReduceMotion) + return; + + var size = max - min; + if (size.X <= 0f || size.Y <= 0f) + return; + + // One advance per frame, not per call: the settings window draws this + // once, but a caller that draws two surfaces would otherwise run the + // simulation at double speed. + var frame = ImGui.GetFrameCount(); + var advance = frame != _lastFrame; + if (advance) + _lastFrame = frame; + + var dt = Math.Min(ImGui.GetIO().DeltaTime, 0.1f); + var time = (float)ImGui.GetTime(); + + for (var i = 0; i < _motes.Length; i++) + { + var m = _motes[i]; + + if (advance) + { + _y[i] -= m.Speed * dt; + if (_y[i] < -0.05f) + _y[i] += 1.1f; + } + + var sway = MathF.Sin(time * 0.35f + m.Phase) * m.Sway; + var pos = new Vector2(min.X + (m.X + sway) * size.X, min.Y + _y[i] * size.Y); + + if (pos.X < min.X || pos.X > max.X || pos.Y < min.Y || pos.Y > max.Y) + continue; + + // Fades out at both ends of its travel, so motes appear and vanish + // instead of popping at the edge. + var edge = Math.Clamp(MathF.Min(_y[i], 1f - _y[i]) * 6f, 0f, 1f); + var pulse = 0.55f + 0.45f * MathF.Sin(time * 0.6f + m.Phase); + + dl.AddCircleFilled( + pos, + m.Radius * Metrics.Scale, + ColourUtil.ApplyAlpha(accentAbgr, 0.16f * edge * pulse), + 8 + ); + } + } +}