Skip to content
PreciSim
source-guide

Adding a page

Build a device debug page from scratch and walk the whole MVVM convention.

A real task to walk the conventions: add a "nozzle vacuum debug" page.

1. ViewModel

// 07.PreciSim.App/ViewModels/VacuumDebugViewModel.cs
public partial class VacuumDebugViewModel : PageViewModelBase
{
    private readonly IDigitalIo _io;
 
    public VacuumDebugViewModel(IDigitalIo io, ILogger<VacuumDebugViewModel> logger)
        : base(logger)
    {
        _io = io;
    }
 
    [ObservableProperty] private bool _vacuumOn;
    [ObservableProperty] private bool _partDetected;
 
    [RelayCommand(CanExecute = nameof(CanToggle))]
    private async Task ToggleVacuumAsync(CancellationToken ct)
    {
        await _io.WriteAsync(IoMap.VacuumValve, !VacuumOn);
        VacuumOn = !VacuumOn;
    }
 
    private bool CanToggle() => !IsBusy && ConnectionState == DeviceState.Connected;
 
    protected override void OnActivated()
    {
        // subscribe only while the page is visible; the base class unsubscribes on leave
        Subscribe(_io.Changes, OnIoChanged);
    }
 
    private void OnIoChanged(IoChange c)
    {
        if (c.Channel == IoMap.PartSensor) PartDetected = c.Value;
    }
}

Points:

  • CommunityToolkit.Mvvm source generators — no hand-written INotifyPropertyChanged;
  • commands carry CanExecute, which drives button state — no IsEnabled logic in the view;
  • subscribe through the base class helper. Forgetting to unsubscribe is the number one cause of WPF memory leaks.

2. View

<!-- 07.PreciSim.App/Views/VacuumDebugView.xaml -->
<UserControl x:Class="PreciSim.App.Views.VacuumDebugView"
             d:DataContext="{d:DesignInstance vm:VacuumDebugViewModel}">
  <StackPanel Margin="16">
    <ToggleButton Content="{DynamicResource Page.Vacuum}"
                  IsChecked="{Binding VacuumOn, Mode=OneWay}"
                  Command="{Binding ToggleVacuumCommand}" />
    <TextBlock Text="{Binding PartDetected, Converter={StaticResource BoolToPresence}}" />
  </StackPanel>
</UserControl>

Plain {Binding}no binding DSL. The code-behind contains InitializeComponent() and nothing else.

3. Registration

// 07.PreciSim.App/Startup/ViewRegistration.cs
services.AddTransient<VacuumDebugViewModel>();
services.AddTransient<VacuumDebugView>();
 
// 07.PreciSim.App/Navigation/PageCatalog.cs
new PageEntry(
    id: "debug.vacuum",
    titleKey: "Page.VacuumDebug",
    icon: Icons.Vacuum,
    requiredRole: UserRole.Engineer,      // permissions declared here, not checked inside the page
    viewModel: typeof(VacuumDebugViewModel))

4. Strings

<!-- Resources/Strings.en.resx -->
<data name="Page.VacuumDebug"><value>Vacuum debug</value></data>
<!-- Resources/Strings.zh-CN.resx -->
<data name="Page.VacuumDebug"><value>真空调试</value></data>

Both resx files. A CI test compares the key sets and fails if one is missing — the same idea as the bilingual dictionary on this website.

5. Test

[Fact]
public async Task ToggleVacuum_WritesToIo()
{
    var io = new FakeDigitalIo();
    var vm = new VacuumDebugViewModel(io, NullLogger<VacuumDebugViewModel>.Instance);
 
    await vm.ToggleVacuumCommand.ExecuteAsync(null);
 
    Assert.True(io.LastWrite(IoMap.VacuumValve));
    Assert.True(vm.VacuumOn);
}

The ViewModel has no WPF dependency, so this runs instantly and needs no UI thread. That is the practical payoff of MVVM, not architectural tidiness.

Checklist

  • No Dispatcher or MessageBox in the ViewModel (dialogs go through IDialogService)
  • View code-behind is only InitializeComponent()
  • Both resx files updated
  • The page declares requiredRole
  • One unit test that does not touch the UI
Last updated: Sep 21, 2026
Was this page helpful?