Skip to content

Commit ff7d09a

Browse files
committed
Add NumberBox<T> control
1 parent ab89ac8 commit ff7d09a

File tree

8 files changed

+819
-31
lines changed

8 files changed

+819
-31
lines changed

doc/controls/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This section documents the built-in controls provided by XenoAtom.Terminal.UI.
99
- `doc/controls/textbox.md`
1010
- `doc/controls/textarea.md`
1111
- `doc/controls/maskedinput.md`
12+
- `doc/controls/numberbox.md`
1213

1314
## Buttons & toggles
1415

doc/controls/numberbox.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# NumberBox
2+
3+
`NumberBox<T>` is a single-line numeric editor built on the `TextEditorCore` infrastructure. It provides caret/selection/clipboard support like `TextBox`, while keeping a bindable numeric `Value`.
4+
5+
> Screenshots: `docs/images/numberbox-basic.png` (placeholder)
6+
7+
## Basic usage
8+
9+
```csharp
10+
var age = new State<int>(42);
11+
12+
var ui = new NumberBox<int>()
13+
.Value(age);
14+
```
15+
16+
## Validation
17+
18+
Validation runs on each text change:
19+
20+
- If the text parses successfully (as `T`) and `ValidateValue` returns `null`, the `Value` is updated.
21+
- Otherwise `Value` is not updated and a validation message is shown below the editor.
22+
23+
```csharp
24+
var port = new State<int>(8080);
25+
26+
var ui = new NumberBox<int>
27+
{
28+
ValueValidator = v => v is >= 1 and <= 65535 ? null : "Port must be in [1..65535]",
29+
}.Value(port);
30+
```
31+
32+
## Parsing settings
33+
34+
You can control parsing via:
35+
36+
- `ParseStyles` (default: `NumberStyles.Number`)
37+
- `FormatProvider` (default: `null` meaning current culture behavior for parsing/formatting)
38+
39+
```csharp
40+
var ui = new NumberBox<double>()
41+
.ParseStyles(NumberStyles.Float)
42+
.FormatProvider(CultureInfo.InvariantCulture);
43+
```
44+
45+
## Styling
46+
47+
The validation message is styled via `NumberBoxStyle` (`NumberBoxStyle.Key`).
48+
49+
```csharp
50+
var ui = new NumberBox<int>()
51+
.Style(NumberBoxStyle.Default with
52+
{
53+
ValidationPrefix = "Error: ",
54+
});
55+
```
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Globalization;
2+
using XenoAtom.Terminal.UI;
3+
using XenoAtom.Terminal.UI.Controls;
4+
5+
namespace XenoAtom.Terminal.UI.ControlsDemo.Demos;
6+
7+
[Demo("NumberBox", "Input", Description = "Numeric editor with validation and bindable Value.")]
8+
public sealed class NumberBoxDemo : ControlsDemoBase
9+
{
10+
public NumberBoxDemo() : base(DemoSource.Get())
11+
{
12+
}
13+
14+
public override Visual Build(DemoContext context)
15+
{
16+
var port = new State<int>(8080);
17+
var factor = new State<double>(1.25);
18+
19+
return new VStack(
20+
DemoUi.Hint("NumberBox<T> updates Value when input parses and validation succeeds."),
21+
new HStack(
22+
new VStack(
23+
"Port (1..65535):",
24+
new NumberBox<int>
25+
{
26+
ValueValidator = v => v is >= 1 and <= 65535 ? null : "Port must be in [1..65535]",
27+
}.Value(port))
28+
.Spacing(1),
29+
new VStack(
30+
"Factor (invariant culture):",
31+
new NumberBox<double>()
32+
.Value(factor)
33+
.ParseStyles(NumberStyles.Float)
34+
.FormatProvider(CultureInfo.InvariantCulture))
35+
.Spacing(1))
36+
.Spacing(4),
37+
new TextBlock(() => $"Port: {port.Value} | Factor: {factor.Value:0.###}"),
38+
new Button("Log").Click(() => context.Log($"Port={port.Value}, Factor={factor.Value:0.###}")))
39+
.Spacing(1);
40+
}
41+
}

