Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions ImGui.Widgets/Card.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides custom ImGui widgets.
/// </summary>
public static partial class ImGuiWidgets
{
/// <summary>
/// A scoped, shadowed container that draws an elevated rounded panel behind whatever is rendered
/// inside the <c>using</c> 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.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// using (new ImGuiWidgets.Card())
/// {
/// ImGui.TextUnformatted("Title");
/// ImGui.TextWrapped("Body copy that flows inside the padded card.");
/// }
/// </code>
/// 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.
/// </remarks>
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;

/// <summary>
/// Begins a card. Call inside a <c>using</c> block; the panel is painted when the block exits.
/// </summary>
/// <param name="width">Outer card width in pixels. When <c>0</c> the card shrinks to fit its content.</param>
/// <param name="padding">Inner padding in pixels on every edge. When negative the style's <see cref="ImGuiStyle.WindowPadding"/> is used.</param>
/// <param name="rounding">Corner radius in pixels. When negative a value derived from the style's frame rounding is used.</param>
/// <param name="background">Explicit fill colour. When <see langword="null"/> an elevated surface colour is resolved from the active theme.</param>
/// <param name="border">Whether to stroke a one-pixel border in the theme's border colour.</param>
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;
}
}

/// <summary>
/// Closes the content group and paints the card's shadow, fill, and border behind it.
/// </summary>
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<Vector4> 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<Vector4> 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);
}
}
}
}
196 changes: 196 additions & 0 deletions ImGui.Widgets/PinInput.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides custom ImGui widgets.
/// </summary>
public static partial class ImGuiWidgets
{
/// <summary>
/// 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 <paramref name="digitsOnly"/> is set, restricted to digits.
/// </summary>
/// <param name="id">A unique identifier for the widget group.</param>
/// <param name="value">The current entry. Updated in place; never longer than <paramref name="length"/>.</param>
/// <param name="length">The number of boxes.</param>
/// <param name="masked">When <see langword="true"/> the characters are rendered as dots instead of the typed glyphs.</param>
/// <param name="digitsOnly">When <see langword="true"/> only decimal digits are accepted.</param>
/// <returns><see langword="true"/> if the value changed this frame; otherwise <see langword="false"/>.</returns>
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);

/// <summary>
/// Cleans an arbitrary string into a left-packed PIN value: optionally strips non-digit characters and
/// truncates to <paramref name="length"/>. Pure helper backing <see cref="PinInput"/>; safe to call without a GL context.
/// </summary>
/// <param name="value">The raw text to clean (may be <see langword="null"/>).</param>
/// <param name="length">The maximum number of characters to keep.</param>
/// <param name="digitsOnly">When <see langword="true"/> only decimal digits are retained.</param>
/// <returns>The cleaned, length-clamped value.</returns>
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();
}

/// <summary>
/// Returns a copy of <paramref name="value"/> with the slot at <paramref name="index"/> set to
/// <paramref name="character"/>, or cleared when <paramref name="character"/> is <see langword="null"/>. The result
/// stays left-packed: setting beyond the current end appends, clearing removes (shifting following characters left).
/// Pure helper backing <see cref="PinInput"/>; safe to call without a GL context.
/// </summary>
/// <param name="value">The current left-packed value.</param>
/// <param name="index">The zero-based slot to modify.</param>
/// <param name="character">The character to place, or <see langword="null"/> to clear the slot.</param>
/// <param name="length">The total number of slots.</param>
/// <returns>The updated value, never longer than <paramref name="length"/>.</returns>
public static string SetPinSlot(string value, int index, char? character, int length)
{
value ??= string.Empty;
if (index < 0 || index >= length)
{
return value;
}

List<char> 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<uint, int> 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;
}
}
}
Loading
Loading