CdCSharp.Pangea.Localization 1.0.53

dotnet add package CdCSharp.Pangea.Localization --version 1.0.53
                    
NuGet\Install-Package CdCSharp.Pangea.Localization -Version 1.0.53
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CdCSharp.Pangea.Localization" Version="1.0.53" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CdCSharp.Pangea.Localization" Version="1.0.53" />
                    
Directory.Packages.props
<PackageReference Include="CdCSharp.Pangea.Localization" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add CdCSharp.Pangea.Localization --version 1.0.53
                    
#r "nuget: CdCSharp.Pangea.Localization, 1.0.53"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package CdCSharp.Pangea.Localization@1.0.53
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=CdCSharp.Pangea.Localization&version=1.0.53
                    
Install as a Cake Addin
#tool nuget:?package=CdCSharp.Pangea.Localization&version=1.0.53
                    
Install as a Cake Tool

🌍 CdCSharp.Pangea

<div align="center">

NuGet Version NuGet Downloads Build Status License .NET Version Avalonia

An Avalonia toolkit: MVVM with generated bindings, themes as C# classes, storage and localization

πŸ“¦ Installation β€’ πŸš€ Quick start β€’ 🧠 Binding β€’ 🎨 Theming β€’ πŸ’Ύ Storage β€’ πŸ” Secrets β€’ πŸ—„οΈ Database β€’ 🌐 Localization β€’ 🧭 Navigation β€’ πŸ’¬ Dialogs β€’ πŸ§ͺ Testing β€’ πŸ€– AI agents

</div>

Status: pre-1.0. The API is still moving and breaking changes land without a deprecation cycle. Pin a version if you depend on it.


🎯 What is Pangea?

Pangea wires up the parts an Avalonia application usually assembles by hand: a DI container, a source generator that turns fields into observable properties, a theme you can restyle from C#, per-platform storage paths, and localization.

Each capability is a feature β€” a class implementing IPangeaFeature, discovered at startup, registering its own services.


πŸ“¦ Installation

Start from a template

dotnet new install CdCSharp.Pangea.Templates

Five starting points - three for a desktop application, two for a phone. pangea-app is the smallest one: the startup wiring, a window, and a sample view model and palette showing the toolkit's conventions.

dotnet new pangea-app -n MyApp
cd MyApp && dotnet run
Option Default
--IncludeSkill true Copy the agent skill into the project
--Sample true Include the sample view model and palette
--PangeaVersion matches the template Version of the Pangea packages to reference

pangea-shell is the worked example. It is an application with a menu, three screens and every feature wired: navigation with a typed request, strings localized into two cultures, settings saved to the per-platform data directory and restored on the next run by an application's own feature, validation rules on a form, a confirmation dialog refusing to leave a screen with unsaved changes, and a custom theme.

dotnet new pangea-shell -n MyApp
cd MyApp && dotnet run
Option Default
--IncludeSkill true Copy the agent skill into the project
--PangeaVersion matches the template Version of the Pangea packages to reference

pangea-data is the same idea for the database feature, which the shell template deliberately leaves out β€” it keeps its settings in a JSON file, and one application showing two ways to store things teaches neither. This one is a list backed by SQLite: a context and a migration, the migration applied at startup behind a splash window with a backup taken first, a seeder, and backup and compact offered to the user.

dotnet new pangea-data -n MyApp
cd MyApp && dotnet run
Option Default
--IncludeSkill true Copy the agent skill into the project
--PangeaVersion matches the template Version of the Pangea packages to reference

pangea-mobile is the phone application: one shared library and a head per platform. The desktop head is always generated - it is how a change is seen without waiting for an emulator - and Android and iOS are chosen. It arrives with the parts of a phone application that are easy to get wrong already right: a single-view shell (a Window cannot be constructed on a phone at all), the platform safe area so the top bar is not under the clock, an AppCompat theme because Avalonia's activity refuses to start under anything else, and navigation that does not summon the system keyboard on arrival.

dotnet new pangea-mobile -n MyApp
cd MyApp && dotnet run --project MyApp.Desktop
Option Default
--Android true Generate the Android head
--iOS true Generate the iOS head
--ApplicationId com.example.app Bundle identifier for the mobile heads
--IncludeSkill true Copy the agent skill into the project
--PangeaVersion matches the template Version of the Pangea packages to reference
--AvaloniaVersion matches the template Version of the Avalonia packages to reference

pangea-mobile-supabase is the same application with a shared backend behind it: anonymous sign-in, row level security, a local cache the screens draw from, and an outbox holding the writes made offline until they can go. The SQL to paste into a new project comes with it, including the two things about Postgres that will otherwise cost an afternoon - that row level security is not a grant, and that a policy reading another table is subject to that table's policies.

dotnet new pangea-mobile-supabase -n MyApp \
    --SupabaseUrl https://xxx.supabase.co --SupabaseKey sb_publishable_...
Option Default
--SupabaseUrl placeholder The project URL. The base URL, not the REST endpoint
--SupabaseKey placeholder The anon or publishable key. Never the service_role key
--Android, --iOS, --ApplicationId as above
--IncludeSkill true Copy the agent skill into the project
--PangeaVersion matches the template Version of the Pangea packages to reference

It runs before any of that is filled in: the screens draw from the cache and the writes queue, which is the same thing that happens on a train.

Start from pangea-shell when you want to see how a feature is meant to be used, pangea-data when the application has a database in it, pangea-mobile when it is going on a phone, pangea-mobile-supabase when more than one person has to see the same data, and pangea-app when you want an empty room.

