feat(statusbar): render slots as pills
Five slots drawn as flowing text with a TextDisabled interpunct between them. They are pills now: channel with its status dot, privacy with its lock glyph, counts, tells, and version right-aligned. The composite texts stay composite. FormatCounts produces "5 tabs · 1.2k msg" and the version slot "v1.10.0 · Hellion" as single strings, and splitting them would have gained nothing while breaking their pinned format. Height derives from the pill rather than sitting beside it as a second constant. MainWindow reserves the body against this property, so the two drifting apart is the entire failure mode here, and a pill is taller than a bare text line. The right-hand slot's fit check actually measures now. The old one compared the region against a flat 200px and never looked at the left-hand slots at all, so at the 480px minimum width it kept drawing the version while the left run needed more room than was left -- the overlap predates this change. Pill grew optional icon support for the privacy lock. Without it that glyph would have been silently dropped in the move. StatusBarCacheTests is re-enabled. It sat in the csproj Compile Remove block, so the format contract this commit reshapes around had no live net at all. Two of its cases construct StatusBar with null services, which is safe because SnapshotForTest touches neither.
This commit is contained in:
@@ -2,11 +2,13 @@ using System.Globalization;
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.ManagedFontAtlas;
|
||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
using Dalamud.Interface.Utility.Raii;
|
||||||
using HellionChat.Code;
|
using HellionChat.Code;
|
||||||
using HellionChat.Resources;
|
using HellionChat.Resources;
|
||||||
using HellionChat.Themes;
|
using HellionChat.Themes;
|
||||||
|
using HellionChat.Ui.StyleEngine;
|
||||||
using HellionChat.Util;
|
using HellionChat.Util;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Components;
|
namespace HellionChat.Ui.Components;
|
||||||
@@ -21,13 +23,20 @@ internal sealed class StatusBar
|
|||||||
// scaling above 100% — GetTextLineHeightWithSpacing scales with the
|
// scaling above 100% — GetTextLineHeightWithSpacing scales with the
|
||||||
// active ImGui font, the 2px spacer rounds against GlobalScale so the
|
// active ImGui font, the 2px spacer rounds against GlobalScale so the
|
||||||
// result lands on integer pixel boundaries.
|
// result lands on integer pixel boundaries.
|
||||||
|
// Derived from the pill, never a second constant: MainWindow reserves the
|
||||||
|
// body height against this property, so the two drifting apart is the whole
|
||||||
|
// failure mode. Slots are pills now, and a pill is taller than a text line.
|
||||||
public static float Height =>
|
public static float Height =>
|
||||||
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
|
StyleEngine.Widgets.Pill.CalcSize("X", withDot: false).Y
|
||||||
|
+ StyleEngine.Metrics.StatusTopSpacer * 2f;
|
||||||
|
|
||||||
private const long UpdateIntervalMs = 1000;
|
private const long UpdateIntervalMs = 1000;
|
||||||
|
|
||||||
private readonly ThemeRegistry _themes;
|
private readonly ThemeRegistry _themes;
|
||||||
private readonly FontManager _fonts;
|
private readonly FontManager _fonts;
|
||||||
|
private readonly StyleEngine.Widgets.WidgetPalette _palette = new(
|
||||||
|
new StyleEngine.TokenResolver()
|
||||||
|
);
|
||||||
|
|
||||||
private long _lastUpdateMs = -UpdateIntervalMs;
|
private long _lastUpdateMs = -UpdateIntervalMs;
|
||||||
private string _cachedCountsText = string.Empty;
|
private string _cachedCountsText = string.Empty;
|
||||||
@@ -111,83 +120,91 @@ internal sealed class StatusBar
|
|||||||
|
|
||||||
// Top border via DrawList — ImGui.Separator has too much padding for
|
// Top border via DrawList — ImGui.Separator has too much padding for
|
||||||
// a tight bottom strip.
|
// a tight bottom strip.
|
||||||
var cursorY = ImGui.GetCursorScreenPos().Y;
|
var origin = ImGui.GetCursorScreenPos();
|
||||||
var winLeft = ImGui.GetWindowPos().X;
|
var winLeft = ImGui.GetWindowPos().X;
|
||||||
var winRight = winLeft + ImGui.GetWindowSize().X;
|
var winRight = winLeft + ImGui.GetWindowSize().X;
|
||||||
|
var palette = _palette;
|
||||||
|
var colors = theme.Colors;
|
||||||
|
|
||||||
ImGui
|
ImGui
|
||||||
.GetWindowDrawList()
|
.GetWindowDrawList()
|
||||||
.AddLine(
|
.AddLine(
|
||||||
new Vector2(winLeft, cursorY),
|
new Vector2(winLeft, origin.Y),
|
||||||
new Vector2(winRight, cursorY),
|
new Vector2(winRight, origin.Y),
|
||||||
ColourUtil.RgbaToAbgr(theme.Colors.Border),
|
palette.Abgr(Token.Border, colors),
|
||||||
1f
|
StyleEngine.Metrics.StatusBorderThickness
|
||||||
);
|
);
|
||||||
ImGui.Dummy(new Vector2(0, 2));
|
|
||||||
|
|
||||||
// Slot 1: active channel indicator
|
var pillFill = palette.Abgr(Token.SurfaceRaised, colors);
|
||||||
|
var pillText = palette.Abgr(Token.Text, colors);
|
||||||
|
var mutedText = palette.Abgr(Token.TextMuted, colors);
|
||||||
|
var gap = StyleEngine.Metrics.StatusTopSpacer * 3f;
|
||||||
|
var top = origin.Y + StyleEngine.Metrics.StatusTopSpacer;
|
||||||
|
|
||||||
|
// Slot 1: active channel. The dot doubles as the connection indicator.
|
||||||
var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
|
var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
|
||||||
var hasChannel = inputCh != InputChannel.Invalid;
|
var hasChannel = inputCh != InputChannel.Invalid;
|
||||||
var chatType = inputCh.ToChatType();
|
var channelName = hasChannel ? inputCh.ToChatType().Name() : "—";
|
||||||
var channelName = hasChannel ? chatType.Name() : "—";
|
var dotAbgr = hasChannel
|
||||||
var dotColor = hasChannel ? theme.Colors.Primary : theme.Colors.TextMuted;
|
? palette.Abgr(Token.AccentPrimary, colors)
|
||||||
DrawDot(dotColor);
|
: palette.Abgr(Token.TextMuted, colors);
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.TextUnformatted(channelName);
|
|
||||||
|
|
||||||
// Slot 2: privacy badge
|
// Slot 2 label, resolved before measuring so the run width is exact.
|
||||||
ImGui.SameLine();
|
|
||||||
DrawSeparator();
|
|
||||||
ImGui.SameLine();
|
|
||||||
using (_fonts.FontAwesome.Push())
|
|
||||||
ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString());
|
|
||||||
ImGui.SameLine();
|
|
||||||
var privacyLabel = Plugin.Config.PrivacyFilterEnabled
|
var privacyLabel = Plugin.Config.PrivacyFilterEnabled
|
||||||
? HellionStrings.StatusBar_Privacy_Enabled
|
? HellionStrings.StatusBar_Privacy_Enabled
|
||||||
: HellionStrings.StatusBar_Privacy_Open;
|
: HellionStrings.StatusBar_Privacy_Open;
|
||||||
ImGui.TextUnformatted(privacyLabel);
|
|
||||||
|
|
||||||
// Slot 3: counts
|
var x = origin.X;
|
||||||
ImGui.SameLine();
|
x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap;
|
||||||
DrawSeparator();
|
x +=
|
||||||
ImGui.SameLine();
|
DrawSlot(
|
||||||
ImGui.TextUnformatted(_cachedCountsText);
|
new Vector2(x, top),
|
||||||
|
privacyLabel,
|
||||||
|
pillFill,
|
||||||
|
pillText,
|
||||||
|
null,
|
||||||
|
(FontAwesomeIcon.Lock, _fonts.FontAwesome)
|
||||||
|
) + gap;
|
||||||
|
x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap;
|
||||||
|
|
||||||
// Slot 4: tells (hidden at 0)
|
|
||||||
if (!string.IsNullOrEmpty(_cachedTellsText))
|
if (!string.IsNullOrEmpty(_cachedTellsText))
|
||||||
{
|
x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap;
|
||||||
ImGui.SameLine();
|
|
||||||
DrawSeparator();
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.TextUnformatted(_cachedTellsText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slot 5: version + brand, right-aligned, muted. Hidden when the
|
// Slot 5: version + brand, right-aligned. Dropped when the left-hand run
|
||||||
// window cannot fit all five slots without overlap.
|
// would actually collide with it -- the old check compared against a flat
|
||||||
|
// 200px and never measured the left slots at all.
|
||||||
var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
|
var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
|
||||||
var versionWidth = ImGui.CalcTextSize(versionText).X;
|
var versionWidth = StyleEngine.Widgets.Pill.CalcSize(versionText, withDot: false).X;
|
||||||
var contentRegionMax = ImGui.GetContentRegionMax().X;
|
var regionRight = origin.X + ImGui.GetContentRegionAvail().X;
|
||||||
const float MinOtherSlotsWidth = 200f;
|
var leftRunEnd = x - gap;
|
||||||
if (contentRegionMax - versionWidth > MinOtherSlotsWidth)
|
|
||||||
{
|
|
||||||
ImGui.SameLine(contentRegionMax - versionWidth);
|
|
||||||
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
|
|
||||||
ImGui.TextUnformatted(versionText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DrawDot(uint rgba)
|
if (regionRight - versionWidth - gap > leftRunEnd)
|
||||||
{
|
DrawSlot(
|
||||||
var pos = ImGui.GetCursorScreenPos();
|
new Vector2(regionRight - versionWidth, top),
|
||||||
const float radius = 4f;
|
versionText,
|
||||||
ImGui
|
pillFill,
|
||||||
.GetWindowDrawList()
|
mutedText,
|
||||||
.AddCircleFilled(
|
null
|
||||||
new Vector2(pos.X + radius, pos.Y + ImGui.GetTextLineHeight() / 2f),
|
|
||||||
radius,
|
|
||||||
ColourUtil.RgbaToAbgr(rgba)
|
|
||||||
);
|
);
|
||||||
ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight()));
|
|
||||||
|
ImGui.Dummy(new Vector2(0, Height));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DrawSeparator() => ImGui.TextDisabled("·");
|
// Returns the slot width so the caller can run them left to right and know
|
||||||
|
// where the run ends.
|
||||||
|
private static float DrawSlot(
|
||||||
|
Vector2 origin,
|
||||||
|
string label,
|
||||||
|
uint fillAbgr,
|
||||||
|
uint textAbgr,
|
||||||
|
uint? dotAbgr,
|
||||||
|
(FontAwesomeIcon Icon, IFontHandle Font)? icon = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
StyleEngine.Widgets.Pill.Draw(origin, label, fillAbgr, textAbgr, dotAbgr, icon: icon);
|
||||||
|
var iconWidth = icon is { } ic
|
||||||
|
? StyleEngine.Widgets.Pill.MeasureIcon(ic.Icon, ic.Font)
|
||||||
|
: 0f;
|
||||||
|
return StyleEngine.Widgets.Pill.CalcSize(label, dotAbgr.HasValue, iconWidth: iconWidth).X;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.ManagedFontAtlas;
|
||||||
using HellionChat.Util;
|
using HellionChat.Util;
|
||||||
|
|
||||||
namespace HellionChat.Ui.StyleEngine.Widgets;
|
namespace HellionChat.Ui.StyleEngine.Widgets;
|
||||||
@@ -16,15 +18,23 @@ internal readonly record struct PillStyle
|
|||||||
public bool Outlined { get; init; }
|
public bool Outlined { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filled capsule with a label, optionally preceded by a status dot. Outlined is
|
// Filled capsule with a label, optionally preceded by a status dot or an icon.
|
||||||
// the variant that would otherwise have been a separate Chip widget.
|
// Outlined is the variant that would otherwise have been a separate Chip widget.
|
||||||
internal static class Pill
|
internal static class Pill
|
||||||
{
|
{
|
||||||
internal static Vector2 CalcSize(string label, bool withDot, PillStyle? styleOverride = null)
|
internal static Vector2 CalcSize(
|
||||||
|
string label,
|
||||||
|
bool withDot,
|
||||||
|
PillStyle? styleOverride = null,
|
||||||
|
float iconWidth = 0f
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var style = styleOverride ?? new PillStyle();
|
var style = styleOverride ?? new PillStyle();
|
||||||
var scale = Metrics.Scale;
|
var scale = Metrics.Scale;
|
||||||
var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f;
|
var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f;
|
||||||
|
if (iconWidth > 0f)
|
||||||
|
lead += iconWidth + style.DotGap * scale;
|
||||||
|
|
||||||
return WidgetGeometry.Pill(
|
return WidgetGeometry.Pill(
|
||||||
ImGui.CalcTextSize(label),
|
ImGui.CalcTextSize(label),
|
||||||
style.PaddingX * scale,
|
style.PaddingX * scale,
|
||||||
@@ -33,18 +43,28 @@ internal static class Pill
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Measures a glyph in the icon font so a caller can feed iconWidth above
|
||||||
|
// without pushing the font twice.
|
||||||
|
internal static float MeasureIcon(FontAwesomeIcon icon, IFontHandle font)
|
||||||
|
{
|
||||||
|
using (font.Push())
|
||||||
|
return ImGui.CalcTextSize(icon.ToIconString()).X;
|
||||||
|
}
|
||||||
|
|
||||||
internal static void Draw(
|
internal static void Draw(
|
||||||
Vector2 origin,
|
Vector2 origin,
|
||||||
string label,
|
string label,
|
||||||
uint fillAbgr,
|
uint fillAbgr,
|
||||||
uint textAbgr,
|
uint textAbgr,
|
||||||
uint? dotAbgr = null,
|
uint? dotAbgr = null,
|
||||||
PillStyle? styleOverride = null
|
PillStyle? styleOverride = null,
|
||||||
|
(FontAwesomeIcon Icon, IFontHandle Font)? icon = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var style = styleOverride ?? new PillStyle();
|
var style = styleOverride ?? new PillStyle();
|
||||||
var scale = Metrics.Scale;
|
var scale = Metrics.Scale;
|
||||||
var size = CalcSize(label, dotAbgr.HasValue, style);
|
var iconWidth = icon is { } ic ? MeasureIcon(ic.Icon, ic.Font) : 0f;
|
||||||
|
var size = CalcSize(label, dotAbgr.HasValue, style, iconWidth);
|
||||||
var dl = ImGui.GetWindowDrawList();
|
var dl = ImGui.GetWindowDrawList();
|
||||||
var max = origin + size;
|
var max = origin + size;
|
||||||
var rounding = style.Rounding * scale;
|
var rounding = style.Rounding * scale;
|
||||||
@@ -63,6 +83,21 @@ internal static class Pill
|
|||||||
textX += r * 2f + style.DotGap * scale;
|
textX += r * 2f + style.DotGap * scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (icon is { } glyph)
|
||||||
|
{
|
||||||
|
using (glyph.Font.Push())
|
||||||
|
{
|
||||||
|
var text = glyph.Icon.ToIconString();
|
||||||
|
var h = ImGui.CalcTextSize(text).Y;
|
||||||
|
dl.AddText(
|
||||||
|
new Vector2(textX, origin.Y + MetricsMath.CenterY(size.Y, h)),
|
||||||
|
textAbgr,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
textX += iconWidth + style.DotGap * scale;
|
||||||
|
}
|
||||||
|
|
||||||
// Measured, not a frozen offset: the font comes from Config.FontSizeV2,
|
// Measured, not a frozen offset: the font comes from Config.FontSizeV2,
|
||||||
// which display scaling does not feed into.
|
// which display scaling does not feed into.
|
||||||
var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight());
|
var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight());
|
||||||
|
|||||||
Reference in New Issue
Block a user