From 139abe29828fd42772a8d6ff740d2713f9cbdfc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 03:27:57 +0000 Subject: [PATCH 1/2] feat: add Card, SkeletonLoader, and PinInput mobile widgets Completes Phase 1 of the mobile UI widgets plan with the container/loader group: - Card: scoped (RAII) shadowed, elevated container. Draws content on a foreground draw-list channel and paints the shadow/fill/border behind it on dispose, resolving colours from the active theme. - SkeletonLoader: SkeletonLine/Rect/Circle shimmer placeholders with a time-driven sweeping highlight band (frame-rate independent, stateless). - PinInput: N-box PIN/OTP entry with auto-advance and backspace-to-previous navigation. Pure left-pack helpers (NormalizePin, SetPinSlot) are exposed for unit testing. Adds a 'Mobile - Containers & Loaders' demo section and unit tests for the PIN helpers. Updates the plan doc to mark Phase 1 complete. https://claude.ai/code/session_01MXurQivZo7cHkmJSEaD1DZ --- ImGui.Widgets/Card.cs | 168 +++++++++++++++ ImGui.Widgets/PinInput.cs | 197 ++++++++++++++++++ ImGui.Widgets/SkeletonLoader.cs | 114 ++++++++++ docs/plans/2026-05-28-mobile-ui-widgets.md | 8 + examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs | 53 +++++ .../ContainerWidgetTests.cs | 96 +++++++++ 6 files changed, 636 insertions(+) create mode 100644 ImGui.Widgets/Card.cs create mode 100644 ImGui.Widgets/PinInput.cs create mode 100644 ImGui.Widgets/SkeletonLoader.cs create mode 100644 tests/ImGui.Widgets.Tests/ContainerWidgetTests.cs 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..dfa46c2 --- /dev/null +++ b/ImGui.Widgets/PinInput.cs @@ -0,0 +1,197 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGui.Widgets; + +using System; +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..23054f9 --- /dev/null +++ b/ImGui.Widgets/SkeletonLoader.cs @@ -0,0 +1,114 @@ +// 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; + float phase = ShimmerPhase(ImGui.GetTime(), 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)); + } +} From b780daa6d85ecfc056cafff9329074cd7ff07b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 04:18:42 +0000 Subject: [PATCH 2/2] style: drop unused using and use id for skeleton shimmer offset - Remove unnecessary 'using System;' from PinInput (IDE0005). - Use the SkeletonLoader id to derive a stable per-placeholder sweep offset so grouped skeletons shimmer in a staggered wave, which also resolves the unused-parameter warning (IDE0060). https://claude.ai/code/session_01MXurQivZo7cHkmJSEaD1DZ --- ImGui.Widgets/PinInput.cs | 1 - ImGui.Widgets/SkeletonLoader.cs | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ImGui.Widgets/PinInput.cs b/ImGui.Widgets/PinInput.cs index dfa46c2..e3c4bdf 100644 --- a/ImGui.Widgets/PinInput.cs +++ b/ImGui.Widgets/PinInput.cs @@ -4,7 +4,6 @@ namespace ktsu.ImGui.Widgets; -using System; using System.Collections.Generic; using System.Text; diff --git a/ImGui.Widgets/SkeletonLoader.cs b/ImGui.Widgets/SkeletonLoader.cs index 23054f9..2f16379 100644 --- a/ImGui.Widgets/SkeletonLoader.cs +++ b/ImGui.Widgets/SkeletonLoader.cs @@ -95,7 +95,11 @@ public static void Draw(string id, Vector2 size, float rounding) float bandWidth = MathF.Max(size.X * BandFraction, 8.0f); float travel = size.X + bandWidth; - float phase = ShimmerPhase(ImGui.GetTime(), SweepPeriod); + + // 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);