Add to an existing project

dotnet add package CdCSharp.Pangea

That is the package to install: besides pulling in every feature, it is where the application model lives β€” PangeaApplication, UsePangea() and the window manager.

Installing it also puts the whole build-time toolchain into the project, with nothing to configure:

[Binding] source generator Fields become observable properties, with PGB001–PGB006 when they cannot
Startup catalog generator Replaces the assembly scan at startup β€” see What startup does instead of scanning
Localization analyzer PGL001/PGL002 on resource keys β€” see Keys checked at compile time

The database feature brings one more, PGD001–PGD003, but only to a project that installs it β€” see Checked at compile time.

Each one also travels with the feature package it belongs to, so a project that installs only CdCSharp.Pangea.Binding still gets the generator that makes [Binding] mean anything.

The features are also published on their own, for using a piece of the toolkit as a plain library without the Pangea application model. Each depends only on CdCSharp.Pangea.Core:

dotnet add package CdCSharp.Pangea.Binding       # [Binding] attribute + source generator
dotnet add package CdCSharp.Pangea.Theming       # palettes, themes, theme service
dotnet add package CdCSharp.Pangea.Storage       # per-platform paths and file access
dotnet add package CdCSharp.Pangea.Localization  # cultures and resource strings
dotnet add package CdCSharp.Pangea.Navigation    # typed navigation requests and a host

The database feature is the one thing the meta-package does not pull in, because EF Core and a native SQLite driver are megabytes an application that never queries anything should not carry:

dotnet add package CdCSharp.Pangea.Data.Sqlite   # EF Core on SQLite, and CdCSharp.Pangea.Data with it

And, for the test project rather than the application:

dotnet add package CdCSharp.Pangea.Testing       # test doubles: see Testing your own application
dotnet add package CdCSharp.Pangea.Data.Testing  # a real SQLite database, deleted with the test

Targets .NET 10 and Avalonia 12.1.


πŸš€ Quick start

// App.axaml.cs
public partial class App : PangeaApplication
{
    public override void Configure(IServiceCollection services)
    {
        services.AddSingleton<IDataService, DataService>();
        // View models deriving from ViewModelBase are registered automatically
    }
}

// Program.cs
public static AppBuilder BuildAvaloniaApp()
    => AppBuilder.Configure<App>()
        .UsePlatformDetect()
        .WithInterFont()
        .LogToTrace()
        .UsePangea();

UsePangea() scans the application's assemblies once, registers every feature it finds, registers your view models, and builds the container. PangeaApplication then lets each feature configure the running application and shows the main window.


🧠 Binding

Fields marked [Binding] become properties that raise change notifications β€” including for the computed properties and commands that depend on them.

public partial class ProductViewModel : ViewModelBase
{
    public ProductViewModel(IServiceProvider services) : base(services) { }

    [Binding] private string _name = "";
    [Binding] private decimal _price;
    [Binding] private bool _isAvailable = true;
    [Binding(ReadOnly = true)] private string _sku = "";
    [Binding(PropertyName = "Tags")] private ObservableCollection<string> _categories = [];

    public string DisplayName => $"{Name} ({Price:C})";
    public bool CanOrder => IsAvailable && Price > 0;

    public RelayCommand OrderCommand => CreateCommand(Order, () => CanOrder);
    public RelayCommand<string> AddTagCommand => CreateCommand<string>(AddTag);

    private void Order() => IsAvailable = false;

    private void AddTag(string? tag)
    {
        if (!string.IsNullOrEmpty(tag)) Tags.Add(tag);
    }

    // Optional hook, called by the generated setter
    partial void OnNameChanged() { }
}

The generator analyses the class and emits only the notifications that are actually needed:

partial class ProductViewModel
{
    public string Name
    {
        get => _name;
        set
        {
            if (SetProperty(ref _name, value))
            {
                OnNameChanged();

                // Computed property notifications
                OnPropertyChanged(nameof(DisplayName));
            }
        }
    }

    public decimal Price
    {
        get => _price;
        set
        {
            if (SetProperty(ref _price, value))
            {
                OnPriceChanged();

                // Computed property notifications
                OnPropertyChanged(nameof(CanOrder));
                OnPropertyChanged(nameof(DisplayName));

                // Command CanExecute notifications
                OrderCommand.RaiseCanExecuteChanged();
            }
        }
    }

    // ReadOnly: getter only, no change hook, no notifications
    public string Sku
    {
        get => _sku;
    }

    partial void OnNameChanged();
    partial void OnPriceChanged();
}

What it works out for you

Computed properties An expression- or getter-bodied property that reads a binding property is notified when it changes
Transitive chains Price β†’ DisplayName β†’ Summary propagates without you listing it
Commands A command whose CanExecute reads a property β€” directly, through a CanX method, or through a computed property β€” gets RaiseCanExecuteChanged()
Collections If a change hook fills a collection - in its own body or through anything it calls - whatever reads that collection is notified, and only that collection

When it cannot generate, it says so instead of letting the compiler complain about a file you did not write: a class that is not partial (PGB001), one whose base supplies no change notification (PGB002), two fields that would produce the same property (PGB003), a name the class already declares (PGB004), [Binding] on a static field (PGB005), or a generated property that hides a base member (PGB006). The last two are warnings. A class with an error generates nothing, so the symptom is a property that is not there.

Validation

Rules go on the field. The generator copies them onto the generated property and validates on every set, through INotifyDataErrorInfo β€” which Avalonia already listens to.

