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
<PackageReference Include="CdCSharp.Pangea.Localization" Version="1.0.53" />
<PackageVersion Include="CdCSharp.Pangea.Localization" Version="1.0.53" />
<PackageReference Include="CdCSharp.Pangea.Localization" />
paket add CdCSharp.Pangea.Localization --version 1.0.53
#r "nuget: CdCSharp.Pangea.Localization, 1.0.53"
#:package CdCSharp.Pangea.Localization@1.0.53
#addin nuget:?package=CdCSharp.Pangea.Localization&version=1.0.53
#tool nuget:?package=CdCSharp.Pangea.Localization&version=1.0.53
π CdCSharp.Pangea
<div align="center">
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: aStringFormat, 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 fromCultureChangedif 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.
TypeRegistryis 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
newwith 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
- Fork the repository
- Create a branch (
git checkout -b feature/amazing-feature) - Commit your changes
- 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
</div>
| Product | Versions 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. |
-
net10.0
- Avalonia (>= 12.1.1)
- CdCSharp.Pangea.Core (>= 1.0.53)
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 |