Skip to content

Commit 09bcf77

Browse files
sebgodclaude
andcommitted
Add TextInputState/Renderer, move MeasureText to Renderer<T> base
- TextInputState: renderer-agnostic text input state (buffer, cursor, activate/deactivate, key handling, commit/cancel) - TextInputRenderer: renders text field with cursor blink onto any Renderer<TSurface> (VkRenderer, RgbaImageRenderer) - MeasureText now returns (Width, Height) tuple on the abstract Renderer<TSurface> base class in DIR.Lib — no more delegate workaround - Updated DIR.Lib 1.3.62, Console.Lib 1.4.92, SdlVulkan.Renderer 1.1.52 (local nupkgs for development) - Fixed VkImageRenderer.MeasureText to use .Width from tuple Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3b8f12b commit 09bcf77

File tree

8 files changed

+268
-4
lines changed

8 files changed

+268
-4
lines changed

nupkgs/Console.Lib.1.4.92.nupkg

57 KB
Binary file not shown.

nupkgs/DIR.Lib.1.3.62.nupkg

26.8 KB
Binary file not shown.
41.3 KB
Binary file not shown.

src/Directory.Packages.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
<PackageVersion Include="Pastel" Version="7.0.1" />
4141
<PackageVersion Include="System.CommandLine" Version="2.0.5" />
4242
<!-- SDL3 + Vulkan packages -->
43-
<PackageVersion Include="DIR.Lib" Version="1.3.61" />
44-
<PackageVersion Include="Console.Lib" Version="1.4.91" />
45-
<PackageVersion Include="SdlVulkan.Renderer" Version="1.1.51" />
43+
<PackageVersion Include="DIR.Lib" Version="1.3.62" />
44+
<PackageVersion Include="Console.Lib" Version="1.4.92" />
45+
<PackageVersion Include="SdlVulkan.Renderer" Version="1.1.52" />
4646
<PackageVersion Include="SDL3-CS" Version="3.5.0-preview.20260213-150035" />
4747
<PackageVersion Include="SDL3-CS.Native" Version="3.5.0-preview.20260205-174353" />
4848
<!-- Silk.NET packages (kept for TianWen.UI.OpenGL, to be removed when that project is deleted) -->

