Skip to content

kzxl/WinFlow

Repository files navigation

WinFlow

Async and UI-thread toolkit for Windows Forms. WinFlow keeps the UI thread responsive by giving you the right primitives for the job: safe marshalling, re-entrancy-safe async commands, throttled progress, debounce/throttle, and batched control updates.

Targets .NET Framework 4.6.2 and .NET 8 (Windows) from a single package.

Why

Most "WinForms is slow" problems are not the framework, they are:

  1. Blocking the single UI thread with long work (the UI freezes).
  2. Updating controls one item at a time (thousands of repaints).
  3. Spamming Control.Invoke from worker threads (the message pump floods).

WinFlow gives you small, focused tools for each of these so you stop re-implementing them in every project.

Install

dotnet add package WinFlow

Quick start

Capture a dispatcher once on the UI thread (e.g. in your main form constructor):

public partial class MainForm : Form
{
    private readonly UiDispatcher _ui;

    public MainForm()
    {
        InitializeComponent();
        _ui = UiDispatcher.Capture(); // call on the UI thread
    }
}

1. Run work without freezing the UI

Pick the pattern by workload:

// I/O-bound: just await the native async API. Do NOT wrap in Task.Run.
var data = await httpClient.GetStringAsync(url);

// CPU-bound: push it to the thread pool.
var result = await BackgroundWork.RunCpuBound(ct => HeavyCompute(input, ct));

gridView.DataSource = result; // back on the UI thread automatically

2. Re-entrancy-safe buttons (no more double-clicks)

AsyncCommand ignores repeat triggers while running, supports cancellation, and raises CanExecuteChanged so the UI can react. ButtonBinder wires it all to a button, including optional busy text and a cancel button.

var command = new AsyncCommand(async token =>
{
    var rows = await LoadFromDbAsync(token);
    _ui.Post(() => grid.DataSource = rows);
});

// Auto-disables btnLoad while running, shows "Loading...", enables btnCancel.
ButtonBinder.Bind(btnLoad, command, busyText: "Loading...", cancelButton: btnCancel);

3. Throttled progress (smooth bar, no flooded pump)

ThrottledProgress<T> coalesces high-frequency reports and delivers at most once per interval on the UI thread, always flushing the final value.

using var progress = new ThrottledProgress<int>(
    _ui,
    percent => progressBar.Value = percent,
    minInterval: TimeSpan.FromMilliseconds(50));

await BackgroundWork.RunCpuBound(ct =>
{
    for (int i = 0; i <= total; i++)
    {
        DoWork(i);
        ((IProgress<int>)progress).Report(i * 100 / total); // safe to call every iteration
    }
});

progress.Flush(); // guarantee the bar reaches 100%

4. Debounce search-as-you-type

private readonly Debouncer _searchDebounce;

// in constructor:
_searchDebounce = new Debouncer(_ui, TimeSpan.FromMilliseconds(300));

private void txtSearch_TextChanged(object sender, EventArgs e)
{
    string query = txtSearch.Text;
    _searchDebounce.Invoke(() => RunSearch(query)); // fires once user pauses
}

Throttler is the leading-edge counterpart for resize/scroll handlers.

5. Bulk control updates in one repaint

// BeginUpdate/EndUpdate + SuspendLayout/ResumeLayout in one call:
listView.BatchUpdate(lv =>
{
    foreach (var item in items)
        lv.Items.Add(new ListViewItem(item.Name));
});

// Or add a range in a single pass:
listBox.AddRangeBatched(names);

// Run async work with a control disabled (released even on error):
await btnSave.RunDisabledAsync(() => SaveAsync());

6. Async search done right

AsyncDebouncer<TInput, TResult> is the full solution for async search-as-you-type. Beyond debouncing, it cancels the previous in-flight request when a new one starts and guarantees a slow earlier request can never overwrite a newer result:

private readonly AsyncDebouncer<string, IReadOnlyList<Product>> _search;

// in constructor:
_search = new AsyncDebouncer<string, IReadOnlyList<Product>>(
    _ui,
    TimeSpan.FromMilliseconds(300),
    operation: (query, token) => _api.SearchAsync(query, token), // gets cancelled if superseded
    onResult:  results => grid.DataSource = results,             // only the latest result lands
    onError:   ex => statusLabel.Text = ex.Message);

private void txtSearch_TextChanged(object sender, EventArgs e)
    => _search.Invoke(txtSearch.Text);

7. Huge grids without freezing

VirtualGridBinding<T> drives a DataGridView in virtual mode. Binding 200,000+ rows is instant because no per-row objects are created; only visible cells are requested:

var binding = VirtualGridBinding<Product>.Attach(
    grid,
    new VirtualColumn<Product>("Id",    p => p.Id),
    new VirtualColumn<Product>("Name",  p => p.Name),
    new VirtualColumn<Product>("Price", p => p.Price.ToString("C")));

binding.SetData(products); // 200k rows, O(1) — grid only re-queries visible cells

8. Fire-and-forget and timeouts safely

// Don't lose exceptions from un-awaited tasks:
RefreshCacheAsync().SafeFireAndForget(ex => logger.Error(ex));

// Bound an operation and cancel it on timeout:
using var cts = new CancellationTokenSource();
var data = await _api.GetAsync(cts.Token).WithTimeout(TimeSpan.FromSeconds(5), cts);

Try the demo

dotnet run --project samples/WinFlow.Demo

A four-tab WinForms app demonstrating every feature. The title bar shows a live clock that keeps ticking during all operations, proving the UI thread is never blocked.

API overview

Type Purpose
UiDispatcher Marshal work to the UI thread (Post, Send, InvokeAsync). Context-based, survives handle recreation.
AsyncCommand Re-entrancy-safe async operation with cancellation and error events.
ButtonBinder Binds an AsyncCommand to a button: auto-disable, busy text, cancel button.
ThrottledProgress<T> Coalesced, rate-limited IProgress<T> delivered on the UI thread.
Debouncer Fire once after a quiet period (search-as-you-type).
Throttler Fire at most once per interval (resize/scroll).
AsyncDebouncer<TIn,TOut> Debounced async search with auto-cancel of stale requests and out-of-order result protection.
VirtualGridBinding<T> Drives DataGridView in virtual mode for hundreds of thousands of rows without per-row objects.
ControlExtensions BatchUpdate, AddRangeBatched, RunDisabledAsync.
BackgroundWork RunCpuBound for thread-pool work (with cancellation).
TaskExtensions SafeFireAndForget, WithTimeout.

Design notes

  • Context-based marshalling. UiDispatcher routes through the captured SynchronizationContext, not a specific control handle, so it keeps working across handle recreation and does not need a live control reference.
  • Inline fast-path. When already on the UI thread, Post/Send/InvokeAsync run inline to avoid a needless message round-trip.
  • No silent swallowing. AsyncCommand rethrows on the calling context if you don't subscribe to Error; cancellation is treated as a normal outcome.
  • Stale-result protection. AsyncDebouncer stamps each request with a generation number and re-checks it both before and after marshalling to the UI thread, so an older slow request can never overwrite a newer one.

Build & test

dotnet build
dotnet test

The library multi-targets net462 and net8.0-windows. Tests run on net8.0-windows using a simulated single-threaded UI synchronization context.

License

MIT. See LICENSE.

About

Async and UI-thread toolkit for Windows Forms featuring safe marshalling, re-entrancy-safe commands, debouncing, and virtual grid binding.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages