Skip to content

Commit 1d39e7f

Browse files
sebgodclaude
andcommitted
Add generic DropdownMenuState and RenderDropdownMenu to PixelWidgetBase
New DropdownMenuState with Open/Close/HandleKeyDown, anchor geometry, OnSelect/OnCustom callbacks, and customizable custom entry label. RenderDropdownMenu on PixelWidgetBase renders the overlay with full-screen backdrop dismiss, item highlighting, and keyboard navigation. Also fix RGBAColor32.Lerp to use Math.Round instead of truncation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6251517 commit 1d39e7f

File tree

5 files changed

+240
-5
lines changed

5 files changed

+240
-5
lines changed

src/DIR.Lib/DIR.Lib.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
99
<IsAotCompatible>true</IsAotCompatible>
1010
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
11-
<VersionPrefix>1.3.68</VersionPrefix>
11+
<VersionPrefix>1.3.70</VersionPrefix>
1212
<AssemblyVersion>1.0.0.0</AssemblyVersion>
1313
<PackageID>DIR.Lib</PackageID>
1414
<PackageLicenseFile>LICENSE</PackageLicenseFile>

src/DIR.Lib/DropdownMenuState.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace DIR.Lib
5+
{
6+
/// <summary>
7+
/// State for a generic dropdown menu overlay. Open it with <see cref="Open"/>,
8+
/// render with <see cref="PixelWidgetBase{TSurface}.RenderDropdownMenu"/>,
9+
/// and handle keyboard with <see cref="HandleKeyDown"/>.
10+
/// </summary>
11+
public class DropdownMenuState
12+
{
13+
public bool IsOpen { get; set; }
14+
public IReadOnlyList<string> Items { get; set; } = [];
15+
public int HighlightIndex { get; set; } = -1;
16+
17+
// Anchor geometry — set by the trigger during normal layout
18+
public float AnchorX { get; set; }
19+
public float AnchorY { get; set; }
20+
public float AnchorWidth { get; set; }
21+
22+
/// <summary>Callback when an item is selected (receives index and item text).</summary>
23+
public Action<int, string>? OnSelect { get; set; }
24+
25+
/// <summary>Whether to include a "Custom..." entry at the end of the list.</summary>
26+
public bool HasCustomEntry { get; set; }
27+
28+
/// <summary>Label for the custom entry (defaults to "Custom...").</summary>
29+
public string CustomEntryLabel { get; set; } = "Custom...";
30+
31+
/// <summary>Callback when the custom entry is selected.</summary>
32+
public Action? OnCustom { get; set; }
33+
34+
/// <summary>
35+
/// Opens the dropdown anchored below the trigger at the given position.
36+
/// </summary>
37+
public void Open(float x, float y, float width,
38+
IReadOnlyList<string> items,
39+
Action<int, string> onSelect,
40+
bool hasCustomEntry = false,
41+
Action? onCustom = null,
42+
string? customEntryLabel = null)
43+
{
44+
IsOpen = true;
45+
AnchorX = x;
46+
AnchorY = y;
47+
AnchorWidth = width;
48+
Items = items;
49+
OnSelect = onSelect;
50+
HasCustomEntry = hasCustomEntry;
51+
CustomEntryLabel = customEntryLabel ?? "Custom...";
52+
OnCustom = onCustom;
53+
HighlightIndex = -1;
54+
}
55+
56+
/// <summary>
57+
/// Closes the dropdown.
58+
/// </summary>
59+
public void Close()
60+
{
61+
IsOpen = false;
62+
HighlightIndex = -1;
63+
}
64+
65+
/// <summary>
66+
/// Handles arrow keys, Enter, and Escape. Returns true if consumed.
67+
/// </summary>
68+
public bool HandleKeyDown(InputKey key)
69+
{
70+
if (!IsOpen)
71+
{
72+
return false;
73+
}
74+
75+
var totalItems = Items.Count + (HasCustomEntry ? 1 : 0);
76+
77+
switch (key)
78+
{
79+
case InputKey.Down:
80+
HighlightIndex = Math.Min(HighlightIndex + 1, totalItems - 1);
81+
return true;
82+
83+
case InputKey.Up:
84+
HighlightIndex = Math.Max(HighlightIndex - 1, 0);
85+
return true;
86+
87+
case InputKey.Enter:
88+
if (HighlightIndex >= 0 && HighlightIndex < Items.Count)
89+
{
90+
OnSelect?.Invoke(HighlightIndex, Items[HighlightIndex]);
91+
Close();
92+
}
93+
else if (HasCustomEntry && HighlightIndex == Items.Count)
94+
{
95+
OnCustom?.Invoke();
96+
Close();
97+
}
98+
return true;
99+
100+
case InputKey.Escape:
101+
Close();
102+
return true;
103+
104+
default:
105+
return false;
106+
}
107+
}
108+
}
109+
}