public partial class SignUpViewModel : ViewModelBase
{
    [Binding]
    [Required(ErrorMessage = "An email is required.")]
    [EmailAddress] private string _email = "";

    [Binding, Range(18, 120)] private int _age;

    // HasErrors comes from ViewModelBase and moves as the user types
    public RelayCommand SignUpCommand => CreateCommand(SignUp, () => !HasErrors);
}

<TextBox Text="{Binding Email}" />

Any ValidationAttribute works, including your own β€” the rules are evaluated by System.ComponentModel.DataAnnotations, not reimplemented in generated code. A property is validated when it is set, so an untouched form shows nothing; ValidateAll() checks everything and tells you whether the view model is valid, which is what a Save button asks first.

Commands

CreateCommand builds a RelayCommand bound to the UI dispatcher.

public RelayCommand SaveCommand => CreateCommand(Save, () => CanSave);          // sync
public RelayCommand LoadCommand => CreateCommand(LoadAsync);                    // async
public RelayCommand<Item> RemoveCommand => CreateCommand<Item>(Remove);         // parameterised

A synchronous body runs on the UI thread, marshalled if you invoke the command from elsewhere. Background work belongs in the Func<Task> overload, where leaving the UI thread is explicit.

Failures always reach ViewModelBase.OnCommandError, which logs them. Override it to add to that β€” calling the base keeps the log line β€” but there is nothing to write for the common case. A command body that throws used to leave a button that visibly did nothing, with no message and no log: the most expensive kind of bug to find, and one nobody chooses on purpose. ExecuteAsync also rethrows so an awaiting caller can react; ICommand.Execute cannot, and does not.

A command will not run twice at once. While its body is running, IsExecuting is true and CanExecute is false, so the button that started the work disables itself. Nothing to write, and no reason for a view model to guard a Save against a second press.

What a view model does need is a way to disable everything else, and ViewModelBase.IsBusy is it: true while any command it created is running, notified on the UI thread, and re-evaluating every command as it changes.

<ProgressBar IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
<Button Content="Delete" Command="{Binding DeleteCommand}" />   

Subscriptions that outlive the screen

A screen that subscribes to a service on the way in is kept alive by that service's event list on the way out. Nothing about it is visible until the application has been used for a while, and by then it is holding every screen the user has opened.

public OrdersViewModel(IServiceProvider services) : base(services)
{
    _repository = services.GetRequiredService<IOrderRepository>();

    // Released when the screen is discarded. The handler is a method, not a lambda: what is
    // unsubscribed has to be the same delegate that was subscribed.
    Subscribe(handler => _repository.Changed += handler,
              handler => _repository.Changed -= handler,
              OnOrdersChanged);
}

There are overloads for INotifyPropertyChanged and INotifyCollectionChanged, and Track for anything else that has to be released. The navigation service discards a screen when it drops one β€” going back, or clearing the history β€” and forward navigation does not, because that screen is on the stack and is coming back. A view model held anywhere else is discarded by whoever holds it.

Discard() deliberately is not Dispose(). Microsoft's container tracks every transient IDisposable it creates and holds it until the process ends, so a disposable view model would replace one leak with a larger and quieter one: every screen ever opened, kept alive by the container that built it.


🎨 Theming

A theme is a pair of palettes, light and dark. A variant is which of the two is showing. They are separate axes: switching theme keeps the variant, and vice versa.

Restyling the application

Override the colours you care about β€” everything derived from them follows:

using CdCSharp.Pangea.Theming.Palettes;

public sealed class BrandDark : DarkPalette
{
    public override Color ThemeAccentColor => Color.Parse("#FF4F9DDE");
    public override Color ThemeBackgroundColor => Color.Parse("#FF101418");
}

public sealed class BrandLight : PangeaPalette   // the base is the light palette
{
    public override Color ThemeAccentColor => Color.Parse("#FF1B6EC2");
}
services.Configure<ThemingOptions>(options =>
{
    // Replace the toolkit's own theme...
    options.Themes[PangeaTheme.DefaultName] = new PangeaTheme(new BrandLight(), new BrandDark());

    // ...or add more and let the user choose
    options.Themes["Contrast"] = new PangeaTheme(new ContrastLight(), new ContrastDark());
    options.DefaultTheme = "Contrast";

    options.EnableSystemThemeDetection = true;      // follow the OS preference
    options.FallbackVariant = ThemeVariant.Dark;    // when it has none
});

Every colour property name is its resource key, and each one also produces a brush with Color swapped for Brush. Overriding ThemeAccentColor therefore updates ThemeAccentColor, ThemeAccentBrush, and everything derived from them.

Switching at runtime

public class AppearanceViewModel : ViewModelBase
{
    private readonly IThemeService _themes;

    public AppearanceViewModel(IServiceProvider services) : base(services) =>
        _themes = services.GetRequiredService<IThemeService>();

    public IReadOnlyCollection<string> Themes => _themes.AvailableThemes;

    public RelayCommand<string> UseTheme => CreateCommand<string>(name => _themes.SetTheme(name!));
    public RelayCommand UseDark => CreateCommand(() => _themes.SetVariant(ThemeVariant.Dark));
}

The toolkit ships a ThemeSelector control with a view model that drives both axes.

Because the palettes are Avalonia theme variants, a ThemeVariantScope can render part of the UI in the opposite variant to the rest of the application.

Control themes

