feat(style): add the drawn-widget set
Five widgets, each with a real consumer inside this cycle: Row for sidebar rows, Badge for unread counts, IconButton for the popout and greeted toggles, LineDivider for section headers, Pill for the channel pill and the status bar. Row carries no Tab on purpose. v1.11.0 moves the sidebar from tab rows to channel rows, and if the chrome sits in a Tab-free widget that cycle only has to change the caller. IconButton is deliberately small. Its two sidebar call sites differ in placement, visibility rule, glyph choice, and one of them bumps a SelfTest counter. Taking all of that as parameters would produce a widget that is five switches and no behaviour, so the caller keeps them. LineDivider brings its own vertical padding and submits its own layout item. The sidebar is about to push ItemSpacing to zero so rows sit flush, and a divider relying on spacing would collapse onto its neighbours there. Colours go through WidgetPalette. TokenResolver returns RGBA and ImDrawList expects ABGR; with five widgets that is the same swap-red-and-blue trap five times over. Sizes go through WidgetGeometry, which is pure so the build suite can pin it, and clamps every result to a positive extent -- ImGui asserts on a zero-sized InvisibleButton and takes the window with it.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<float> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user