diff --git a/HellionChat/Ui/StyleEngine/Widgets/Badge.cs b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs new file mode 100644 index 0000000..44f29cb --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct BadgeStyle +{ + public BadgeStyle() { } + + public float PaddingX { get; init; } = 4f; + public float Height { get; init; } = 14f; + public float FillAlpha { get; init; } = 0.22f; + public int MaxCount { get; init; } = 99; +} + +// Unread counter. Replaces a bare dot, and deliberately not in StatusDanger: +// red reads as an error, an unread message is not one. +internal static class Badge +{ + internal static string Format(int count, int maxCount) => + count > maxCount ? $"{maxCount}+" : count.ToString(); + + internal static Vector2 CalcSize(int count, BadgeStyle? styleOverride = null) + { + var style = styleOverride ?? new BadgeStyle(); + var text = Format(count, style.MaxCount); + return WidgetGeometry.Badge( + ImGui.CalcTextSize(text), + style.PaddingX * Metrics.Scale, + style.Height * Metrics.Scale + ); + } + + // Caller must not be inside a pushed icon font: the FontAwesome atlas has no + // ASCII digits, so the count would render as blanks. + internal static void Draw( + Vector2 origin, + int count, + uint accentAbgr, + uint textAbgr, + BadgeStyle? styleOverride = null + ) + { + if (count <= 0) + return; + + var style = styleOverride ?? new BadgeStyle(); + var text = Format(count, style.MaxCount); + var size = CalcSize(count, style); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + + var fillAbgr = + (accentAbgr & 0x00FFFFFFu) + | ((uint)Math.Clamp(MathF.Round(255f * style.FillAlpha), 0f, 255f) << 24); + + dl.AddRectFilled(origin, max, fillAbgr, size.Y * 0.5f); + dl.AddRect(origin, max, accentAbgr, size.Y * 0.5f); + + var textSize = ImGui.CalcTextSize(text); + dl.AddText(origin + (size - textSize) * 0.5f, textAbgr, text); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs new file mode 100644 index 0000000..488c5e9 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs @@ -0,0 +1,70 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct IconButtonStyle +{ + public IconButtonStyle() { } + + public float HoverFillAlpha { get; init; } = 0.18f; +} + +// Deliberately small. Its two call sites in the sidebar differ in placement +// (SameLine vs. absolute cursor), visibility rule, glyph choice and one of them +// bumps a SelfTest counter. Folding all of that in would produce a widget that +// is five switches and no behaviour, so the caller keeps those and this draws +// the hit area plus the glyph. +internal static class IconButton +{ + internal static Vector2 CalcSize(float width, float height) => + WidgetGeometry.IconButton(width, height); + + internal static (bool Clicked, bool Hovered) Draw( + uint id, + Vector2 size, + FontAwesomeIcon? glyph, + uint glyphAbgr, + uint hoverFillAbgr, + IFontHandle font, + IconButtonStyle? styleOverride = null + ) + { + var style = styleOverride ?? new IconButtonStyle(); + var clamped = WidgetGeometry.IconButton(size.X, size.Y); + var origin = ImGui.GetCursorScreenPos(); + + ImGui.InvisibleButton($"##hellion-iconbtn-{id}", clamped); + var hovered = ImGui.IsItemHovered(); + var clicked = ImGui.IsItemClicked(); + + var amount = HoverState.Query(id, hovered); + var dl = ImGui.GetWindowDrawList(); + + if (amount > 0f) + { + var a = (uint)Math.Clamp(MathF.Round(255f * style.HoverFillAlpha * amount), 0f, 255f); + dl.AddRectFilled( + origin, + origin + clamped, + (hoverFillAbgr & 0x00FFFFFFu) | (a << 24), + clamped.Y * 0.2f + ); + } + + if (glyph is { } icon) + { + using (font.Push()) + { + var text = icon.ToIconString(); + var textSize = ImGui.CalcTextSize(text); + dl.AddText(origin + (clamped - textSize) * 0.5f, glyphAbgr, text); + } + } + + return (clicked, hovered); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs new file mode 100644 index 0000000..6302de2 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct LineDividerStyle +{ + public LineDividerStyle() { } + + public float Thickness { get; init; } = 1f; + public float PadY { get; init; } = 6f; + public float LabelInsetX { get; init; } = 6f; +} + +// Section marker: a rule with an optional caption underneath. Carries its own +// vertical padding and submits its own layout item, because the sidebar pushes +// ItemSpacing to zero so rows can sit flush -- a divider relying on spacing +// would collapse onto its neighbours there. +internal static class LineDivider +{ + internal static Vector2 CalcSize(string? label, LineDividerStyle? styleOverride = null) + { + var style = styleOverride ?? new LineDividerStyle(); + var scale = Metrics.Scale; + var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight(); + return WidgetGeometry.LineDivider( + ImGui.GetContentRegionAvail().X, + style.Thickness * scale, + style.PadY * scale, + labelHeight + ); + } + + internal static void Draw( + string? label, + uint lineAbgr, + uint labelAbgr, + LineDividerStyle? styleOverride = null + ) + { + var style = styleOverride ?? new LineDividerStyle(); + var scale = Metrics.Scale; + var size = CalcSize(label, style); + var origin = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + + var lineY = origin.Y + style.PadY * scale; + dl.AddLine( + new Vector2(origin.X, lineY), + new Vector2(origin.X + size.X, lineY), + lineAbgr, + style.Thickness * scale + ); + + if (label is not null) + dl.AddText( + new Vector2(origin.X + style.LabelInsetX * scale, lineY + style.PadY * scale), + labelAbgr, + label + ); + + // Advances the cursor so the next row starts below, rather than drawing + // over it. + ImGui.Dummy(size); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs new file mode 100644 index 0000000..fbdfcac --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct PillStyle +{ + public PillStyle() { } + + public float Height { get; init; } = 22f; + public float PaddingX { get; init; } = 8f; + public float Rounding { get; init; } = 6f; + public float DotRadius { get; init; } = 4f; + public float DotGap { get; init; } = 6f; + public bool Outlined { get; init; } +} + +// Filled capsule with a label, optionally preceded by a status dot. Outlined is +// the variant that would otherwise have been a separate Chip widget. +internal static class Pill +{ + internal static Vector2 CalcSize(string label, bool withDot, PillStyle? styleOverride = null) + { + var style = styleOverride ?? new PillStyle(); + var scale = Metrics.Scale; + var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f; + return WidgetGeometry.Pill( + ImGui.CalcTextSize(label), + style.PaddingX * scale, + style.Height * scale, + lead + ); + } + + internal static void Draw( + Vector2 origin, + string label, + uint fillAbgr, + uint textAbgr, + uint? dotAbgr = null, + PillStyle? styleOverride = null + ) + { + var style = styleOverride ?? new PillStyle(); + var scale = Metrics.Scale; + var size = CalcSize(label, dotAbgr.HasValue, style); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + var rounding = style.Rounding * scale; + + if (style.Outlined) + dl.AddRect(origin, max, fillAbgr, rounding); + else + dl.AddRectFilled(origin, max, fillAbgr, rounding); + + var textX = origin.X + style.PaddingX * scale; + + if (dotAbgr is { } dot) + { + var r = style.DotRadius * scale; + dl.AddCircleFilled(new Vector2(textX + r, origin.Y + size.Y * 0.5f), r, dot, 12); + textX += r * 2f + style.DotGap * scale; + } + + // Measured, not a frozen offset: the font comes from Config.FontSizeV2, + // which display scaling does not feed into. + var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight()); + dl.AddText(new Vector2(textX, textY), textAbgr, label); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs new file mode 100644 index 0000000..2762d7a --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -0,0 +1,75 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// Visual state of a list row. Deliberately carries no Tab: v1.11.0 swaps the +// sidebar from tab rows to channel rows and should only have to change the +// caller, not the chrome. +internal readonly record struct RowVisualState +{ + public RowVisualState() { } + + public bool IsActive { get; init; } + public float HoverAmount { get; init; } + public uint SurfaceAbgr { get; init; } + public uint SurfaceHoverAbgr { get; init; } + public uint SurfaceActiveAbgr { get; init; } + public uint AccentAbgr { get; init; } + public uint BorderAbgr { get; init; } +} + +internal readonly record struct RowStyle +{ + public RowStyle() { } + + public float AccentBarWidth { get; init; } = 2f; + public bool DrawSeparator { get; init; } = true; +} + +internal static class Row +{ + // Painted before icon and label, and strictly with draw-list calls only: + // TabContextMenu binds to the last submitted item, so an interactive widget + // between the row button and the popup call would steal its right-click. + internal static void Draw( + Vector2 origin, + Vector2 size, + RowVisualState state, + RowStyle? styleOverride = null + ) + { + if (size.X <= 0f || size.Y <= 0f) + return; + + var style = styleOverride ?? new RowStyle(); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + + var baseAbgr = state.IsActive ? state.SurfaceActiveAbgr : state.SurfaceAbgr; + dl.AddRectFilled(origin, max, baseAbgr); + + if (state.HoverAmount > 0f) + dl.AddRectFilled(origin, max, ScaleAlpha(state.SurfaceHoverAbgr, state.HoverAmount)); + + if (state.IsActive && style.AccentBarWidth > 0f) + { + var barWidth = style.AccentBarWidth * Metrics.Scale; + dl.AddRectFilled(origin, new Vector2(origin.X + barWidth, max.Y), state.AccentAbgr); + } + + if (style.DrawSeparator) + dl.AddLine( + new Vector2(origin.X, max.Y - 1f), + new Vector2(max.X, max.Y - 1f), + state.BorderAbgr, + Metrics.Scale + ); + } + + private static uint ScaleAlpha(uint abgr, float factor) + { + var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * factor), 0f, 255f); + return (abgr & 0x00FFFFFFu) | (a << 24); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs b/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs new file mode 100644 index 0000000..169026b --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs @@ -0,0 +1,27 @@ +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// TokenResolver returns RGBA, ImDrawList expects ABGR. Getting that wrong swaps +// red and blue, and with five widgets it is the same trap five times over, so +// every widget colour goes through here. +internal sealed class WidgetPalette +{ + private readonly TokenResolver _resolver; + + public WidgetPalette(TokenResolver resolver) + { + _resolver = resolver; + } + + internal uint Abgr(Token token, ThemeColors colors) => + ColourUtil.RgbaToAbgr(_resolver.Resolve(token, colors)); + + internal uint Abgr(Token token, ThemeColors colors, float alpha) + { + var abgr = Abgr(token, colors); + var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * alpha), 0f, 255f); + return (abgr & 0x00FFFFFFu) | (a << 24); + } +} diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs new file mode 100644 index 0000000..7032714 --- /dev/null +++ b/HellionChat/Util/WidgetGeometry.cs @@ -0,0 +1,58 @@ +using System.Numerics; + +namespace HellionChat.Util; + +// Size arithmetic for the drawn widgets, split from the widgets themselves so +// the build suite can pin it: the widgets need ImGui.CalcTextSize, this does +// not. Every result is clamped to a positive size -- ImGui asserts on a +// zero-sized InvisibleButton and takes the whole window down with it. +internal static class WidgetGeometry +{ + private const float MinExtent = 1f; + + internal static Vector2 Pill(Vector2 textSize, float paddingX, float height, float leadWidth) + { + var width = textSize.X + paddingX * 2f + leadWidth; + return Clamp(new Vector2(width, height)); + } + + internal static Vector2 Badge(Vector2 textSize, float paddingX, float height) + { + // Round badges look wrong when a single digit makes them narrower than + // they are tall, so a badge is at least a circle. + var width = MathF.Max(textSize.X + paddingX * 2f, height); + return Clamp(new Vector2(width, height)); + } + + internal static Vector2 IconButton(float width, float height) => + Clamp(new Vector2(width, height)); + + internal static Vector2 LineDivider( + float width, + float lineThickness, + float padY, + float labelHeight + ) + { + var height = lineThickness + padY * 2f + labelHeight; + return Clamp(new Vector2(width, height)); + } + + // Sum of pill widths plus the gap between them. Used by the status bar to + // decide whether the right-hand slot still fits, replacing a fixed 200px + // guess that never measured the left-hand slots at all. + internal static float SlotRunWidth(ReadOnlySpan widths, float gap) + { + if (widths.Length == 0) + return 0f; + + var total = 0f; + foreach (var w in widths) + total += w; + + return total + gap * (widths.Length - 1); + } + + private static Vector2 Clamp(Vector2 size) => + new(MathF.Max(MinExtent, size.X), MathF.Max(MinExtent, size.Y)); +}