src/NuGet.config

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
<packageSources>
44
<clear />
55
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
6+
<add key="local" value="../nupkgs" />
67
</packageSources>
78
<packageSourceMapping>
89
<packageSource key="nuget.org">
910
<package pattern="*" />
11+
<package pattern="DIR.Lib" />
12+
<package pattern="Console.Lib" />
13+
<package pattern="SdlVulkan.Renderer" />
14+
</packageSource>
15+
<packageSource key="local">
16+
<package pattern="DIR.Lib" />
17+
<package pattern="Console.Lib" />
18+
<package pattern="SdlVulkan.Renderer" />
1019
</packageSource>
1120
</packageSourceMapping>
1221
</configuration>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System;
2+
using DIR.Lib;
3+
4+
namespace TianWen.UI.Abstractions;
5+
6+
/// <summary>
7+
/// Renders a single-line text input field onto any <see cref="Renderer{TSurface}"/>.
8+
/// Works with VkRenderer (GPU) and RgbaImageRenderer (TUI).
9+
/// </summary>
10+
public static class TextInputRenderer
11+
{
12+
private static readonly RGBAColor32 FieldBackground = new(40, 40, 50, 255);
13+
private static readonly RGBAColor32 FieldBackgroundActive = new(50, 50, 65, 255);
14+
private static readonly RGBAColor32 FieldBorder = new(80, 80, 100, 255);
15+
private static readonly RGBAColor32 FieldBorderActive = new(100, 140, 200, 255);
16+
private static readonly RGBAColor32 TextColor = new(220, 220, 220, 255);
17+
private static readonly RGBAColor32 PlaceholderColor = new(120, 120, 140, 255);
18+
private static readonly RGBAColor32 CursorColor = new(200, 200, 255, 255);
19+
20+
/// <summary>
21+
/// Renders a text input field at the specified position.
22+
/// </summary>
23+
/// <param name="renderer">Target renderer.</param>
24+
/// <param name="state">Text input state.</param>
25+
/// <param name="x">Left edge in pixels.</param>
26+
/// <param name="y">Top edge in pixels.</param>
27+
/// <param name="width">Field width in pixels.</param>
28+
/// <param name="height">Field height in pixels.</param>
29+
/// <param name="fontFamily">Font path for text rendering.</param>
30+
/// <param name="fontSize">Font size in pixels.</param>
31+
/// <param name="frameCount">Frame counter for cursor blink (blinks every 30 frames).</param>
32+
public static void Render<TSurface>(
33+
Renderer<TSurface> renderer,
34+
TextInputState state,
35+
int x, int y, int width, int height,
36+
string fontFamily, float fontSize,
37+
long frameCount = 0)
38+
{
39+
var bgColor = state.IsActive ? FieldBackgroundActive : FieldBackground;
40+
var borderColor = state.IsActive ? FieldBorderActive : FieldBorder;
41+
42+
// Background
43+
renderer.FillRectangle(
44+
new RectInt(new PointInt(x + width, y + height), new PointInt(x, y)),
45+
bgColor);
46+
47+
// Border
48+
renderer.DrawRectangle(
49+
new RectInt(new PointInt(x + width, y + height), new PointInt(x, y)),
50+
borderColor, 1);
51+
52+
// Text or placeholder
53+
var padding = (int)(fontSize * 0.4f);
54+
var textX = x + padding;
55+
var textY = y;
56+
var textW = width - padding * 2;
57+
var textH = height;
58+
59+
var displayText = state.Text.Length > 0 ? state.Text : (state.IsActive ? "" : state.Placeholder);
60+
var textColor = state.Text.Length > 0 ? TextColor : PlaceholderColor;
61+
62+
if (displayText.Length > 0)
63+
{
64+
var layoutRect = new RectInt(
65+
new PointInt(textX + textW, textY + textH),
66+
new PointInt(textX, textY));
67+
68+
renderer.DrawText(
69+
displayText.AsSpan(),
70+
fontFamily,
71+
fontSize,
72+
textColor,
73+
layoutRect,
74+
TextAlign.Near,
75+
TextAlign.Center);
76+
}
77+
78+
// Cursor (blinking)
79+
if (state.IsActive && (frameCount / 30) % 2 == 0)
80+
{
81+
// Measure text up to cursor position to find cursor X
82+
var textBeforeCursor = state.Text.Length > 0 && state.CursorPos > 0
83+
? state.Text[..state.CursorPos]
84+
: "";
85+
var cursorX = textX + (int)renderer.MeasureText(textBeforeCursor.AsSpan(), fontFamily, fontSize).Width;
86+
var cursorY = y + (int)(height * 0.15f);
87+
var cursorH = (int)(height * 0.7f);
88+
89+
renderer.FillRectangle(
90+
new RectInt(new PointInt(cursorX + 2, cursorY + cursorH), new PointInt(cursorX, cursorY)),
91+
CursorColor);
92+
}
93+
}
94+
95+
/// <summary>
96+
/// Hit-tests whether a click is inside the text field.
97+
/// </summary>
98+
public static bool HitTest(int clickX, int clickY, int fieldX, int fieldY, int fieldWidth, int fieldHeight)
99+
{
100+
return clickX >= fieldX && clickX < fieldX + fieldWidth
101+
&& clickY >= fieldY && clickY < fieldY + fieldHeight;
102+
}
103+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System;
2+
3+
namespace TianWen.UI.Abstractions;
4+
5+
/// <summary>
6+
/// State for a single-line text input field. Renderer-agnostic — works with both
7+
/// VkRenderer (GPU) and RgbaImageRenderer (TUI). The SDL3 StartTextInput/StopTextInput
8+
/// lifecycle is managed by the host application's event loop.
9+
/// </summary>
10+
public class TextInputState
11+
{
12+
/// <summary>Whether this field is currently focused and accepting text input.</summary>
13+
public bool IsActive { get; set; }
14+
15+
/// <summary>The current text content.</summary>
16+
public string Text { get; set; } = "";
17+
18+
/// <summary>Cursor position (character index, 0 = before first char).</summary>
19+
public int CursorPos { get; set; }
20+
21+
/// <summary>Optional placeholder text shown when empty and not active.</summary>
22+
public string Placeholder { get; set; } = "";
23+
24+
/// <summary>Set to true when the user pressed Enter to commit the value.</summary>
25+
public bool IsCommitted { get; set; }
26+
27+
/// <summary>Set to true when the user pressed Escape to cancel.</summary>
28+
public bool IsCancelled { get; set; }
29+
30+
/// <summary>
31+
/// Handles a text input event (from SDL3 TextInput or Console.Lib TryReadInput).
32+
/// Inserts the text at the cursor position.
33+
/// </summary>
34+
public void InsertText(string input)
35+
{
36+
if (string.IsNullOrEmpty(input))
37+
{
38+
return;
39+
}
40+
41+
Text = Text.Insert(CursorPos, input);
42+
CursorPos += input.Length;
43+
IsCommitted = false;
44+
IsCancelled = false;
45+
}
46+
47+
/// <summary>
48+
/// Handles a key press. Returns true if the key was consumed.
49+
/// </summary>
50+
public bool HandleKey(TextInputKey key)
51+
{
52+
switch (key)
53+
{
54+
case TextInputKey.Backspace:
55+
if (CursorPos > 0)
56+
{
57+
Text = Text.Remove(CursorPos - 1, 1);
58+
CursorPos--;
59+
}
60+
return true;
61+
62+
case TextInputKey.Delete:
63+
if (CursorPos < Text.Length)
64+
{
65+
Text = Text.Remove(CursorPos, 1);
66+
}
67+
return true;
68+
69+
case TextInputKey.Left:
70+
if (CursorPos > 0)
71+
{
72+
CursorPos--;
73+
}
74+
return true;
75+
76+
case TextInputKey.Right:
77+
if (CursorPos < Text.Length)
78+
{
79+
CursorPos++;
80+
}
81+
return true;
82+
83+
case TextInputKey.Home:
84+
CursorPos = 0;
85+
return true;
86+
87+
case TextInputKey.End:
88+
CursorPos = Text.Length;
89+
return true;
90+
91+
case TextInputKey.Enter:
92+
IsCommitted = true;
93+
return true;
94+
95+
case TextInputKey.Escape:
96+
IsCancelled = true;
97+
return true;
98+
99+
default:
100+
return false;
101+
}
102+
}
103+
104+
/// <summary>
105+
/// Resets the field to empty, uncommitted state.
106+
/// </summary>
107+
public void Clear()
108+
{
109+
Text = "";
110+
CursorPos = 0;
111+
IsCommitted = false;
112+
IsCancelled = false;
113+
}
114+
115+
/// <summary>
116+
/// Activates the field with optional initial text.
117+
/// </summary>
118+
public void Activate(string? initialText = null)
119+
{
120+
IsActive = true;
121+
IsCommitted = false;
122+
IsCancelled = false;
123+
if (initialText is not null)
124+
{
125+
Text = initialText;
126+
CursorPos = initialText.Length;
127+
}
128+
}
129+
130+
/// <summary>
131+
/// Deactivates the field.
132+
/// </summary>
133+
public void Deactivate()
134+
{
135+
IsActive = false;
136+
}
137+
}
138+
139+
/// <summary>
140+
/// Abstract key actions for text input, mapped from platform-specific scancodes.
141+
/// </summary>
142+
public enum TextInputKey
143+
{
144+
Backspace,
145+
Delete,
146+
Left,
147+
Right,
148+
Home,
149+
End,
150+
Enter,
151+
Escape
152+
}

src/TianWen.UI.Vulkan/VkImageRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1397,7 +1397,7 @@ private float MeasureText(string text, float fontSize)
13971397
return text.Length * fontSize * 0.6f;
13981398
}
13991399

1400-
return _renderer.MeasureText(text.AsSpan(), _fontPath, fontSize);
1400+
return _renderer.MeasureText(text.AsSpan(), _fontPath, fontSize).Width;
14011401
}
14021402

14031403
private void DrawText(string text, float screenX, float screenY, float fontSize, float r, float g, float b)

0 commit comments

Comments
 (0)