feat(style): depth for the settings window
Brief was a mix of Lightless and Character Select+, modern without being overloaded, and the gradient allowed to show more. The content pane gets a stronger gradient plus an accent wash falling from the top edge to about a third of the height. Without it the pane was a neutral box that happened to sit inside a themed window; now the theme reaches the surface being read. Ambient motes drift up behind it. Procedural rather than the image sequence Character Select+ uses, so there are no assets to ship and they take the theme's accent instead of whatever was baked into a PNG. Fixed count, fixed arrays, seeded once, so the layout is identical every session and nothing allocates per frame. They advance once per frame regardless of how many surfaces call in, and ReduceMotion skips them entirely -- same contract HoverState already honours. The tab sidebar was the last stock-ImGui element and had started to look it. Its entries are drawn now: darker plane than the content pane so the two read as separate surfaces, a chamfered plate with a solid leading bar for the active one, hover fading between muted and full text. Its icons go through the draw list with a font pointer taken outside the scope, rather than being drawn inside a FontAwesome push. That atlas has no ASCII glyphs, so any label caught inside such a scope renders blank -- which has already happened twice in this plugin, once in the tooltips and once in a badge.
This commit is contained in:
@@ -160,7 +160,9 @@ internal static class PluginHostFactory
|
||||
() => sp.GetRequiredService<Plugin>().MainWindow.UserHide()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar(
|
||||
sp.GetRequiredService<FontManager>()
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.Settings.ContentArea(
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
|
||||
@@ -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<string> 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);
|
||||
}
|
||||
|
||||
@@ -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<string>? 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user