src/DIR.Lib/InputKey.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,30 @@ public enum InputModifier
5252
Ctrl = 2,
5353
Alt = 4,
5454
}
55+
56+
/// <summary>
57+
/// Extension methods for mapping <see cref="InputKey"/> to <see cref="TextInputKey"/>.
58+
/// </summary>
59+
public static class InputKeyExtensions
60+
{
61+
extension(InputKey key)
62+
{
63+
/// <summary>
64+
/// Maps an <see cref="InputKey"/> and <see cref="InputModifier"/> to a <see cref="TextInputKey"/>,
65+
/// or null if not applicable. Handles Ctrl+A → SelectAll.
66+
/// </summary>
67+
public TextInputKey? ToTextInputKey(InputModifier modifiers = InputModifier.None) => key switch
68+
{
69+
InputKey.Backspace => TextInputKey.Backspace,
70+
InputKey.Delete => TextInputKey.Delete,
71+
InputKey.Left => TextInputKey.Left,
72+
InputKey.Right => TextInputKey.Right,
73+
InputKey.Home => TextInputKey.Home,
74+
InputKey.End => TextInputKey.End,
75+
InputKey.Enter => TextInputKey.Enter,
76+
InputKey.Escape => TextInputKey.Escape,
77+
InputKey.A when (modifiers & InputModifier.Ctrl) != 0 => TextInputKey.SelectAll,
78+
_ => null
79+
};
80+
}
81+
}

src/DIR.Lib/PixelWidgetBase.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,105 @@ public List<TextInputState> GetRegisteredTextInputs()
150150
/// </summary>
151151
public virtual bool HandleMouseWheel(float scrollY, float mouseX, float mouseY) => false;
152152

