Files
HC_APTBS/MainWindow.xaml.cs
LucianoDev 827b811b39 feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree:

- Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the
  identification card, manual KWP write + transaction log, ROM/EEPROM dump
  card with progress banner and completion message, persisted custom-commands
  library, persisted EEPROM passwords library. New service primitives:
  IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync.
  Persistence mirrors the Clients XML pattern in two new files
  (custom_commands.xml, eeprom_passwords.xml).
- Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear
  K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and
  progress dialog VM, gated on dashboard alarms.
- BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at
  0x0106 via IKwpService.ReadBipStatusAsync; status definitions in
  BipStatusDefinition.
- Tests page redesign: TestSectionCard + PhaseTileView replacing the old
  TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/
  TestSectionView controls and their VMs.
- Pump command sliders: Fluent thick-track style with overhang thumb,
  click-anywhere-and-drag, mouse-wheel adjustment.
- Window startup: app.manifest declares PerMonitorV2 DPI awareness,
  MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and
  maximizes there (after the hook is in place) so the app fits the work
  area exactly on any display configuration.
- Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias
  importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and
  dump-functions reference docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 13:59:50 +02:00

234 lines
8.8 KiB
C#

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls.Primitives;
using System.Windows.Interop;
using HC_APTBS.ViewModels;
using Wpf.Ui.Controls;
namespace HC_APTBS;
/// <summary>
/// Code-behind for MainWindow. Bridges <see cref="NavigationView"/> selection
/// to <see cref="MainViewModel.SelectedPage"/> in both directions.
///
/// Click handling: NavigationViewItem inherits from ButtonBase, so we listen
/// for the bubbling Click routed event at the NavigationView level. This
/// avoids NavigationView.SelectionChanged + Navigate(), which require items
/// to set TargetPageType (we don't — page hosting stays with the TabControl).
///
/// Programmatic selection: WPF-UI 3.0.5's NavigationView.SelectedItem is a
/// CLR property with `protected set` — there is no SelectedItemProperty DP
/// to set. Instead we drive each item's IsActive flag directly by calling
/// the public NavigationViewItem.Activate / Deactivate methods, which is
/// exactly what WPF-UI's own navigation pipeline does internally. This is
/// what makes the IsActive trigger in the BoxyNavItemTemplate fire.
/// </summary>
public partial class MainWindow : FluentWindow
{
private readonly MainViewModel _viewModel;
private NavigationViewItem? _activeItem;
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
// Maximisation is handled in the OnSourceInitialized override below, AFTER
// the WM_GETMINMAXINFO hook is installed. Setting WindowState=Maximized in
// XAML applies during InitializeComponent and triggers the first maximize
// before any hook can run — that's what produces the "bigger than fullscreen
// and clipped" symptom on FluentWindow / DPI-scaled displays.
_viewModel = viewModel;
DataContext = viewModel;
// Catch every NavigationViewItem click that bubbles up.
NavView.AddHandler(
ButtonBase.ClickEvent,
new RoutedEventHandler(OnNavItemClicked));
#if DEVELOPER_TOOLS
InjectDeveloperToolsPage();
#endif
Loaded += OnLoaded;
viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
#if DEVELOPER_TOOLS
/// <summary>
/// Adds the Developer Tools nav item and tab at runtime. Done in code-behind
/// rather than XAML so the consumer Release build (which removes the page
/// files entirely) has zero references to a type that doesn't exist there.
/// The nav item is appended to <c>FooterMenuItems</c> next to Settings; the
/// hosting <see cref="System.Windows.Controls.TabItem"/> is appended to
/// <c>PageTabs</c> at index 6, matching <c>AppPage.Developer</c>.
/// </summary>
private void InjectDeveloperToolsPage()
{
var navItem = new Wpf.Ui.Controls.NavigationViewItem
{
Content = "Developer",
Tag = AppPage.Developer,
TargetPageTag = "Developer",
Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.WrenchScrewdriver24 },
};
NavView.FooterMenuItems.Add(navItem);
var page = new Views.Pages.DeveloperPage();
page.SetBinding(System.Windows.FrameworkElement.DataContextProperty,
new System.Windows.Data.Binding(nameof(MainViewModel.DeveloperPage)));
PageTabs.Items.Add(new System.Windows.Controls.TabItem { Content = page });
}
#endif
private void OnLoaded(object sender, RoutedEventArgs e)
{
SyncSelectionFromViewModel();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.SelectedPage))
SyncSelectionFromViewModel();
}
private void OnNavItemClicked(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is DependencyObject source
&& FindAncestor<NavigationViewItem>(source) is { Tag: AppPage page } item
&& _viewModel.SelectedPage != page)
{
_viewModel.SelectedPage = page;
}
}
private void SyncSelectionFromViewModel()
{
var item = FindItemForPage(_viewModel.SelectedPage);
if (item is null || ReferenceEquals(_activeItem, item))
return;
// Flip IsActive on the previous and new items. NavigationViewItem
// exposes Activate/Deactivate publicly via INavigationViewItem; both
// call SetCurrentValue(IsActiveProperty, true/false) internally,
// which fires the IsActive trigger in BoxyNavItemTemplate.
_activeItem?.Deactivate(NavView);
item.Activate(NavView);
_activeItem = item;
}
private NavigationViewItem? FindItemForPage(AppPage page)
{
foreach (var menu in NavView.MenuItems)
if (menu is NavigationViewItem nvi && nvi.Tag is AppPage tag && tag == page)
return nvi;
foreach (var footer in NavView.FooterMenuItems)
if (footer is NavigationViewItem nvi && nvi.Tag is AppPage tag && tag == page)
return nvi;
return null;
}
private static T? FindAncestor<T>(DependencyObject? start) where T : DependencyObject
{
for (var node = start; node is not null; node = System.Windows.Media.VisualTreeHelper.GetParent(node))
if (node is T match)
return match;
return null;
}
private void OnWindowClosing(object sender, CancelEventArgs e)
{
// Allow the window to close; services are disposed by the DI container.
}
// ── Maximised-bounds hook (WM_GETMINMAXINFO) ──────────────────────────────
private const int WM_GETMINMAXINFO = 0x0024;
private const int MONITOR_DEFAULTTONEAREST = 2;
/// <summary>
/// Override (instead of event subscription) so the hook is installed at the
/// earliest possible point in the window lifecycle, before any subsequent
/// <c>WM_GETMINMAXINFO</c>. The maximize state itself is set here too, so it
/// runs only after the hook is in place.
/// </summary>
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var hwnd = new WindowInteropHelper(this).Handle;
HwndSource.FromHwnd(hwnd)?.AddHook(WindowProc);
WindowState = WindowState.Maximized;
}
private static IntPtr WindowProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_GETMINMAXINFO)
{
ApplyWorkAreaMaxBounds(hwnd, lParam);
handled = true;
}
return IntPtr.Zero;
}
/// <summary>
/// Constrains the window's <c>WM_GETMINMAXINFO</c> response to the current monitor's
/// work area. Without this hook, a maximised <see cref="Window"/> takes the full
/// monitor rectangle (including the taskbar) and gains an 8 px "phantom border" past
/// each edge, which is what produces the "bigger than full screen and clipped" symptom.
/// </summary>
private static void ApplyWorkAreaMaxBounds(IntPtr hwnd, IntPtr lParam)
{
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor != IntPtr.Zero)
{
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (GetMonitorInfo(monitor, ref info))
{
var work = info.rcWork;
var monitorRect = info.rcMonitor;
mmi.ptMaxPosition.x = Math.Abs(work.left - monitorRect.left);
mmi.ptMaxPosition.y = Math.Abs(work.top - monitorRect.top);
mmi.ptMaxSize.x = Math.Abs(work.right - work.left);
mmi.ptMaxSize.y = Math.Abs(work.bottom - work.top);
}
}
Marshal.StructureToPtr(mmi, lParam, true);
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int x; public int y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
}