diff --git a/ImGui.Widgets/Card.cs b/ImGui.Widgets/Card.cs
new file mode 100644
index 0000000..cbeab64
--- /dev/null
+++ b/ImGui.Widgets/Card.cs
@@ -0,0 +1,168 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.ImGui.Widgets;
+
+using System;
+using System.Numerics;
+
+using Hexa.NET.ImGui;
+
+///
+/// Provides custom ImGui widgets.
+///
+public static partial class ImGuiWidgets
+{
+ ///
+ /// A scoped, shadowed container that draws an elevated rounded panel behind whatever is rendered
+ /// inside the using block. Because Dear ImGui is immediate-mode and the card's size is not
+ /// known until its content has been laid out, the body is drawn onto a foreground draw-list channel
+ /// first and the background (shadow + fill + border) is painted behind it on disposal.
+ ///
+ ///
+ /// Usage:
+ ///
+ /// using (new ImGuiWidgets.Card())
+ /// {
+ /// ImGui.TextUnformatted("Title");
+ /// ImGui.TextWrapped("Body copy that flows inside the padded card.");
+ /// }
+ ///
+ /// Nested child windows / scrolling regions are not supported inside a card because they render to
+ /// their own draw lists and will not participate in the channel split.
+ ///
+ public sealed class Card : IDisposable
+ {
+ private readonly ImDrawListPtr drawList;
+ private readonly Vector2 origin;
+ private readonly Vector2 padding;
+ private readonly float rounding;
+ private readonly float width;
+ private readonly Vector4? background;
+ private readonly bool border;
+ private readonly bool wrapPushed;
+ private bool disposed;
+
+ ///
+ /// Begins a card. Call inside a using block; the panel is painted when the block exits.
+ ///
+ /// Outer card width in pixels. When 0 the card shrinks to fit its content.
+ /// Inner padding in pixels on every edge. When negative the style's is used.
+ /// Corner radius in pixels. When negative a value derived from the style's frame rounding is used.
+ /// Explicit fill colour. When an elevated surface colour is resolved from the active theme.
+ /// Whether to stroke a one-pixel border in the theme's border colour.
+ public Card(float width = 0f, float padding = -1f, float rounding = -1f, Vector4? background = null, bool border = true)
+ {
+ ImGuiStylePtr style = ImGui.GetStyle();
+ this.padding = padding >= 0f ? new Vector2(padding, padding) : style.WindowPadding;
+ this.rounding = rounding >= 0f ? rounding : MathF.Max(style.FrameRounding, 4.0f);
+ this.width = width;
+ this.background = background;
+ this.border = border;
+
+ drawList = ImGui.GetWindowDrawList();
+ origin = ImGui.GetCursorScreenPos();
+
+ // Content paints on the foreground channel so the background can be drawn behind it later.
+ drawList.ChannelsSplit(2);
+ drawList.ChannelsSetCurrent(1);
+
+ ImGui.SetCursorScreenPos(origin + this.padding);
+ ImGui.BeginGroup();
+
+ if (width > 0f)
+ {
+ float inner = MathF.Max(width - (this.padding.X * 2.0f), 1.0f);
+ ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + inner);
+ wrapPushed = true;
+ }
+ }
+
+ ///
+ /// Closes the content group and paints the card's shadow, fill, and border behind it.
+ ///
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+
+ if (wrapPushed)
+ {
+ ImGui.PopTextWrapPos();
+ }
+
+ ImGui.EndGroup();
+
+ Vector2 contentMax = ImGui.GetItemRectMax();
+ Vector2 cardMin = origin;
+ Vector2 cardMax = contentMax + padding;
+ if (width > 0f)
+ {
+ cardMax.X = origin.X + width;
+ }
+
+ drawList.ChannelsSetCurrent(0);
+
+ Span colors = ImGui.GetStyle().Colors;
+ DrawShadow(drawList, cardMin, cardMax, rounding, colors[(int)ImGuiCol.BorderShadow]);
+
+ Vector4 fill = background ?? ResolveSurface(colors);
+ drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(fill), rounding);
+
+ if (border)
+ {
+ drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(colors[(int)ImGuiCol.Border]), rounding);
+ }
+
+ drawList.ChannelsMerge();
+
+ // Reserve the full card footprint in the layout so following widgets flow below it.
+ ImGui.SetCursorScreenPos(origin);
+ ImGui.Dummy(cardMax - cardMin);
+ }
+
+ // Resolve an "elevated surface" colour: prefer an opaque child/popup background, else fall back to the window background.
+ private static Vector4 ResolveSurface(Span colors)
+ {
+ Vector4 child = colors[(int)ImGuiCol.ChildBg];
+ if (child.W > 0.01f)
+ {
+ return child;
+ }
+
+ Vector4 popup = colors[(int)ImGuiCol.PopupBg];
+ return popup.W > 0.01f ? popup : colors[(int)ImGuiCol.WindowBg];
+ }
+
+ // Soft drop shadow: a stack of expanding rounded rects, faintest on the outside, offset slightly downward.
+ private static void DrawShadow(ImDrawListPtr drawList, Vector2 min, Vector2 max, float rounding, Vector4 shadowColor)
+ {
+ float baseAlpha = shadowColor.W > 0.01f ? shadowColor.W : 0.25f;
+ Vector3 rgb = shadowColor.W > 0.01f ? new Vector3(shadowColor.X, shadowColor.Y, shadowColor.Z) : Vector3.Zero;
+
+ const int layers = 4;
+ float maxGrow = MathF.Max(rounding, 6.0f);
+ Vector2 offset = new(0.0f, MathF.Max(rounding * 0.25f, 2.0f));
+
+ // Largest (faintest) first so smaller, stronger layers stack on top.
+ for (int i = layers; i >= 1; i--)
+ {
+ float f = (float)i / layers;
+ float grow = maxGrow * f;
+ float alpha = baseAlpha * (1.0f - f);
+ if (alpha <= 0.0f)
+ {
+ continue;
+ }
+
+ Vector2 g = new(grow, grow);
+ drawList.AddRectFilled(min - g + offset, max + g + offset, ImGui.GetColorU32(new Vector4(rgb.X, rgb.Y, rgb.Z, alpha)), rounding + grow);
+ }
+ }
+ }
+}
diff --git a/ImGui.Widgets/PinInput.cs b/ImGui.Widgets/PinInput.cs
new file mode 100644
index 0000000..e3c4bdf
--- /dev/null
+++ b/ImGui.Widgets/PinInput.cs
@@ -0,0 +1,196 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.ImGui.Widgets;
+
+using System.Collections.Generic;
+using System.Text;
+
+using Hexa.NET.ImGui;
+
+///
+/// Provides custom ImGui widgets.
+///
+public static partial class ImGuiWidgets
+{
+ ///
+ /// Draws an N-box PIN / one-time-passcode entry. Typing fills the boxes left to right and auto-advances
+ /// to the next box; pressing Backspace in an empty box clears the previous box and moves focus back.
+ /// The value is kept left-packed (no gaps) and, when is set, restricted to digits.
+ ///
+ /// A unique identifier for the widget group.
+ /// The current entry. Updated in place; never longer than .
+ /// The number of boxes.
+ /// When the characters are rendered as dots instead of the typed glyphs.
+ /// When only decimal digits are accepted.
+ /// if the value changed this frame; otherwise .
+ public static bool PinInput(string id, ref string value, int length = 6, bool masked = false, bool digitsOnly = true) =>
+ PinInputImpl.Draw(id, ref value, length, masked, digitsOnly);
+
+ ///
+ /// Cleans an arbitrary string into a left-packed PIN value: optionally strips non-digit characters and
+ /// truncates to . Pure helper backing ; safe to call without a GL context.
+ ///
+ /// The raw text to clean (may be ).
+ /// The maximum number of characters to keep.
+ /// When only decimal digits are retained.
+ /// The cleaned, length-clamped value.
+ public static string NormalizePin(string? value, int length, bool digitsOnly)
+ {
+ if (string.IsNullOrEmpty(value) || length <= 0)
+ {
+ return string.Empty;
+ }
+
+ StringBuilder builder = new(length);
+ foreach (char c in value)
+ {
+ if (digitsOnly && !char.IsDigit(c))
+ {
+ continue;
+ }
+
+ builder.Append(c);
+ if (builder.Length >= length)
+ {
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Returns a copy of with the slot at set to
+ /// , or cleared when is . The result
+ /// stays left-packed: setting beyond the current end appends, clearing removes (shifting following characters left).
+ /// Pure helper backing ; safe to call without a GL context.
+ ///
+ /// The current left-packed value.
+ /// The zero-based slot to modify.
+ /// The character to place, or to clear the slot.
+ /// The total number of slots.
+ /// The updated value, never longer than .
+ public static string SetPinSlot(string value, int index, char? character, int length)
+ {
+ value ??= string.Empty;
+ if (index < 0 || index >= length)
+ {
+ return value;
+ }
+
+ List chars = [.. value];
+
+ if (character.HasValue)
+ {
+ if (index < chars.Count)
+ {
+ chars[index] = character.Value;
+ }
+ else if (chars.Count < length)
+ {
+ // Append at the next free slot; clicking ahead of the cursor still fills contiguously.
+ chars.Add(character.Value);
+ }
+ }
+ else if (index < chars.Count)
+ {
+ chars.RemoveAt(index);
+ }
+
+ if (chars.Count > length)
+ {
+ chars.RemoveRange(length, chars.Count - length);
+ }
+
+ return new string([.. chars]);
+ }
+
+ internal static class PinInputImpl
+ {
+ // Per-ID box index that should grab keyboard focus next frame; -1 when there is no pending request.
+ private static readonly Dictionary FocusRequest = [];
+
+ public static bool Draw(string id, ref string value, int length, bool masked, bool digitsOnly)
+ {
+ if (length < 1)
+ {
+ length = 1;
+ }
+
+ value = NormalizePin(value, length, digitsOnly);
+
+ uint groupId = ImGui.GetID(id);
+ int focusReq = FocusRequest.GetValueOrDefault(groupId, -1);
+
+ ImGui.PushID(id);
+
+ float box = ImGui.GetFrameHeight() * 1.2f;
+ ImGuiInputTextFlags flags = ImGuiInputTextFlags.AutoSelectAll;
+ if (digitsOnly)
+ {
+ flags |= ImGuiInputTextFlags.CharsDecimal;
+ }
+
+ if (masked)
+ {
+ flags |= ImGuiInputTextFlags.Password;
+ }
+
+ bool changed = false;
+
+ for (int i = 0; i < length; i++)
+ {
+ if (i > 0)
+ {
+ ImGui.SameLine(0.0f, ImGui.GetStyle().ItemInnerSpacing.X);
+ }
+
+ ImGui.PushID(i);
+ ImGui.SetNextItemWidth(box);
+
+ if (focusReq == i)
+ {
+ ImGui.SetKeyboardFocusHere();
+ }
+
+ bool empty = i >= value.Length;
+ string slot = empty ? string.Empty : value[i].ToString();
+ string edited = slot;
+
+ if (ImGui.InputText("##slot", ref edited, 8u, flags))
+ {
+ string cleaned = NormalizePin(edited, 2, digitsOnly);
+ char? typed = cleaned.Length > 0 ? cleaned[^1] : null;
+ value = SetPinSlot(value, i, typed, length);
+ changed = true;
+
+ // Auto-advance after a successful keystroke.
+ if (typed.HasValue && i + 1 < length)
+ {
+ FocusRequest[groupId] = i + 1;
+ }
+ }
+ else if (empty && i > 0 && ImGui.IsItemFocused() && ImGui.IsKeyPressed(ImGuiKey.Backspace))
+ {
+ // Backspace in an empty box clears the previous box and steps focus back.
+ value = SetPinSlot(value, i - 1, null, length);
+ FocusRequest[groupId] = i - 1;
+ changed = true;
+ }
+
+ ImGui.PopID();
+ }
+
+ // Drop the focus request only if it was consumed (not replaced by a new one this frame).
+ if (focusReq >= 0 && FocusRequest.GetValueOrDefault(groupId, -1) == focusReq)
+ {
+ FocusRequest.Remove(groupId);
+ }
+
+ ImGui.PopID();
+ return changed;
+ }
+ }
+}
diff --git a/ImGui.Widgets/SkeletonLoader.cs b/ImGui.Widgets/SkeletonLoader.cs
new file mode 100644
index 0000000..2f16379
--- /dev/null
+++ b/ImGui.Widgets/SkeletonLoader.cs
@@ -0,0 +1,118 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.ImGui.Widgets;
+
+using System;
+using System.Numerics;
+
+using Hexa.NET.ImGui;
+
+///
+/// Provides custom ImGui widgets.
+///
+public static partial class ImGuiWidgets
+{
+ ///
+ /// Draws a shimmering placeholder used while real content is loading. A bright band sweeps
+ /// horizontally across a muted base fill, giving the familiar "skeleton" loading effect. The
+ /// animation is driven by so it is frame-rate independent and needs
+ /// no per-widget state.
+ ///
+ public static void SkeletonLine(string id, float width = 0f, float height = 0f)
+ {
+ float resolvedHeight = height > 0f ? height : ImGui.GetTextLineHeight();
+ float resolvedWidth = width > 0f ? width : ImGui.GetContentRegionAvail().X;
+ SkeletonImpl.Draw(id, new Vector2(resolvedWidth, resolvedHeight), resolvedHeight * 0.35f);
+ }
+
+ ///
+ /// Draws a rectangular shimmering placeholder of the given size (e.g. an image or thumbnail slot).
+ ///
+ public static void SkeletonRect(string id, Vector2 size, float rounding = -1f)
+ {
+ float resolvedRounding = rounding >= 0f ? rounding : MathF.Max(ImGui.GetStyle().FrameRounding, 4.0f);
+ SkeletonImpl.Draw(id, size, resolvedRounding);
+ }
+
+ ///
+ /// Draws a circular shimmering placeholder (e.g. an avatar slot).
+ ///
+ public static void SkeletonCircle(string id, float diameter = 0f)
+ {
+ float resolved = diameter > 0f ? diameter : ImGui.GetFrameHeight() * 2.0f;
+ SkeletonImpl.Draw(id, new Vector2(resolved, resolved), resolved * 0.5f);
+ }
+
+ internal static class SkeletonImpl
+ {
+ // Seconds for the shimmer band to travel once across the placeholder.
+ private const float SweepPeriod = 1.4f;
+
+ // Width of the moving highlight band as a fraction of the placeholder width.
+ private const float BandFraction = 0.4f;
+
+ ///
+ /// Normalized sweep phase in [0, 1) for the given absolute and
+ /// . Pure helper so the sweep can be unit tested without a GL context.
+ ///
+ internal static float ShimmerPhase(double time, float period)
+ {
+ if (period <= 0.0f)
+ {
+ return 0.0f;
+ }
+
+ double cycles = time / period;
+ return (float)(cycles - Math.Floor(cycles));
+ }
+
+ public static void Draw(string id, Vector2 size, float rounding)
+ {
+ if (size.X <= 0.0f || size.Y <= 0.0f)
+ {
+ return;
+ }
+
+ Vector2 origin = ImGui.GetCursorScreenPos();
+ ImGui.Dummy(size);
+
+ Vector2 min = origin;
+ Vector2 max = origin + size;
+
+ ImDrawListPtr drawList = ImGui.GetWindowDrawList();
+ Span colors = ImGui.GetStyle().Colors;
+
+ // Muted base and a lighter highlight, both pulled from the active theme.
+ Vector4 baseColor = colors[(int)ImGuiCol.FrameBg];
+ Vector4 highlight = colors[(int)ImGuiCol.FrameBgHovered];
+
+ drawList.AddRectFilled(min, max, ImGui.GetColorU32(baseColor), rounding);
+
+ // Clip the moving band to the placeholder bounds so it never bleeds past the edges.
+ drawList.PushClipRect(min, max, true);
+
+ float bandWidth = MathF.Max(size.X * BandFraction, 8.0f);
+ float travel = size.X + bandWidth;
+
+ // Offset each placeholder's sweep by a stable amount derived from its id so a group of
+ // skeletons shimmers in a staggered wave rather than in lockstep.
+ double offsetSeconds = ImGui.GetID(id) % 1000u / 1000.0 * SweepPeriod;
+ float phase = ShimmerPhase(ImGui.GetTime() + offsetSeconds, SweepPeriod);
+ float center = min.X - (bandWidth * 0.5f) + (phase * travel);
+
+ float left = center - (bandWidth * 0.5f);
+ float right = center + (bandWidth * 0.5f);
+
+ uint edge = ImGui.GetColorU32(highlight with { W = 0.0f });
+ uint core = ImGui.GetColorU32(highlight);
+
+ // Two halves form a transparent -> bright -> transparent horizontal gradient.
+ drawList.AddRectFilledMultiColor(new Vector2(left, min.Y), new Vector2(center, max.Y), edge, core, core, edge);
+ drawList.AddRectFilledMultiColor(new Vector2(center, min.Y), new Vector2(right, max.Y), core, edge, edge, core);
+
+ drawList.PopClipRect();
+ }
+ }
+}
diff --git a/docs/plans/2026-05-28-mobile-ui-widgets.md b/docs/plans/2026-05-28-mobile-ui-widgets.md
index 7c4bc3e..e2ff89e 100644
--- a/docs/plans/2026-05-28-mobile-ui-widgets.md
+++ b/docs/plans/2026-05-28-mobile-ui-widgets.md
@@ -6,6 +6,14 @@ Date: 2026-05-28
Expand `ImGui.Widgets` with common mobile UI patterns that Dear ImGui does not ship natively, while staying true to ImGui's immediate-mode philosophy and the library's existing conventions (static entry points, scoped RAII, primary constructors, tabs for indentation, file-scoped namespaces).
+## Progress
+
+- **Phase 0 — Foundational Infrastructure**: ✅ complete (`GestureDetector`, `Tween`/`Spring`/`Easing`, `InertialScroll`, `OverlayHost`).
+- **Phase 1 — High-value, low-risk widgets**: ✅ complete (`Switch`, `SegmentedControl`, `Chip`/`ChipGroup`, `Stepper`, `Avatar`, `Badge`, `Rating`, `RangeSlider`, `PageIndicator`, `Card`, `SkeletonLoader`, `PinInput`).
+- **Phase 2 — Gesture-dependent widgets**: ⬜ not started. Next up — start with `SwipeableListItem` (already have `GestureDetector`).
+- **Phase 3 — Overlays & navigation**: ⬜ not started (`OverlayHost` is ready; begin with `Toast`).
+- **Phase 4 — Polish**: ⬜ not started.
+
## Phase 0 — Foundational Infrastructure
Most mobile widgets need shared plumbing. Build these first; everything else depends on them.
diff --git a/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs b/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs
index 0f33fca..7bd23ed 100644
--- a/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs
+++ b/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs
@@ -83,6 +83,11 @@ private static void Main()
private static int notificationCount = 5;
private static int carouselPage;
+ // Mobile container / loader demo state
+ private static string pinValue = string.Empty;
+ private static string otpValue = string.Empty;
+ private static bool skeletonLoading = true;
+
private static ImGuiWidgets.DividerContainer DividerContainer { get; } = new("DemoDividerContainer");
private static ImGuiPopups.MessageOK MessageOK { get; } = new();
private static ImGuiWidgets.TabPanel DemoTabPanel { get; } = new("DemoTabPanel", true, true);
@@ -178,6 +183,7 @@ private static void ShowWidgetDemos(float size)
ShowScopedWidgetsDemo();
ShowTreeDemo();
ShowMobileDecoratorsDemo();
+ ShowMobileContainersDemo();
}
private static void ShowAdvancedDemos(float size)
@@ -789,6 +795,53 @@ private static void ShowMobileDecoratorsDemo()
}
}
+ private static void ShowMobileContainersDemo()
+ {
+ if (ImGui.CollapsingHeader("Mobile - Containers & Loaders"))
+ {
+ ImGui.TextUnformatted("Card (scoped, shadowed, elevated container):");
+ ImGui.Separator();
+
+ using (new ImGuiWidgets.Card(width: 280.0f))
+ {
+ ImGui.TextUnformatted("Card title");
+ ImGui.TextWrapped("Cards group related content on an elevated surface with a soft drop shadow. Padding and rounding scale with the theme.");
+ if (ImGui.Button("Action##cardAction"))
+ {
+ notificationCount++;
+ }
+ }
+
+ ImGui.Separator();
+ ImGui.TextUnformatted("PIN / OTP input (auto-advancing boxes):");
+ ImGuiWidgets.PinInput("Pin##pin", ref pinValue, length: 4);
+ ImGui.TextUnformatted($"PIN: {(pinValue.Length == 4 ? pinValue : "(incomplete)")}");
+
+ ImGui.TextUnformatted("Masked, 6 digits:");
+ ImGuiWidgets.PinInput("Otp##otp", ref otpValue, length: 6, masked: true);
+
+ ImGui.Separator();
+ ImGui.Checkbox("Loading##skeletonToggle", ref skeletonLoading);
+ ImGui.TextUnformatted("Skeleton loaders (shimmer placeholders):");
+ if (skeletonLoading)
+ {
+ ImGuiWidgets.SkeletonCircle("SkelAvatar");
+ ImGui.SameLine();
+ ImGui.BeginGroup();
+ ImGuiWidgets.SkeletonLine("SkelLine1", width: 180.0f);
+ ImGui.Spacing();
+ ImGuiWidgets.SkeletonLine("SkelLine2", width: 120.0f);
+ ImGui.EndGroup();
+ ImGui.Spacing();
+ ImGuiWidgets.SkeletonRect("SkelThumb", new Vector2(220.0f, 80.0f));
+ }
+ else
+ {
+ ImGui.TextUnformatted("Content loaded.");
+ }
+ }
+ }
+
private static void ShowComboDemo()
{
if (ImGui.CollapsingHeader("Combo Boxes"))
diff --git a/tests/ImGui.Widgets.Tests/ContainerWidgetTests.cs b/tests/ImGui.Widgets.Tests/ContainerWidgetTests.cs
new file mode 100644
index 0000000..96191d6
--- /dev/null
+++ b/tests/ImGui.Widgets.Tests/ContainerWidgetTests.cs
@@ -0,0 +1,96 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.ImGui.Widgets.Tests;
+
+using ktsu.ImGui.Widgets;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests for the pure logic backing the Phase 1 "container / loader" widgets (Card, SkeletonLoader, PinInput).
+/// The draw paths are immediate-mode and verified visually in the demo; these cover the testable helpers.
+///
+[TestClass]
+public sealed class ContainerWidgetTests
+{
+ [TestMethod]
+ public void NormalizePin_DigitsOnly_StripsNonDigits()
+ {
+ Assert.AreEqual("1234", ImGuiWidgets.NormalizePin("1a2b3c4d", 6, digitsOnly: true));
+ Assert.AreEqual("0090", ImGuiWidgets.NormalizePin(" 00-90 ", 6, digitsOnly: true));
+ }
+
+ [TestMethod]
+ public void NormalizePin_TruncatesToLength()
+ {
+ Assert.AreEqual("123456", ImGuiWidgets.NormalizePin("1234567890", 6, digitsOnly: true));
+ Assert.AreEqual("ab", ImGuiWidgets.NormalizePin("abcdef", 2, digitsOnly: false));
+ }
+
+ [TestMethod]
+ public void NormalizePin_NonDigitsKeptWhenNotDigitsOnly()
+ {
+ Assert.AreEqual("a1b2", ImGuiWidgets.NormalizePin("a1b2", 8, digitsOnly: false));
+ }
+
+ [TestMethod]
+ public void NormalizePin_NullEmptyOrZeroLength_ReturnsEmpty()
+ {
+ Assert.AreEqual("", ImGuiWidgets.NormalizePin(null, 6, digitsOnly: true));
+ Assert.AreEqual("", ImGuiWidgets.NormalizePin("", 6, digitsOnly: true));
+ Assert.AreEqual("", ImGuiWidgets.NormalizePin("123", 0, digitsOnly: true));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_ReplaceExistingSlot()
+ {
+ Assert.AreEqual("1x34", ImGuiWidgets.SetPinSlot("1234", 1, 'x', 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_AppendAtNextFreeSlot()
+ {
+ Assert.AreEqual("125", ImGuiWidgets.SetPinSlot("12", 2, '5', 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_ClickingAheadStillFillsContiguously()
+ {
+ // Typing into a box past the current end appends rather than leaving a gap.
+ Assert.AreEqual("125", ImGuiWidgets.SetPinSlot("12", 3, '5', 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_ClearRemovesAndShiftsLeft()
+ {
+ Assert.AreEqual("134", ImGuiWidgets.SetPinSlot("1234", 1, null, 4));
+ Assert.AreEqual("123", ImGuiWidgets.SetPinSlot("1234", 3, null, 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_ClearEmptySlot_NoChange()
+ {
+ Assert.AreEqual("12", ImGuiWidgets.SetPinSlot("12", 3, null, 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_ReplaceWithinFullValue()
+ {
+ Assert.AreEqual("1294", ImGuiWidgets.SetPinSlot("1234", 2, '9', 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_IndexOutOfRange_ReturnsUnchanged()
+ {
+ Assert.AreEqual("12", ImGuiWidgets.SetPinSlot("12", -1, '9', 4));
+ Assert.AreEqual("12", ImGuiWidgets.SetPinSlot("12", 4, '9', 4));
+ }
+
+ [TestMethod]
+ public void SetPinSlot_NullValue_TreatedAsEmpty()
+ {
+ Assert.AreEqual("9", ImGuiWidgets.SetPinSlot(null!, 0, '9', 4));
+ }
+}