The control dictionaries under Resources/Controls/Shared are Avalonia's Simple theme, vendored as a starting point. A manifest records which files are still untouched copies and which this repo has taken ownership of, and a test fails when the two drift apart or when the Avalonia version moves without the theme being re-vendored.


πŸ’Ύ Storage

Per-platform application folders plus file access.

public class SettingsService(IStorageService storage)
{
    private readonly string _path = storage.GetDataFilePath("settings.json");

    public Task<Settings?> LoadAsync() => storage.ReadJsonAsync<Settings>(_path);

    public Task SaveAsync(Settings settings) => storage.WriteJsonAsync(_path, settings);
}
services.Configure<StorageOptions>(options =>
{
    options.ApplicationName = "MyApp";   // folder name under the platform's data directory
    options.UsePortableMode = false;     // true keeps everything next to the executable
    options.CustomDataPath = null;       // or somewhere of your choosing
});

Writes create the folders they need. Reads do not: ReadTextAsync fails on a missing file the way File.ReadAllTextAsync does, while ReadJsonAsync returns null, for state that may not exist yet.

JSON that will not convert throws StorageSerializationException, not an IOException. The two are different problems with different answers: "the disk is full, the file is open" is worth retrying, and "this object cannot be serialized" will fail identically on every run, on every machine, for every user. Catching them together is how an offline queue quietly discards everything it was holding.

try
{
    await _storage.WriteJsonAsync(path, queue);
}
catch (StorageSerializationException ex)
{
    // A defect, not a condition. Never a silent retry.
    _logger.LogError(ex, "The outbox cannot be written and is being dropped");
}
catch (IOException ex)
{
    _logger.LogWarning(ex, "The outbox could not be written this time; keeping it in memory");
}

Serialization happens before the file is touched, so a failure leaves whatever was there intact rather than truncating it.


πŸ” Secrets and connectivity

Two things every application on a phone needs and no application should write itself.

Where a credential goes

A refresh token is a bearer credential: whoever has it is the user until it is revoked. In settings.json it travels into backups, sync folders, support bundles and screenshots without anyone deciding that it should.

public class SessionStore(ISecretStore secrets)
{
    public Task SaveAsync(string refreshToken) => secrets.SetAsync("refresh-token", refreshToken);

    public Task<string?> LoadAsync() => secrets.GetAsync("refresh-token");
}

The store registered by default protects secrets as well as anything can without the platform's own API, and says which through Protection:

SecretProtection
OperatingSystem Windows: encrypted with DPAPI under the current user, so a copied file is useless elsewhere
UserOnly Linux and macOS: a file the filesystem keeps other accounts out of
Device What a head registering the Android Keystore or the iOS Keychain reports

A mobile head registers its own through UsePangea, and everything that stores a secret picks it up unchanged:

protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
    base.CustomizeAppBuilder(builder)
        .UsePangea(services => services.AddSingleton<ISecretStore>(_ => new KeystoreSecretStore(this)));

That is where platform services go, and it exists because there is nowhere else: a head cannot use App.Configure, which lives in the shared library and cannot see Android. What it registers runs first, and the toolkit's own services use TryAdd, so the platform's answer is the one resolved and the defaults fill in the rest. The application's Configure still has the final word.

The Supabase feature stores its session through this, and moves one it finds in the old plain file into the store on the next start, so upgrading neither signs users out nor leaves the credential where it was.

Whether it is worth trying

public class Outbox(IConnectivity connectivity)
{
    public async Task DrainAsync(CancellationToken cancellationToken)
    {
        await connectivity.WaitForConnectionAsync(cancellationToken);
        // ...
    }
}

IsConnected answers "is it worth trying", not "will it work" β€” a phone behind a captive portal is connected by every measure the operating system has and can reach nothing, so requests still handle their own failures. What it replaces is inferring the network from the shape of the last HttpRequestException. Changed is what tells an outbox that now is a good moment.

The default implementation reads the operating system's network interfaces, which works everywhere and is least accurate on a phone; a head with ConnectivityManager or NWPathMonitor registers its own through the same UsePangea hook.


πŸ—„οΈ Database

Entity Framework Core, wired the way a desktop application needs it rather than the way a web request does. It is not part of the CdCSharp.Pangea package: EF Core plus a native SQLite driver is several megabytes that an application storing its state in a JSON file should not carry. The engine comes with its provider package.

dotnet add package CdCSharp.Pangea.Data.Sqlite   # brings CdCSharp.Pangea.Data with it

Register a context in App.Configure, beside everything else the application registers:

services.AddPangeaDbContext<AppDbContext>(db =>
{
    db.UseSqlite("notes.db");                                 // the provider, and the file name
    db.Options.Migration = MigrationStrategy.MigrateWithBackup;
});

The file goes in the folder the storage feature keeps this application's data in β€” %APPDATA% on Windows, ~/.config on Linux, ~/Library/Application Support on macOS, or beside the executable in portable mode. That is the reason this feature exists rather than a bare UseSqlite call.

The context is an ordinary DbContext with the constructor the factory uses:

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Note> Notes => Set<Note>();
}

Reaching it from a view model

Ask for IPangeaDbContext<AppDbContext>, never for the context itself:

public partial class NotesViewModel : ViewModelBase
{
    private readonly IPangeaDbContext<AppDbContext> _db;

    [Binding] private ObservableCollection<Note> _notes = [];

    public NotesViewModel(IServiceProvider services) : base(services) =>
        _db = services.GetRequiredService<IPangeaDbContext<AppDbContext>>();

    public async Task LoadAsync() =>
        Notes = await _db.ToObservableAsync(context => context.Notes.OrderBy(note => note.Title));