samples/FullscreenDemo/Program.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
var statusState = new State<string>("ready");
1111
var progressState = new State<double>(0.0);
1212
var sliderState = new State<double>(0.35);
13+
var portState = new State<int>(8080);
1314
var switcherIndexState = new State<int>(0);
1415
var switchState = new State<bool>(false);
1516
var chartTickState = new State<int>(0);
@@ -186,6 +187,14 @@ void ShowModalDialog()
186187
.ValueChanged((_, e) => sliderState.Value = e.NewValue))
187188
.Spacing(1)
188189
.HorizontalAlignment(HorizontalAlignment.Stretch),
190+
new VStack(
191+
"Port (1..65535):",
192+
new NumberBox<int>
193+
{
194+
ValueValidator = v => v is >= 1 and <= 65535 ? null : "Port must be in [1..65535]",
195+
}.Value(portState))
196+
.Spacing(1)
197+
.HorizontalAlignment(HorizontalAlignment.Stretch),
189198
progressBars)
190199
.Spacing(0)
191200
.HorizontalAlignment(HorizontalAlignment.Stretch)
@@ -411,7 +420,7 @@ void ShowModalDialog()
411420
.HorizontalAlignment(HorizontalAlignment.Stretch))
412421
})
413422
.HorizontalAlignment(HorizontalAlignment.Stretch),
414-
new TextBlock().Text(() => $"Status: {statusState.Value} | Slider: {sliderState.Value:0.00}"))
423+
new TextBlock().Text(() => $"Status: {statusState.Value} | Slider: {sliderState.Value:0.00} | Port: {portState.Value}"))
415424
.Spacing(1)
416425
.HorizontalAlignment(HorizontalAlignment.Stretch)
417426
.VerticalAlignment(VerticalAlignment.Stretch);