153+
// --- Dropdown menu ---
154+
155+
/// <summary>
156+
/// Renders a dropdown menu overlay. <b>Must be called last</b> in the render pass
157+
/// so that its clickable regions win hit testing (paint order = z-order).
158+
/// Registers a full-screen backdrop that dismisses the dropdown on click-outside.
159+
/// </summary>
160+
protected void RenderDropdownMenu(
161+
DropdownMenuState dropdown,
162+
string fontPath,
163+
float fontSize,
164+
RGBAColor32 bgColor,
165+
RGBAColor32 highlightColor,
166+
RGBAColor32 textColor,
167+
RGBAColor32 borderColor,
168+
float viewportWidth,
169+
float viewportHeight,
170+
float maxHeight = 0f)
171+
{
172+
if (!dropdown.IsOpen || dropdown.Items.Count == 0)
173+
{
174+
return;
175+
}
176+
177+
var rowH = fontSize * 1.8f;
178+
var padding = fontSize * 0.5f;
179+
var totalItems = dropdown.Items.Count + (dropdown.HasCustomEntry ? 1 : 0);
180+
var dropdownH = totalItems * rowH;
181+
if (maxHeight > 0f && dropdownH > maxHeight)
182+
{
183+
dropdownH = maxHeight;
184+
}
185+
186+
var x = dropdown.AnchorX;
187+
var y = dropdown.AnchorY;
188+
var w = dropdown.AnchorWidth;
189+
190+
// Full-screen backdrop — closes dropdown on click-outside
191+
RegisterClickable(0, 0, viewportWidth, viewportHeight, new HitResult.ButtonHit("DropdownBackdrop"),
192+
() => dropdown.Close());
193+
194+
// Border
195+
FillRect(x - 1f, y - 1f, w + 2f, dropdownH + 2f, borderColor);
196+
// Background
197+
FillRect(x, y, w, dropdownH, bgColor);
198+
199+
// Items
200+
var itemY = y;
201+
for (var i = 0; i < dropdown.Items.Count && itemY + rowH <= y + dropdownH; i++)
202+
{
203+
if (i == dropdown.HighlightIndex)
204+
{
205+
FillRect(x, itemY, w, rowH, highlightColor);
206+
}
207+
208+
DrawText(dropdown.Items[i].AsSpan(), fontPath,
209+
x + padding, itemY, w - padding * 2f, rowH,
210+
fontSize, textColor, TextAlign.Near, TextAlign.Center);
211+
212+
var capturedI = i;
213+
var capturedItem = dropdown.Items[i];
214+
RegisterClickable(x, itemY, w, rowH, new HitResult.ListItemHit("Dropdown", i),
215+
() =>
216+
{
217+
dropdown.OnSelect?.Invoke(capturedI, capturedItem);
218+
dropdown.Close();
219+
});
220+
221+
itemY += rowH;
222+
}
223+
224+
// "Custom..." entry
225+
if (dropdown.HasCustomEntry && itemY + rowH <= y + dropdownH)
226+
{
227+
var customIdx = dropdown.Items.Count;
228+
if (customIdx == dropdown.HighlightIndex)
229+
{
230+
FillRect(x, itemY, w, rowH, highlightColor);
231+
}
232+
233+
// Slightly dimmed, blue-shifted text for the "Custom..." entry
234+
var customColor = new RGBAColor32(
235+
(byte)((textColor.Red * 3 + 2) / 4),
236+
(byte)((textColor.Green * 3 + 2) / 4),
237+
(byte)Math.Min(255, textColor.Blue + 40),
238+
textColor.Alpha);
239+
DrawText(dropdown.CustomEntryLabel.AsSpan(), fontPath,
240+
x + padding, itemY, w - padding * 2f, rowH,
241+
fontSize, customColor, TextAlign.Near, TextAlign.Center);
242+
243+
RegisterClickable(x, itemY, w, rowH, new HitResult.ListItemHit("Dropdown", customIdx),
244+
() =>
245+
{
246+
dropdown.OnCustom?.Invoke();
247+
dropdown.Close();
248+
});
249+
}
250+
}
251+
153252
// --- Drawing helpers ---
154253

155254
protected void FillRect(float x, float y, float w, float h, RGBAColor32 color)

src/DIR.Lib/RGBAColor32.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ public readonly record struct RGBAColor32(byte Red, byte Green, byte Blue, byte
88
/// Linearly interpolates between two colors by factor t (0..1).
99
/// </summary>
1010
public static RGBAColor32 Lerp(RGBAColor32 a, RGBAColor32 b, float t) => new(
11-
(byte)(a.Red + (b.Red - a.Red) * t),
12-
(byte)(a.Green + (b.Green - a.Green) * t),
13-
(byte)(a.Blue + (b.Blue - a.Blue) * t),
14-
(byte)(a.Alpha + (b.Alpha - a.Alpha) * t));
11+
(byte)Math.Round(a.Red + (b.Red - a.Red) * t),
12+
(byte)Math.Round(a.Green + (b.Green - a.Green) * t),
13+
(byte)Math.Round(a.Blue + (b.Blue - a.Blue) * t),
14+
(byte)Math.Round(a.Alpha + (b.Alpha - a.Alpha) * t));
1515

1616
/// <summary>
1717
/// Returns this color with alpha premultiplied by the given mask alpha.

0 commit comments

Comments
 (0)