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
49 changes: 49 additions & 0 deletions .github/workflows/test-winforms.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: test-winforms

on:
push:
branches:
- main
pull_request:
branches:
- main

permissions:
contents: read

jobs:
build:
runs-on: windows-latest

env:
config: 'Debug'
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

- name: Restore
run: dotnet restore src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj

- name: Build
run: dotnet build src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj --configuration ${{ env.config }} --no-restore

- name: Test
run: dotnet test src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj --no-restore --no-build --configuration ${{ env.config }}

- name: Upload test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: winforms-test-logs
path: |
src/StrongTypes.WinForms.Tests/**/TestResults/**/*.log
src/StrongTypes.WinForms.Tests/**/TestResults/**/*.trx
retention-days: 5
4 changes: 2 additions & 2 deletions Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Add packages only when the host project actually hits that stack:
- **EfCore** — only if EF Core is in use.
- **FsCheck** — only for property-based test projects.
- **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines.
- **WPF / WinForms** — no package. Two-way binding works off the core package's `[TypeConverter]`s; there is nothing to install or call. A `Kalicz.StrongTypes.Wpf` package existed before v2 — if you find it referenced, remove it. See `references/wpf.md`.
- **WPF / WinForms** — no package. Two-way binding works off the core package's `[TypeConverter]`s; there is nothing to install or call. A `Kalicz.StrongTypes.Wpf` package existed before v2 — if you find it referenced, remove it. See `references/desktop.md`.
- **AspNetCore** — add it when a controller takes `NonEmptyEnumerable<T>` from a non-body source (forms, repeated query params, header lists), **or** when you want JSON request-body validation errors keyed by the property name (`Value`) instead of the System.Text.Json path (`$.value`). The error-key normalization is on by default once `AddStrongTypes()` is called — opt out with `AddStrongTypes(o => o.NormalizeJsonErrorKeys = false)`, or set `o.JsonErrorKeyCasing`. The binder alone is niche — `[FromBody]` already round-trips `NonEmptyEnumerable<T>` via the core JSON converters — but the error-key normalization applies to any JSON API. See `references/aspnetcore.md`.

## Type catalog — what's in the box
Expand Down Expand Up @@ -73,7 +73,7 @@ demand when about to write code against that surface.
| EF Core: `UseStrongTypes` value converters, interval column mapping, `.Unwrap()` LINQ marker | `references/efcore.md` |
| FsCheck: shared `Generators` class, shipped arbitraries | `references/fscheck.md` |
| OpenAPI: `AddStrongTypes()` for either `AddOpenApi()` (`Kalicz.StrongTypes.OpenApi.Microsoft`) or `AddSwaggerGen()` (`Kalicz.StrongTypes.OpenApi.Swashbuckle`) | `references/openapi.md` |
| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/wpf.md` |
| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/desktop.md` |
| ASP.NET Core MVC: `services.AddStrongTypes()` for `NonEmptyEnumerable<T>` from `[FromForm]` & friends, plus JSON request-body validation error-key normalization | `references/aspnetcore.md` |

## Design philosophy — picking the right wrapper
Expand Down
2 changes: 1 addition & 1 deletion Skill/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,6 @@ Three things in there surprise people:
- **Nullable wrappers still enforce the invariant** when a value is present:
`OptionalLimit` may be absent, but `-1` is rejected.
- The same `TypeConverter` powers anything that goes through `TypeDescriptor` —
WPF/WinForms two-way binding (`references/wpf.md`), designers,
WPF/WinForms two-way binding (`references/desktop.md`), designers,
`PropertyGrid`, and libraries doing generic string↔object conversion. It is
one mechanism on the type, so none of them need a registration call.
105 changes: 105 additions & 0 deletions Skill/references/desktop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Desktop MVVM binding (WPF, WinForms)

**No package, no setup.** Two-way binding a text control to a strong-typed
view-model property works off the core `Kalicz.StrongTypes` package alone,
in both WPF and WinForms.

> There was a `Kalicz.StrongTypes.Wpf` package requiring a
> `this.UseStrongTypes()` call in `App.OnStartup`. It is **gone as of v2** —
> the core types now carry `[TypeConverter]` themselves. Delete the package
> reference and the call; nothing replaces them. If you see
> `UseStrongTypes()` on an `Application` in old code or a blog post, that is
> the removed API. (The identically-named EF Core and OpenAPI
> `UseStrongTypes()` calls are unrelated and still current.)

## Why it works

Both frameworks route `string → T` through `TypeDescriptor.GetConverter(T)`
and never consult `IParsable<T>` directly. Every strong type with a string
round-trip carries a `[TypeConverter]` that bridges the two —
`NonEmptyString`, `Email`, `Digit`, and every closed instantiation of the
generic numeric wrappers (`Positive<int>`, `Negative<decimal>`, …). Nothing
to register: the attribute travels with the type.