src/XenoAtom.Terminal.UI.SourceGen/TerminalUiGenerator.cs

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ private sealed record BindablePropertyInfo(
8787
string ContainingTypeDisplayName,
8888
string PropertyName,
8989
string PropertyTypeFullyQualified,
90+
bool IsDelegateProperty,
9091
bool IsParentVisual,
9192
bool IsVisualChildProperty,
9293
bool GenerateImplementation,
@@ -163,6 +164,7 @@ public static BindablePropertyResult TryCreate(GeneratorAttributeSyntaxContext c
163164
SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
164165

165166
var propertyTypeFullyQualified = propertySymbol.Type.ToDisplayString(typeFormat);
167+
var isDelegateProperty = propertySymbol.Type.TypeKind == TypeKind.Delegate;
166168
var isVisualChildProperty = ComputeIsVisualChildProperty(context.SemanticModel.Compilation, containingType, propertySymbol.Type);
167169
var isVisual = InheritsFromVisual(context.SemanticModel.Compilation, containingType);
168170

@@ -182,6 +184,7 @@ public static BindablePropertyResult TryCreate(GeneratorAttributeSyntaxContext c
182184
ContainingTypeDisplayName: containingTypeDisplayName,
183185
PropertyName: propertyName,
184186
PropertyTypeFullyQualified: propertyTypeFullyQualified,
187+
IsDelegateProperty: isDelegateProperty,
185188
IsParentVisual: isVisual,
186189
IsVisualChildProperty: isVisualChildProperty,
187190
GenerateImplementation: generateImplementation,
@@ -518,43 +521,49 @@ private static string GenerateFluentExtensionsSource(INamedTypeSymbol containing
518521

519522
if (p.IsParentVisual)
520523
{
521-
sb.Append(methodIndent).AppendLine("/// <summary>");
522-
sb.Append(methodIndent).Append("/// Registers a dynamic update that assigns <see cref=\"").Append(receiverTypeXml).Append('.').Append(EscapeIdentifier(propName)).AppendLine("\"/> from a computed value and returns the same instance.");
523-
sb.Append(methodIndent).AppendLine("/// </summary>");
524-
sb.Append(methodIndent).AppendLine("/// <remarks>");
525-
sb.Append(methodIndent).AppendLine("/// The delegate is evaluated during the dynamic update pass; accessed bindings are tracked so only affected visuals are refreshed.");
526-
sb.Append(methodIndent).AppendLine("/// </remarks>");
527-
sb.Append(methodIndent).AppendLine("/// <param name=\"obj\">The instance to configure.</param>");
528-
sb.Append(methodIndent).Append("/// <param name=\"").Append(EscapeIdentifier(argName)).AppendLine("\">A delegate that computes the current value.</param>");
529-
sb.Append(methodIndent).AppendLine("/// <returns>The same instance for chaining.</returns>");
530-
sb.Append(methodIndent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"XenoAtom.Terminal.UI.SourceGen\", \"0.1.0\")]");
531-
if (canUseGeneric)
532-
{
533-
sb.Append(methodIndent).Append("public static T ").Append(EscapeIdentifier(propName)).Append("<T>(this T obj, global::System.Func<").Append(argType).Append("> ").Append(EscapeIdentifier(argName))
534-
.Append(") where T : ").Append(receiverType).AppendLine();
535-
sb.Append(methodIndent).Append(" => global::XenoAtom.Terminal.UI.VisualExtensions.Update(obj, x => x.").Append(EscapeIdentifier(propName)).Append(" = ").Append(EscapeIdentifier(argName)).AppendLine("());");
536-
}
537-
else
524+
// If the property itself is a delegate (e.g. Func<...>), avoid generating an overload taking
525+
// Func<PropertyType>. It would compete with the natural "set the delegate" overload and complicate
526+
// method resolution for lambdas.
527+
if (!p.IsDelegateProperty)
538528
{
539-
sb.Append(methodIndent).Append("public static ").Append(receiverType).Append(' ').Append(EscapeIdentifier(propName));
540-
if (needsTypeParameters)
541-
{
542-
AppendTypeParameters(sb, containingType);
543-
}
544-
sb.Append("(this ").Append(receiverType).Append(" obj, global::System.Func<").Append(argType)
545-
.Append("> ").Append(EscapeIdentifier(argName)).Append(')');
546-
if (needsTypeParameters)
529+
sb.Append(methodIndent).AppendLine("/// <summary>");
530+
sb.Append(methodIndent).Append("/// Registers a dynamic update that assigns <see cref=\"").Append(receiverTypeXml).Append('.').Append(EscapeIdentifier(propName)).AppendLine("\"/> from a computed value and returns the same instance.");
531+
sb.Append(methodIndent).AppendLine("/// </summary>");
532+
sb.Append(methodIndent).AppendLine("/// <remarks>");
533+
sb.Append(methodIndent).AppendLine("/// The delegate is evaluated during the dynamic update pass; accessed bindings are tracked so only affected visuals are refreshed.");
534+
sb.Append(methodIndent).AppendLine("/// </remarks>");
535+
sb.Append(methodIndent).AppendLine("/// <param name=\"obj\">The instance to configure.</param>");
536+
sb.Append(methodIndent).Append("/// <param name=\"").Append(EscapeIdentifier(argName)).AppendLine("\">A delegate that computes the current value.</param>");
537+
sb.Append(methodIndent).AppendLine("/// <returns>The same instance for chaining.</returns>");
538+
sb.Append(methodIndent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"XenoAtom.Terminal.UI.SourceGen\", \"0.1.0\")]");
539+
if (canUseGeneric)
547540
{
548-
AppendTypeParameterConstraints(sb, containingType, methodIndent);
541+
sb.Append(methodIndent).Append("public static T ").Append(EscapeIdentifier(propName)).Append("<T>(this T obj, global::System.Func<").Append(argType).Append("> ").Append(EscapeIdentifier(argName))
542+
.Append(") where T : ").Append(receiverType).AppendLine();
543+
sb.Append(methodIndent).Append(" => global::XenoAtom.Terminal.UI.VisualExtensions.Update(obj, x => x.").Append(EscapeIdentifier(propName)).Append(" = ").Append(EscapeIdentifier(argName)).AppendLine("());");
549544
}
550545
else
551546
{
552-
sb.AppendLine();
547+
sb.Append(methodIndent).Append("public static ").Append(receiverType).Append(' ').Append(EscapeIdentifier(propName));
548+
if (needsTypeParameters)
549+
{
550+
AppendTypeParameters(sb, containingType);
551+
}
552+
sb.Append("(this ").Append(receiverType).Append(" obj, global::System.Func<").Append(argType)
553+
.Append("> ").Append(EscapeIdentifier(argName)).Append(')');
554+
if (needsTypeParameters)
555+
{
556+
AppendTypeParameterConstraints(sb, containingType, methodIndent);
557+
}
558+
else
559+
{
560+
sb.AppendLine();
561+
}
562+
sb.Append(methodIndent).Append(" => global::XenoAtom.Terminal.UI.VisualExtensions.Update(obj, x => x.").Append(EscapeIdentifier(propName)).Append(" = ").Append(EscapeIdentifier(argName)).AppendLine("());");
553563
}
554-
sb.Append(methodIndent).Append(" => global::XenoAtom.Terminal.UI.VisualExtensions.Update(obj, x => x.").Append(EscapeIdentifier(propName)).Append(" = ").Append(EscapeIdentifier(argName)).AppendLine("());");
555-
}
556564

557-
sb.AppendLine();
565+
sb.AppendLine();
566+
}
558567

559568
sb.Append(methodIndent).AppendLine("/// <summary>");
560569
sb.Append(methodIndent).Append("/// Configures <see cref=\"").Append(receiverTypeXml).Append('.').Append(EscapeIdentifier(propName)).AppendLine("\"/> from a <see cref=\"global::XenoAtom.Terminal.UI.State{T}\"/> and returns the same instance.");
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// Licensed under the BSD-Clause 2 license.
3+
// See license.txt file in the project root for full license information.
4+
5+
using XenoAtom.Terminal;
6+
using XenoAtom.Terminal.UI.Controls;
7+
using XenoAtom.Terminal.UI.Hosting;
8+
9+
namespace XenoAtom.Terminal.UI.Tests;
10+
11+
[TestClass]
12+
public sealed class NumberBoxInputTests
13+
{
14+
[TestMethod]
15+
public void NumberBox_Updates_Bound_State_When_Typing_Valid_Number()
16+
{
17+
var state = new State<int>(8080);
18+
var numberBox = new NumberBox<int>().Value(state);
19+
var root = new VStack { numberBox };
20+
21+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 6));
22+
driver.Tick();
23+
24+
driver.App.Focus(numberBox);
25+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlA, Modifiers = TerminalModifiers.Ctrl });
26+
driver.Backend.PushEvent(new TerminalTextEvent { Text = "42" });
27+
28+
driver.TickUntil(() => state.Value == 42);
29+
Assert.AreEqual(42, state.Value);
30+
}
31+
32+
[TestMethod]
33+
public void NumberBox_DoesNot_Update_Value_When_Text_Is_Not_A_Number()
34+
{
35+
var state = new State<int>(10);
36+
var numberBox = new NumberBox<int>()
37+
.Value(state)
38+
.InvalidNumberMessage("Not a number");
39+
var root = new VStack { numberBox };
40+
41+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 6));
42+
driver.Tick();
43+
44+
driver.App.Focus(numberBox);
45+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlA, Modifiers = TerminalModifiers.Ctrl });
46+
driver.Backend.PushEvent(new TerminalTextEvent { Text = "abc" });
47+
48+
driver.TickUntil(() => numberBox.Text == "abc");
49+
Assert.AreEqual(10, state.Value);
50+
51+
var screen = new AnsiTestScreen(40, 6);
52+
screen.Apply(driver.Backend.GetOutText());
53+
var rendered = screen.GetText();
54+
StringAssert.Contains(rendered, "Not a number");
55+
}
56+
57+
[TestMethod]
58+
public void NumberBox_Uses_Custom_Value_Validator_Message()
59+
{
60+
var state = new State<int>(5);
61+
var numberBox = new NumberBox<int>
62+
{
63+
ValueValidator = v => v is >= 0 and <= 9 ? null : "Must be a single digit",
64+
}.Value(state);
65+
var root = new VStack { numberBox };
66+
67+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 6));
68+
driver.Tick();
69+
70+
driver.App.Focus(numberBox);
71+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlA, Modifiers = TerminalModifiers.Ctrl });
72+
driver.Backend.PushEvent(new TerminalTextEvent { Text = "12" });
73+
74+
driver.TickUntil(() => numberBox.Text == "12");
75+
Assert.AreEqual(5, state.Value, "Invalid input should not update the bound state.");
76+
77+
var screen = new AnsiTestScreen(40, 6);
78+
screen.Apply(driver.Backend.GetOutText());
79+
var rendered = screen.GetText();
80+
StringAssert.Contains(rendered, "Must be a single digit");
81+
}
82+
}

0 commit comments

Comments
 (0)