    public Task AddAsync(string title) => _db.WriteAsync((context, token) =>
    {
        context.Notes.Add(new Note { Title = title });
        return Task.CompletedTask;
    });
}

Each call builds a context from the pooled factory, uses it and disposes it. A DbContext is a unit of work: it is not thread-safe, and it remembers every entity it has loaded. Both are fine for a request that lives for milliseconds and wrong for a window that stays open all day β€” a context injected into a view model and kept would grow without bound and serve values that changed hours ago. Registering a context therefore removes EF's own registration of it, so injecting one is a startup error rather than a leak nobody notices.

ReadAsync Runs a query with tracking off β€” what comes back is data for the UI
WriteAsync Runs a change and saves it, one write at a time
ToObservableAsync A query whose result is built on the UI thread, ready to bind to
Create() A context of your own, for a transaction or a bulk load

Migrations on the user's machine

Nobody is going to run dotnet ef database update on a machine you do not have. So pending migrations are applied at startup, which makes it the one part of startup that can destroy something β€” hence the default:

MigrationStrategy
MigrateWithBackup Default. Copies the database, migrates, and puts the copy back if the migration fails
Migrate Applies pending migrations
EnsureCreated Creates the schema from the model if there is no database yet. No history, so the next model change needs the file deleted
None The application looks after its own schema

The work runs behind a splash window rather than on the UI thread β€” see Work that has to finish first. If it fails, the splash says why and the main window never opens: an application whose database did not open has nothing to show.

Writing a migration is the ordinary EF workflow. What the template adds is an IDesignTimeDbContextFactory, without which the tooling starts the Avalonia application looking for a context:

dotnet tool install --global dotnet-ef
dotnet ef migrations add AddSomething --output-dir Data/Migrations

There is no database update step. The application applies its own migrations.

Backup, restore and size

There is no database administrator behind a desktop application, so whatever the user will be told to do has to be a button in the application:

public class SettingsViewModel(IDatabaseMaintenance<AppDbContext> maintenance)
{
    public async Task ShowAsync()
    {
        DatabaseInfo info = await maintenance.GetInfoAsync();
        // info.FilePath, info.SizeBytes, info.AppliedMigrations, info.PendingMigrations
    }

    public Task<string> BackUpAsync() => maintenance.BackupAsync();

    public Task CompactAsync() => maintenance.CompactAsync();
}

SQLite backups are taken with VACUUM INTO, not by copying the file: a copy of a live database may be missing pages that are still in the write-ahead log. Automatic backups are pruned to BackupsToKeep.

Seeding

public sealed class WelcomeNoteSeeder : IDataSeeder<AppDbContext>
{
    public async Task SeedAsync(AppDbContext context, CancellationToken cancellationToken)
    {
        if (await context.Notes.AnyAsync(cancellationToken)) return;

        context.Notes.Add(new Note { Title = "Welcome" });
    }
}
services.AddDataSeeder<AppDbContext, WelcomeNoteSeeder>();

Seeders run at startup after the schema is up to date, in Order, on every run β€” so the check above is not optional. Whatever is left pending on the context is saved for you.

What the SQLite provider sets, and why

Write-ahead logging, so a read does not block behind a write. A busy timeout, so a contended write waits instead of failing. Foreign keys on, because SQLite leaves them off. And writes taken one at a time, because the engine has exactly one writer whatever the callers think β€” without that, the second concurrent save reports "database is locked" to a user who has no idea what that means.

Checked at compile time

Installing the feature brings an analyzer with it. Three rules, all warnings, all for mistakes that compile and then surface much later than they were made:

PGD001 AddPangeaDbContext with no UseSqlite() β€” the container throws when it is built, naming a call that is nowhere near the registration
PGD002 GetRequiredService<AppDbContext>() β€” the context is deliberately not registered, and asking for one is the leak this feature exists to prevent. A context the application registered itself with EF's own AddDbContext is left alone
PGD003 SaveChangesAsync inside WriteAsync, which saves for you. Harmless and invisible: the second save finds nothing to do

Turn one down in a .globalconfig where it does not suit the project.

Trimming

EF Core is not trim-safe and says so: its API carries [RequiresUnreferencedCode] and [RequiresDynamicCode]. Published with TrimMode=full and no compiled model, a SQLite application builds and then throws MissingMethodException on the first query. Nothing else in Pangea depends on these packages, so the rest of the toolkit is unaffected.

If you need to trim, the verified recipe is to keep EF out of it:

<TrimMode>partial</TrimMode>
<ItemGroup>
  <TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore" />
  <TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore.Relational" />
  <TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup>

EF's compiled models make full trimming work too, but they are not wired in here on purpose: a compiled model that was not regenerated after a model change does not complain β€” it writes the wrong columns. NativeAOT is out regardless, because building the design-time model a migration needs is not something it supports.

Testing

dotnet add package CdCSharp.Pangea.Data.Testing

A real SQLite database in a directory of its own, registered through the same AddPangeaDbContext path the application uses, and deleted with the test:

await using PangeaTestDatabase<AppDbContext> database = await PangeaTestDatabase<AppDbContext>.CreateAsync();

await database.Db.WriteAsync((context, token) =>
{
    context.Notes.Add(new Note { Title = "first" });
    return Task.CompletedTask;
});

Assert.Equal(1, await database.Db.ReadAsync((context, token) => context.Notes.CountAsync(token)));

🌐 Localization