Composite types have no single-textbox string form and so get no converter:
bind their parts instead. An interval (`FiniteInterval<T>`, `Interval<T>`,
`IntervalFrom<T>`, `IntervalUntil<T>`) binds field-by-field via `.Start` /
`.End`; `Maybe<T>` and `NonEmptyEnumerable<T>` likewise bind through their
members, not as a whole.

## WPF

```xml
<TextBox Text="{Binding Name,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnExceptions=True}" />
```

…where `Name` is a view-model property of type `NonEmptyString`.

`ValidatesOnExceptions=True` is the load-bearing piece — strong types throw
`ArgumentException` from `Create` / `Parse` when the input violates the
invariant; `ValidatesOnExceptions=True` turns that into a `ValidationError`
on the binding, which drives WPF's standard "invalid input" red-border
template. Without it, the binding silently swallows the failure and the
view-model is left holding the previous valid value.

### Culture

The converter parses **and** formats in the binding's culture (`ConverterCulture`,
or the element's `Language`) — WPF never consults the host thread's culture. A
`Positive<decimal>` displays as `1234,5` on a de-DE binding and reads back
unchanged. Don't add an `IValueConverter` to compensate — a pre-v2
`Kalicz.StrongTypes.Wpf` formatted with the ambient culture while parsing in the
binding's, which turned `1234.5` into `12345` on round-trip; if a workaround
exists in your code for that, delete it.

## WinForms

```csharp
textBox.DataBindings.Add(new Binding(
nameof(TextBox.Text), viewModel, nameof(PersonViewModel.Name),
formattingEnabled: true, DataSourceUpdateMode.OnPropertyChanged));
```

`formattingEnabled: true` is the load-bearing piece here — it routes the
binding through the `TypeConverter` pipeline. Differences from WPF:

- The binding culture is `FormatInfo ?? CultureInfo.CurrentCulture` — the
host culture governs by default, the opposite of WPF. Set
`Binding.FormatInfo` explicitly when the input culture must not follow
the machine.
- Invalid input surfaces through `Binding.BindingComplete` (state
`Exception`), not a validation-error template — the source keeps its
last valid value either way.
- A binding only activates once its control lives on a **shown** form —
always true in a running app, but a control bound before its form is
shown (or a unit test without one) sees a silently dormant binding.

## Nullable properties

In both frameworks a nullable wrapper (`Positive<int>?`, `NonEmptyString?`)
binds and validates normally, but **clearing the box does not set the
property to null** — the empty string reaches the converter and fails the
invariant. The BCL's `NullableConverter` (which maps empty to null, and does
exactly that for configuration binding) is never consulted. This is
long-standing behaviour, not a v2 change. If "cleared means null" matters,
bind through a `string?` view-model property and convert in the view-model.

## MAUI and Avalonia

Not covered yet. MAUI's binding engine never consults `[TypeConverter]`s
when writing target → source: display bindings work for every strong type,
but typed input silently never reaches a strong-typed view-model property.
Until dedicated support exists, a MAUI user must put an explicit
`IValueConverter` (string ↔ strong type via `Parse` / `ToString`) on each
two-way binding. For status on both frameworks, point them at issue
[#94](https://github.com/KaliCZ/StrongTypes/issues/94).

## Decision rule

> **Nothing to add, nothing to call.** Reference `Kalicz.StrongTypes` and bind.
> The only thing you must remember is `ValidatesOnExceptions=True` on inbound
> WPF bindings and `formattingEnabled: true` on WinForms ones, or invalid
> input fails silently.
81 changes: 0 additions & 81 deletions Skill/references/wpf.md

This file was deleted.

3 changes: 3 additions & 0 deletions StrongTypes.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
<Project Path="src/StrongTypes.Tests/StrongTypes.Tests.csproj" />
<Project Path="src/StrongTypes/StrongTypes.csproj" />
</Folder>
<Folder Name="/WinForms/">
<Project Path="src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj" />
</Folder>
<Folder Name="/WPF/">
<Project Path="src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj" />
<Project Path="src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDe

…where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` is the load-bearing piece: it turns the `ArgumentException` a strong type throws on invalid input into a `ValidationError`, driving WPF's standard red-border template. Without it the binding swallows the failure silently.

The same `TypeDescriptor` mechanism backs WinForms and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94).
Supported the same way, with nothing to install or call: WPF, WinForms, and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94).

[↑ Back to contents](#contents)

Expand Down
20 changes: 20 additions & 0 deletions src/StrongTypes.WinForms.Tests/BindingFailureRecorder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#nullable enable

using System.Windows.Forms;

namespace StrongTypes.WinForms.Tests;

/// <summary>Records the first failed <see cref="Binding.BindingComplete"/> state — WinForms' error surface, where WPF raises a validation error.</summary>
internal sealed class BindingFailureRecorder
{
public BindingCompleteState? State { get; private set; }

public BindingFailureRecorder(Binding binding) =>
binding.BindingComplete += (_, e) =>
{
if (e.BindingCompleteState != BindingCompleteState.Success)
{
State ??= e.BindingCompleteState;
}
};
}
Loading