services.Configure<LocalizationOptions>(options =>
{
    options.SupportedCultures = ["en-US", "es-ES"];
    options.DefaultCulture = "en-US";
    options.AutoDetectCulture = true;

    // Assemblies holding the .resx-generated resource classes
    options.ResourceAssemblies.Add(typeof(Strings).Assembly);
});
public class GreetingViewModel : ViewModelBase
{
    private readonly ILocalizationService _localization;

    public string Welcome => _localization.GetString("WelcomeMessage");

    public void SwitchToSpanish() => _localization.SetCulture("es-ES");
}

An unresolved key comes back as itself, so a missing translation is visible rather than blank. SetCulture applies to the whole application, including threads started afterwards, and raises CultureChanged.

Changing language while the application runs

SetCulture changes the culture, but nothing on screen re-reads its text unless something tells it to. LocalizedStrings is what does: one object every binding goes through, registered for you, that announces a change of culture as a change to every string it holds.

<TextBlock Text="{Binding Strings[Home_Title]}" />
public class HomeViewModel : ViewModelBase
{
    public HomeViewModel(IServiceProvider services) : base(services) =>
        Strings = services.GetRequiredService<LocalizedStrings>();

    // The whole window follows a change of culture, because every label is one of these.
    public LocalizedStrings Strings { get; }
}

LanguageSelector is the picker, handed the view model the container built β€” the same arrangement as ThemeSelector:

<loc:LanguageSelector ViewModel="{Binding LanguageSelector}" />
public LanguageSelectorViewModel LanguageSelector { get; } =
    services.GetRequiredService<LanguageSelectorViewModel>();

It lists the supported cultures by their native names β€” someone hunting for Spanish in an English window is looking for "EspaΓ±ol" β€” applies the choice immediately, rolls back if the service refuses it, and follows a culture changed anywhere else.

What this does not refresh is anything the culture affects without going through LocalizedStrings: a StringFormat, a date, a number. Those are formatted by the binding itself, and a binding that has not been told anything changed will not run again. Re-raise those properties yourself from CultureChanged if a screen shows them.

Keys checked at compile time

That fallback is also why a mistyped key can ship: the application keeps working and shows WelcomeMessage to the user. The package carries an analyzer that reads the project's .resx files and says so first.

PGL001 The key is in none of the .resx files
PGL002 The key is in the neutral .resx but missing from a translation

GetString declares its parameter as a key, and so does LocalizedStrings, so every Strings["..."] in the application is checked already. Put [LocalizationKey] on wrappers of your own and their call sites are checked the same way:

// Same rules, one call site: Greeting("Welcome_Back", name) is checked like any other key.
public string Greeting([LocalizationKey] string key, string name) =>
    string.Format(_localization.GetString(key), name);

Only constant keys are checked; one built at runtime is left alone. Keys named in XAML are not seen either β€” they are not C# β€” but PGL002 is about the resource files themselves, so it reports whether or not any code reads the key.

Both are warnings. A key that resolves to nothing and a language that is missing one are defects with no other symptom β€” nothing else in the build, and nothing at runtime, will ever mention them.

Change that where it does not suit the project, with a .globalconfig beside it (PGL002 is reported against .resx files and once per compilation, which no .editorconfig section matches reliably):

is_global = true

# Stricter, for a project where an untranslated string must not ship:
dotnet_diagnostic.PGL002.severity = error

# Or quieter, where translation lags the code on purpose:
# dotnet_diagnostic.PGL002.severity = suggestion
<ItemGroup>
  <GlobalAnalyzerConfigFiles Include="localization.globalconfig" />
</ItemGroup>

πŸ”§ Configuration

public partial class App : PangeaApplication
{
    public override PangeaOptions ConfigurePangeaOptions(PangeaOptions options)
    {
        options.DI.AutoRegisterViewModels = true;
        options.DI.ViewModelLifetime = ServiceLifetime.Transient;

        // Assemblies to scan beyond those reachable from the entry assembly
        options.DI.AdditionalAssemblies.Add(typeof(PluginFeature).Assembly);

        options.Window.AutoDiscoverMainWindow = true;
        options.Window.MainWindowType = typeof(MainWindow);
        options.Window.MainViewModelType = typeof(MainWindowViewModel);

        return options;
    }
}

Work that has to finish first

IPangeaFeature.ConfigureApplication runs on the UI thread and returns nothing, so the only thing a feature can do with slow work there is start it and hope. That is right for work whose result merely replaces a default β€” the shell template restores the saved culture that way β€” and wrong for work the first screen cannot do without.

public sealed class WarmCacheInitializer(ICatalog catalog) : IPangeaAsyncInitializer
{
    public string Name => "Loading the catalog";   // shown on the splash while it runs

    public int Order => 0;                          // lower runs first

    public Task InitializeAsync(CancellationToken cancellationToken) =>
        catalog.LoadAsync(cancellationToken);
}
services.AddSingleton<IPangeaAsyncInitializer, WarmCacheInitializer>();

Every registered initializer is awaited, in order, off the UI thread, while a splash window stands in for the main one. Register none and startup is exactly what it always was: the main window is created and shown with nothing in between. The database feature registers one for you.

options.Startup.ShowSplash = true;                  // false leaves the screen empty until the main window
options.Startup.SplashWindowType = typeof(Splash);  // your own; implement IPangeaSplashView for the status
options.Startup.Timeout = TimeSpan.FromMinutes(2);  // null waits forever
options.Startup.FailureBehavior = StartupFailureBehavior.Report;

Report is the default: the splash becomes the failure report and stays there, because a process that vanishes with no window and no message is worse than one that says why. Continue logs it and opens the main window anyway; Throw rethrows on the UI thread, where the application's unhandled exception handling can see it.

Running past Timeout is one of those failures, reported as a TimeoutException naming the initializer that was still going β€” not as the bare A task was canceled underneath it.

Writing a feature

public class TelemetryFeature : IPangeaFeature
{
    public string Name => "Telemetry";
    public Version Version => new(1, 0, 0);

    public void ConfigureServices(IServiceCollection services) =>
        services.AddSingleton<ITelemetry, Telemetry>();

    public void ConfigureApplication(IServiceProvider services, IPangeaApplicationContext context)
    {
        // Runs once the container is built, with the application available
    }
}

Discovery is by interface: any non-abstract IPangeaFeature in a scanned assembly is instantiated and registered. A feature that fails to configure aborts startup naming itself, rather than leaving the application half-wired.

Logging

The toolkit logs through ILogger and registers no providers of its own. Add yours and it is picked up:

public override void Configure(IServiceCollection services) =>
    services.AddLogging(builder => builder.AddConsole());

What startup does instead of scanning

Discovering features, view models and views used to mean walking every assembly the application can reach and reading every type in it. All of that is knowable while the code is being compiled, so a source generator writes it down: each project gets a PangeaCatalog listing what it contributes, registered from a module initializer before anything asks.

Nothing to configure β€” the generator ships with the packages. What changes:

  • Startup does no assembly scan. TypeRegistry is still there and still registered, but nothing makes it read anything unless something asks it a question the catalog cannot answer.
  • View models are registered with a generated factory β€” a plain new with each dependency resolved by type β€” rather than left for the container to construct by reflection.
  • Views and windows are created by generated constructor calls rather than Activator.
  • Navigation requests are checked against their destinations from the catalog.

A project the generator never ran in falls back to the old path in full, and so does an application that names extra assemblies through options.DI.AdditionalAssemblies β€” nothing was compiled alongside those, so they are still read. The catalog is used only when the application's own assembly has one, because the toolkit's assemblies always do and that on its own proves nothing about the application.

This is also what makes trimming and ahead-of-time compilation reachable: a constructor called by generated code is a constructor the trimmer can see. Reflection has not left the toolkit entirely β€” validation attributes, the typed navigation arrival hook and resource discovery still use it β€” so treat this as the first step rather than the finished job.


πŸ’¬ Dialogs

Two questions, answered without a window being written for them.

bool confirmed = await _dialogs.ConfirmAsync(
    "Delete order", "This cannot be undone.", "Delete it", "Keep it");

await _dialogs.AlertAsync("Saved", "Your changes have been saved.");

Dismissing the dialog by its window chrome is read as a cancel, so ConfirmAsync never returns true without the user saying so. The dialog takes the application's theme, and needs a main window to own it.

For a dialog with its own fields, or a result that is not a bool, write a view model and a window and show it with IWindowManager.ShowDialogAsync<TWindow, TViewModel, TResult> β€” IDialogService is deliberately only these two questions.

Keyboard. Windows and dialogs both get focus placed on their first control when they open, unless they focused something themselves. Escape closes a dialog; it does not close a window, which is the platform convention rather than an oversight β€” Alt+F4 closes windows, and Escape destroying one holding unsaved work is a keystroke away from losing it. A secondary window can ask for it:

<Window xmlns:win="using:CdCSharp.Pangea.Windows"
        win:WindowBehavior.CloseOnEscape="True">

🧭 Navigation

A navigation request names where it goes, so the call site stays short and the compiler still checks it.

public sealed record ShowOrder(Guid Id) : INavigationRequest<OrderViewModel>;
public partial class OrderViewModel : ViewModelBase, INavigationAware<ShowOrder>
{
    public OrderViewModel(IServiceProvider services) : base(services) { }

    [Binding] private Order? _order;

    public Task OnNavigatedToAsync(ShowOrder request)
    {
        Order = _orders.Find(request.Id);   // request.Id is a Guid, no cast
        return Task.CompletedTask;
    }
}
// The destination is inferred from the request
await _navigation.NavigateToAsync(new ShowOrder(orderId));
await _navigation.NavigateToAsync<SettingsViewModel>();   // no data to carry
await _navigation.GoBackAsync();

Put a host where the content belongs and it follows along:

<Window xmlns:pangea="clr-namespace:CdCSharp.Pangea.Navigation;assembly=CdCSharp.Pangea.Navigation">
  <DockPanel>
    <StackPanel DockPanel.Dock="Left">  </StackPanel>
    <pangea:NavigationHost />
  </DockPanel>
</Window>

Lifecycle. Every view model gets three hooks from ViewModelBase. They now fire:

CanNavigateAwayAsync Return false to cancel the navigation - how a screen keeps unsaved work
OnNavigatedFromAsync The screen is no longer current
OnNavigatedToAsync The screen became current. A request arrives through INavigationAware<TRequest> instead

Going back returns the same view model instance and does not replay the request.

Arriving at a screen moves keyboard focus into it β€” the first control that can take it, or the host itself when the screen has none, so Tab always has somewhere to start. Set MovesFocusOnNavigation="False" on a host that is not the main subject of the screen, such as a detail pane beside a list where taking focus off the list on every selection would be maddening.

Not on a phone. Where the platform has no windows, focusing the first control of the screen just arrived at opens the system keyboard over the content the user navigated to read, so a host that was not told either way does not move focus there. Setting the property is respected on both, and an application presenting a touch shell on a desktop lifetime β€” a kiosk, a tablet build β€” says so once for all its hosts:

NavigationHost.SetApplicationMovesFocus(this, false);   // in App.Initialize

Views are found by name, through the same type scan the rest of the toolkit uses: OrderViewModel is displayed by OrderView, and MainWindowViewModel by MainWindow. Register explicitly with IViewLocator.Register<TViewModel, TView>() when a view does not follow either.

A request whose destination does not implement INavigationAware<TRequest> would navigate and silently drop its data. Startup checks every request and aborts naming both sides.


πŸ€– AI coding agents

Pangea ships a skill that teaches an AI coding agent to use the toolkit: the mental model, the conventions, the pitfalls, and the full list of theme resource keys.

Every C# sample in it is compiled against the real assemblies as part of the test suite, through the source generator, so the guidance cannot drift away from the code. That check has already caught a generator bug and an API that made the documented approach impossible.

Two ways to get it:

# 1. The template writes it into your project, where agents look for project skills
dotnet new pangea-app -n MyApp                  # --IncludeSkill is on by default
# -> MyApp/.claude/skills/pangea/SKILL.md       commit it and the whole team gets it
# 2. Download the version-pinned skill from the matching release
curl -L -o pangea-skill.zip \
  https://github.com/smaicas/CdCSharp.Pangea/releases/download/v1.0.0/pangea-skill-1.0.0.zip
unzip pangea-skill.zip -d ~/.claude/skills/     # unpacks as pangea/SKILL.md, available everywhere

The skill follows the usual layout: a directory named after the skill, holding a SKILL.md whose frontmatter says what it is and when to reach for it, with the bulky key reference in references/. The directory name is the skill's identity, so keep it pangea.

Pin the skill to the Pangea version you use. Guidance from an older release describes an API that has since moved, and an agent will follow it confidently.


πŸ§ͺ Testing your own application

dotnet add package CdCSharp.Pangea.Testing

A view model takes an IServiceProvider and asks it for what it needs, so testing one otherwise means starting Avalonia, building the real container and waiting for a window. PangeaTestServices is the same shape with test doubles in it:

PangeaTestServices services = new();
services.Dialogs.Answering(true);

OrderViewModel screen = new(services);
screen.DeleteCommand.Execute(null);

Assert.True(screen.Deleted);
Assert.Equal("Delete ORD-0001?", services.Dialogs.Confirmations.Single().Message);

Commands run inline, so a command has finished when Execute returns. Register the application's own services with services.Add<IOrders>(new FakeOrders()).

InlineUIDispatcher Runs everything where it was called. The default
PumpingUIDispatcher Owned by the calling thread, runs queued work on Drain() β€” for when the question is whether a call waited
RecordingDialogService Answers from a script and remembers how the question was worded
RecordingNavigationService Records where a navigation was headed and what it carried
InMemoryStorageService The same paths and the same JSON round trip, with nothing on disk
DictionaryLocalizationService Strings from a dictionary, without satellite assemblies
RecordingThemeService Tracks theme and variant without an application's styles

πŸ§ͺ Tests

dotnet test --project test/CdCSharp.Pangea.Core.Tests/CdCSharp.Pangea.Core.Tests.csproj

The suites cover the source generator, the theme (structure, resource resolution per variant, control template smoke tests, drift against upstream Avalonia), commands and threading, startup registries, storage, localization, navigation, the agent skill's samples, and the templates. test/CdCSharp.Pangea.Tests.Int is a sample application with a control gallery for looking at the theme by eye.

The theming, window and template tests run on Avalonia.Headless, so they need no display. test/CdCSharp.Pangea.Templates.Compile compiles the shipped templates against the toolkit in the working tree, so a template that stops building fails the build rather than the next person who generates one; CdCSharp.Pangea.Templates.Tests then starts the shell template's application and checks that startup, the view locator and a typed navigation request all still work. CI goes one step further: every option combination of both templates is generated from the packed package against the packages just built, and compiled for real.


🀝 Contributing

  1. Fork the repository
  2. Create a branch (git checkout -b feature/amazing-feature)
  3. Commit your changes
  4. Push and open a Pull Request

Found a bug? Check existing issues or open a new one.


πŸ“„ License

MIT β€” see LICENSE.


πŸ™ Acknowledgments

  • Avalonia UI β€” the cross-platform UI framework, and the Simple theme the control dictionaries started from
  • .NET β€” source generators and modern C#

<div align="center">

Made with ❀️ for the Avalonia and .NET community

πŸ” Back to top

</div>

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on CdCSharp.Pangea.Localization:

Package Downloads
CdCSharp.Pangea

Modern Avalonia ToolKit with MVVM, Binding, Theming and Storage features

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.53 130 8/23/2026
1.0.52 116 8/22/2026
1.0.48 120 8/18/2026
1.0.47 109 8/18/2026
1.0.46 116 8/18/2026
1.0.45 109 8/17/2026
1.0.44 103 8/17/2026
1.0.42 105 8/17/2026
1.0.41 109 8/17/2026
1.0.37 178 11/29/2025
1.0.36 244 11/27/2025
1.0.34 263 8/30/2025
1.0.33 270 8/29/2025
1.0.32 280 8/26/2025
1.0.31 311 8/26/2025
1.0.30 301 8/26/2025
1.0.29 265 8/25/2025
1.0.28 308 8/24/2025
1.0.27 312 8/24/2025
1.0.26 309 8/24/2025
Loading failed