Files
ReLibreHardwareMonitor/LibreHardwareMonitor.Windows.WinUI/MainWindow.cs
T
JMR-devandCopilot fd55a89e22 Convert sensor pane scaffold and TreeView to MainWindow.xaml (Phase 5b step 4)
Declare the sensor pane (SensorPane), header host (SensorHeaderHost), and TreeView (SensorTree, with its ContentPresenter DataTemplate and SelectionChanged handler) in XAML. The measurer-managed header grid and the per-row CreateSensorRow content remain imperative since they involve dynamic column-width pushing and per-item context flyouts/double-tap handlers; this is the hybrid fallback called out in the Phase 5b plan.

Drop _sensorTree/_sensorPane fields; replace BuildSensorPane with a small PopulateSensorHeader and delete the old XamlReader-loaded DataTemplate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-02 13:31:24 -05:00

908 lines
34 KiB
C#

// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) LibreHardwareMonitor and Contributors.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using LibreHardwareMonitor.Hardware;
using LibreHardwareMonitor.Windows.WinUI.Controls;
using LibreHardwareMonitor.Windows.WinUI.Services;
using LibreHardwareMonitor.Windows.WinUI.Utilities;
using LibreHardwareMonitor.Windows.WinUI.ViewModels;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Markup;
using Microsoft.UI.Xaml.Media;
using WinRT.Interop;
using IOPath = System.IO.Path;
namespace LibreHardwareMonitor.Windows.WinUI;
public sealed partial class MainWindow : Window
{
private readonly AppWindow _appWindow;
private readonly WindowChromeManager _chromeManager;
private readonly WindowPlacementService _placementService;
private readonly DispatcherQueueTimer _timer;
private readonly PlotView _plotView;
private readonly Grid _plotPane;
private readonly WinUiStartupTrace? _startupTrace;
private readonly TrayIconService _trayIconService;
private readonly DialogService _dialogService;
private readonly SensorColumnMeasurer _columnMeasurer;
private readonly SecondaryWindowCoordinator _secondaryWindows;
private bool _firstLayoutRecorded;
private bool _isUpdating;
private bool _initialWindowStateApplied;
private bool _isClosingForExit;
private bool _isShuttingDown;
private bool _runtimeErrorLogged;
private bool _isMonitoringStarted;
private bool _startupCompletionRequested;
private bool _startupCompleteRecorded;
private bool _sensorTreeRebuildQueued;
private bool _sensorColumnWidthUpdateQueued;
public MainWindow() : this(null)
{
}
internal MainWindow(WinUiStartupTrace? startupTrace)
{
_startupTrace = startupTrace;
_startupTrace?.Mark("MainWindow.Constructor.Begin");
AppSettings settings = MeasureStartup("MainWindow.LoadSettings", AppSettings.LoadDefault);
ViewModel = MeasureStartup("MainWindow.CreateViewModel", () => new MainWindowViewModel(settings, _startupTrace));
_appWindow = MeasureStartup("MainWindow.GetAppWindow", () =>
{
IntPtr hwnd = WindowNative.GetWindowHandle(this);
WindowId windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
AppWindow appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = "Libre Hardware Monitor";
return appWindow;
});
IntPtr mainHwnd = WindowNative.GetWindowHandle(this);
_chromeManager = new WindowChromeManager(mainHwnd);
_placementService = new WindowPlacementService(_appWindow, settings, mainHwnd);
_trayIconService = MeasureStartup("MainWindow.CreateTrayIconService", () => new TrayIconService(
WindowNative.GetWindowHandle(this),
settings,
() => ViewModel.TemperatureUnit));
_trayIconService.IsMainIconEnabled = ViewModel.MinimizeToTray;
_dialogService = new DialogService(() => Content.XamlRoot, ViewModel, WindowNative.GetWindowHandle(this));
_secondaryWindows = new SecondaryWindowCoordinator(ViewModel, HideShowMainWindow);
_columnMeasurer = new SensorColumnMeasurer(DispatcherQueue, ViewModel, _startupTrace);
MeasureStartup("MainWindow.ApplySavedDeviceColumnWidth", _columnMeasurer.ApplySavedWidth, () => FormattableString.Invariant($"width={_columnMeasurer.DeviceColumnWidth:F0}"));
_columnMeasurer.SettleTriggered += (_, _) => UpdateSensorColumnWidths();
MeasureStartup("MainWindow.InitializeComponent", InitializeComponent);
RootGrid.Background = new SolidColorBrush(Colors.White);
RootGrid.Loaded += RootGrid_Loaded;
RootGrid.LayoutUpdated += RootGrid_LayoutUpdated;
Bind(ContentGrid, UIElement.IsHitTestVisibleProperty, ViewModel, nameof(ViewModel.IsHardwareInteractionEnabled));
MeasureStartup("MainWindow.PopulateMenuSubmenus", PopulateMenuSubmenus);
MeasureStartup("MainWindow.PopulateSensorHeader", PopulateSensorHeader);
PlotView? builtPlot = null;
Grid plotPane = MeasureStartup("MainWindow.BuildPlotPane", () =>
{
Grid pane = BuildPlotPane(out PlotView pv);
builtPlot = pv;
return pane;
});
_plotPane = plotPane;
_plotView = builtPlot!;
ContentGrid.Children.Add(plotPane);
MeasureStartup("MainWindow.RestoreWindowBounds", _placementService.Restore);
MeasureStartup("MainWindow.MaximizeWindow", _placementService.Maximize);
MeasureStartup("MainWindow.ApplyTheme", ApplyTheme);
MeasureStartup("MainWindow.UpdatePlotLayout", UpdatePlotLayout);
_timer = MeasureStartup("MainWindow.CreateTimer", () =>
{
DispatcherQueueTimer timer = DispatcherQueue.CreateTimer();
timer.Interval = ViewModel.UpdateInterval;
timer.Tick += UpdateTimer_Tick;
return timer;
});
MeasureStartup("MainWindow.WireEvents", () =>
{
ViewModel.PropertyChanged += (_, args) =>
{
if (args.PropertyName is nameof(ViewModel.UpdateInterval) or nameof(ViewModel.UpdateIntervalIndex))
_timer.Interval = ViewModel.UpdateInterval;
if (args.PropertyName is nameof(ViewModel.ShowPlot)
or nameof(ViewModel.PlotLocation)
or nameof(ViewModel.PlotGridColumn)
or nameof(ViewModel.PlotGridRow))
UpdatePlotLayout();
if (args.PropertyName is nameof(ViewModel.PlotStackedAxes)
or nameof(ViewModel.ShowPlotAxisLabels)
or nameof(ViewModel.PlotTimeWindowIndex))
DrawPlot();
if (args.PropertyName == nameof(ViewModel.ShowGadget))
UpdateGadgetVisibility();
if (args.PropertyName == nameof(ViewModel.MinimizeToTray))
_trayIconService.IsMainIconEnabled = ViewModel.MinimizeToTray;
if (args.PropertyName == nameof(ViewModel.TemperatureUnit))
{
_trayIconService.Update();
SyncGadgetSensors();
ViewModel.RefreshPlotSeries();
}
if (args.PropertyName == nameof(ViewModel.ShowHiddenSensors))
QueueSensorTreeRebuild();
if (args.PropertyName is nameof(ViewModel.ShowValueColumn)
or nameof(ViewModel.ShowMinColumn)
or nameof(ViewModel.ShowMaxColumn))
QueueSensorColumnWidthUpdate();
if (args.PropertyName == nameof(ViewModel.ThemeMode))
ApplyTheme();
};
ViewModel.RootItems.CollectionChanged += RootItems_CollectionChanged;
ViewModel.PlotInvalidated += (_, _) => DrawPlot();
ViewModel.GadgetSensorsChanged += (_, _) => SyncGadgetSensors();
ViewModel.TraySensorsChanged += (_, _) => SyncTraySensors();
_trayIconService.HideShowRequested += (_, _) => HideShowMainWindow();
_trayIconService.ExitRequested += (_, _) => CloseApplication();
_appWindow.Changed += AppWindow_Changed;
Closed += MainWindow_Closed;
});
_startupTrace?.Mark("MainWindow.Constructor.Complete");
}
public MainWindowViewModel ViewModel { get; }
public void StartMonitoringAfterActivation()
{
_startupTrace?.Mark("MainWindow.StartMonitoringAfterActivation.Queued");
if (!DispatcherQueue.TryEnqueue(async () =>
{
if (_isMonitoringStarted)
return;
_isMonitoringStarted = true;
try
{
_startupTrace?.Mark("MainWindow.StartMonitoringAfterActivation.Begin");
ApplyInitialWindowState();
await MeasureStartupAsync("MainWindowViewModel.StartAsync", ViewModel.StartAsync);
SyncTraySensors();
SyncGadgetSensors();
UpdateGadgetVisibility();
MeasureStartup("MainWindow.StartTimer", _timer.Start);
_startupTrace?.Mark("MainWindow.StartMonitoringAfterActivation.Complete");
RequestStartupTraceComplete();
_startupTrace?.Flush();
}
catch (Exception ex)
{
_startupTrace?.Mark("MainWindow.StartMonitoringAfterActivation.Exception", $"{ex.GetType().FullName}: {ex.Message}");
_startupTrace?.Flush();
RecordRuntimeError("Hardware initialization failed", ex);
}
}))
{
_startupTrace?.Mark("MainWindow.StartMonitoringAfterActivation.EnqueueFailed");
_startupTrace?.Flush();
}
}
private string GetRootSizeDetail()
{
return FormattableString.Invariant($"root={RootGrid.ActualWidth:F0}x{RootGrid.ActualHeight:F0}, rows={_columnMeasurer.RowCount}, rootNodes={SensorTree.RootNodes.Count}");
}
private void RootGrid_Loaded(object sender, RoutedEventArgs e)
{
RootGrid.Loaded -= RootGrid_Loaded;
_startupTrace?.Mark("MainWindow.RootLoaded", GetRootSizeDetail());
_startupTrace?.Flush();
}
private void RootGrid_LayoutUpdated(object? sender, object e)
{
if (_firstLayoutRecorded)
return;
_firstLayoutRecorded = true;
RootGrid.LayoutUpdated -= RootGrid_LayoutUpdated;
_startupTrace?.Mark("MainWindow.FirstLayoutUpdated", GetRootSizeDetail());
_startupTrace?.Flush();
}
private void MeasureStartup(string phase, Action action)
{
if (_startupTrace is not { IsComplete: false })
{
action();
return;
}
_startupTrace.Measure(phase, action);
}
private void MeasureStartup(string phase, Action action, Func<string> getDetail)
{
if (_startupTrace is not { IsComplete: false })
{
action();
return;
}
_startupTrace.Measure(phase, action, getDetail);
}
private T MeasureStartup<T>(string phase, Func<T> action)
{
if (_startupTrace is not { IsComplete: false })
return action();
return _startupTrace.Measure(phase, action);
}
private async Task MeasureStartupAsync(string phase, Func<Task> action)
{
if (_startupTrace is not { IsComplete: false })
{
await action();
return;
}
await _startupTrace.MeasureAsync(phase, action);
}
private void PopulateMenuSubmenus()
{
PopulateRadioSubMenu(TemperatureUnitMenu, [
("Celsius", TemperatureUnit.Celsius),
("Fahrenheit", TemperatureUnit.Fahrenheit)
], () => ViewModel.TemperatureUnit, v => ViewModel.TemperatureUnit = v);
PopulateRadioSubMenu(PlotLocationMenu, [
("Window", PlotLocation.Window),
("Bottom", PlotLocation.Bottom),
("Right", PlotLocation.Right)
], () => ViewModel.PlotLocation, v => ViewModel.PlotLocation = v);
PopulateRadioSubMenu(ThemeMenu, [
("Auto", AppThemeMode.Auto),
("Light", AppThemeMode.Light),
("Dark", AppThemeMode.Dark),
("Black", AppThemeMode.Black)
], () => ViewModel.ThemeMode, v => ViewModel.ThemeMode = v);
PopulateRadioSubMenu(StrokeThicknessMenu, [
("1pt", 1),
("2pt", 2),
("3pt", 3),
("4pt", 4)
], () => (int)ViewModel.PlotStrokeThickness, v => ViewModel.PlotStrokeThickness = v);
PopulateRadioSubMenu(FileRotationMenu, [
("Per Session", LoggerFileRotation.PerSession),
("Daily", LoggerFileRotation.Daily)
], () => ViewModel.FileRotationMethod, v => ViewModel.FileRotationMethod = v);
PopulateRadioSubMenu(UpdateIntervalMenu, [
("250ms", 0), ("500ms", 1), ("1s", 2), ("2s", 3), ("5s", 4), ("10s", 5)
], () => ViewModel.UpdateIntervalIndex, v => ViewModel.UpdateIntervalIndex = v);
PopulateRadioSubMenu(LoggingIntervalMenu, [
("1s", 0), ("2s", 1), ("5s", 2), ("10s", 3), ("30s", 4), ("1min", 5),
("2min", 6), ("5min", 7), ("10min", 8), ("30min", 9), ("1h", 10), ("2h", 11), ("6h", 12)
], () => ViewModel.LoggingIntervalIndex, v => ViewModel.LoggingIntervalIndex = v);
PopulateRadioSubMenu(SensorTimeWindowMenu, [
("30s", 0), ("1min", 1), ("2min", 2), ("5min", 3), ("10min", 4),
("30min", 5), ("1h", 6), ("2h", 7), ("6h", 8), ("12h", 9), ("24h", 10)
], () => ViewModel.SensorValuesTimeWindowIndex, v => ViewModel.SensorValuesTimeWindowIndex = v);
RunWebServerItem.IsEnabled = !ViewModel.IsWebServerUnavailable;
WebServerInterfaceItem.IsEnabled = !ViewModel.IsWebServerUnavailable;
WebServerAuthItem.IsEnabled = !ViewModel.IsWebServerUnavailable;
}
private static void PopulateRadioSubMenu<T>(MenuFlyoutSubItem subItem, (string Label, T Value)[] items, Func<T> getter, Action<T> setter)
{
foreach ((string label, T value) in items)
{
ToggleMenuFlyoutItem item = new()
{
Text = label,
IsChecked = Equals(getter(), value),
Tag = value
};
item.Click += (_, _) =>
{
setter(value);
foreach (ToggleMenuFlyoutItem sibling in subItem.Items.OfType<ToggleMenuFlyoutItem>())
sibling.IsChecked = Equals(sibling.Tag, value);
};
subItem.Items.Add(item);
}
}
private async void OnSaveReport(object sender, RoutedEventArgs e) => await _dialogService.SaveReportAsync();
private void OnResetHardware(object sender, RoutedEventArgs e) => ViewModel.ResetHardware();
private void OnExit(object sender, RoutedEventArgs e) => CloseApplication();
private void OnResetMinMax(object sender, RoutedEventArgs e) => ViewModel.ResetMinMax();
private void OnExpandAll(object sender, RoutedEventArgs e)
{
ViewModel.SetAllExpanded(true);
RebuildSensorTree();
}
private void OnCollapseAll(object sender, RoutedEventArgs e)
{
ViewModel.SetAllExpanded(false);
RebuildSensorTree();
}
private void OnResetPlot(object sender, RoutedEventArgs e) => ResetPlot();
private async void OnWebServerSettings(object sender, RoutedEventArgs e) => await _dialogService.ShowWebServerSettingsAsync();
private async void OnWebServerAuthentication(object sender, RoutedEventArgs e) => await _dialogService.ShowWebServerAuthenticationAsync();
private async void OnAbout(object sender, RoutedEventArgs e) => await _dialogService.ShowAboutAsync();
private void PopulateSensorHeader()
{
Grid header = _columnMeasurer.CreateHeaderGrid();
header.Padding = new Thickness(8, 5, 8, 5);
header.Background = (Brush)Application.Current.Resources["SystemControlBackgroundChromeMediumLowBrush"];
header.Children.Add(CreateHeaderText("Sensor", 0, Visibility.Visible));
header.Children.Add(CreateHeaderText("Value", 1, ViewModel.ValueColumnVisibility, nameof(ViewModel.ValueColumnVisibility)));
header.Children.Add(CreateHeaderText("Min", 2, ViewModel.MinColumnVisibility, nameof(ViewModel.MinColumnVisibility)));
header.Children.Add(CreateHeaderText("Max", 3, ViewModel.MaxColumnVisibility, nameof(ViewModel.MaxColumnVisibility)));
SensorHeaderHost.Children.Add(header);
}
private Grid BuildPlotPane(out PlotView plotView)
{
Grid pane = new()
{
Padding = new Thickness(8),
BorderBrush = (Brush)Application.Current.Resources["SystemControlForegroundBaseLowBrush"],
BorderThickness = new Thickness(1, 0, 0, 0),
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }
}
};
Grid toolbar = new()
{
ColumnDefinitions =
{
new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) },
new ColumnDefinition { Width = GridLength.Auto }
}
};
toolbar.Children.Add(new TextBlock { Text = "Plot", FontWeight = new global::Windows.UI.Text.FontWeight { Weight = 600 }, VerticalAlignment = VerticalAlignment.Center });
Button reset = new() { Content = "Reset" };
reset.Click += (_, _) => ResetPlot();
Grid.SetColumn(reset, 1);
toolbar.Children.Add(reset);
pane.Children.Add(toolbar);
PlotView plotViewLocal = new(ViewModel);
Grid.SetRow(plotViewLocal, 1);
pane.Children.Add(plotViewLocal);
plotView = plotViewLocal;
return pane;
}
private void RootItems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
QueueSensorTreeRebuild();
}
private void QueueSensorTreeRebuild()
{
if (_sensorTreeRebuildQueued)
return;
_sensorTreeRebuildQueued = true;
if (!DispatcherQueue.TryEnqueue(() =>
{
_sensorTreeRebuildQueued = false;
RebuildSensorTree();
}))
{
_sensorTreeRebuildQueued = false;
}
}
private void QueueSensorColumnWidthUpdate()
{
if (_sensorColumnWidthUpdateQueued)
return;
_sensorColumnWidthUpdateQueued = true;
if (!DispatcherQueue.TryEnqueue(() =>
{
_sensorColumnWidthUpdateQueued = false;
UpdateSensorColumnWidths();
}))
{
_sensorColumnWidthUpdateQueued = false;
}
}
private async void UpdateTimer_Tick(DispatcherQueueTimer sender, object args)
{
if (_isUpdating || _isShuttingDown)
return;
_isUpdating = true;
try
{
await ViewModel.UpdateAsync();
// The window may have been closed (and the view model/computer disposed) while we awaited the update.
if (_isShuttingDown)
return;
UpdateSensorColumnWidths();
_trayIconService.Update();
_secondaryWindows.SyncGadgetSensors();
DrawPlot();
}
catch (Exception ex)
{
// A transient update failure must not permanently freeze the UI: keep the timer running and just record it.
RecordRuntimeError("Sensor update failed", ex);
}
finally
{
_isUpdating = false;
}
}
private void RecordRuntimeError(string message, Exception exception)
{
_startupTrace?.Mark("MainWindow.RuntimeError", $"{message}: {exception.GetType().FullName}: {exception.Message}");
_startupTrace?.Flush();
// Use a dedicated runtime log (not the startup log that App.xaml.cs appends to) and write it only once, so a
// recurring per-tick failure neither clobbers the startup diagnostics nor grows the file without bound.
string logPath = IOPath.Combine(AppContext.BaseDirectory, "LibreHardwareMonitor.Windows.WinUI.runtime.log");
if (!_runtimeErrorLogged)
{
try
{
File.WriteAllText(logPath, exception.ToString());
}
catch
{
// Never let diagnostic logging throw out of the update loop.
}
_runtimeErrorLogged = true;
}
ViewModel.SetStatusText($"{message}. See {IOPath.GetFileName(logPath)}.");
}
private void MainWindow_Closed(object sender, WindowEventArgs args)
{
if (!_isClosingForExit && ViewModel.MinimizeOnClose)
{
args.Handled = true;
MinimizeOrHideMainWindow();
return;
}
_isShuttingDown = true;
_timer.Stop();
_columnMeasurer.StopSettleTimer();
_trayIconService.Dispose();
_secondaryWindows.CloseAll();
_placementService.Save();
ViewModel.Dispose();
_startupTrace?.Dispose();
}
private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args)
{
if (_isClosingForExit || !ViewModel.MinimizeToTray)
return;
if (sender.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Minimized })
HideMainWindowToTray();
}
private void ApplyInitialWindowState()
{
if (_initialWindowStateApplied)
return;
_initialWindowStateApplied = true;
if (ViewModel.StartMinimized)
MinimizeOrHideMainWindow();
}
private void CloseApplication()
{
_isClosingForExit = true;
Close();
}
private void HideShowMainWindow()
{
if (_chromeManager.IsHiddenOrMinimizedOrInvisible)
{
_chromeManager.Restore();
Activate();
}
else
{
HideMainWindowToTray();
}
}
private void MinimizeOrHideMainWindow()
{
if (ViewModel.MinimizeToTray)
HideMainWindowToTray();
else
_chromeManager.Minimize();
}
private void HideMainWindowToTray()
{
_chromeManager.HideToTray();
}
private void SyncTraySensors()
{
_trayIconService.RestoreSelectedSensors(ViewModel.GetTraySensorItems());
}
private void SyncGadgetSensors()
{
_secondaryWindows.SyncGadgetSensors();
}
private void UpdateGadgetVisibility()
{
_secondaryWindows.UpdateGadgetVisibility();
}
private void UpdatePlotWindowVisibility()
{
_secondaryWindows.UpdatePlotWindowVisibility();
}
private void RebuildSensorTree()
{
MeasureStartup("MainWindow.RebuildSensorTree", RebuildSensorTreeCore, GetRootSizeDetail);
_columnMeasurer.ScheduleSettle();
SyncTraySensors();
SyncGadgetSensors();
if (_startupCompletionRequested)
CompleteStartupTraceIfReady();
}
private void RebuildSensorTreeCore()
{
_columnMeasurer.ResetRows();
SensorTree.RootNodes.Clear();
foreach (SensorTreeItemViewModel item in ViewModel.RootItems)
{
TreeViewNode? node = CreateTreeNode(item);
if (node != null)
SensorTree.RootNodes.Add(node);
}
UpdateSensorColumnWidths();
}
private void CompleteStartupTraceIfReady()
{
if (_startupCompleteRecorded || _startupTrace is not { IsComplete: false } || _columnMeasurer.RowCount == 0)
return;
_startupCompleteRecorded = true;
_startupTrace.Complete("MainWindow.StartupComplete", GetRootSizeDetail());
}
private void RequestStartupTraceComplete()
{
_startupCompletionRequested = true;
if (!DispatcherQueue.TryEnqueue(CompleteStartupTraceIfReady))
CompleteStartupTraceIfReady();
}
private TreeViewNode? CreateTreeNode(SensorTreeItemViewModel item)
{
if (item.RowVisibility == Visibility.Collapsed)
return null;
TreeViewNode node = new()
{
Content = CreateSensorRow(item),
IsExpanded = item.IsExpanded
};
foreach (SensorTreeItemViewModel child in item.Children)
{
TreeViewNode? childNode = CreateTreeNode(child);
if (childNode != null)
node.Children.Add(childNode);
}
return node;
}
private FrameworkElement CreateSensorRow(SensorTreeItemViewModel item)
{
Grid row = _columnMeasurer.CreateRowGrid();
row.DataContext = item;
row.Padding = new Thickness(0, 3, 0, 3);
StackPanel sensorCell = new() { Orientation = Orientation.Horizontal, Spacing = 6 };
CheckBox plotCheck = new() { MinWidth = 22 };
Bind(plotCheck, ToggleButton.IsCheckedProperty, item, nameof(SensorTreeItemViewModel.Plot), BindingMode.TwoWay);
Bind(plotCheck, UIElement.VisibilityProperty, item, nameof(SensorTreeItemViewModel.PlotCheckVisibility));
plotCheck.Checked += (_, _) => QueuePlotSeriesRefresh();
plotCheck.Unchecked += (_, _) => QueuePlotSeriesRefresh();
sensorCell.Children.Add(plotCheck);
FontIcon icon = new() { FontSize = 14 };
Bind(icon, FontIcon.GlyphProperty, item, nameof(SensorTreeItemViewModel.IconGlyph));
sensorCell.Children.Add(icon);
TextBlock name = new() { TextTrimming = TextTrimming.CharacterEllipsis };
Bind(name, TextBlock.TextProperty, item, nameof(SensorTreeItemViewModel.Text));
ToolTip toolTip = new();
Bind(toolTip, ContentControl.ContentProperty, item, nameof(SensorTreeItemViewModel.ToolTip));
ToolTipService.SetToolTip(name, toolTip);
sensorCell.Children.Add(name);
row.Children.Add(sensorCell);
row.Children.Add(CreateValueTextBlock(item, nameof(SensorTreeItemViewModel.Value), nameof(SensorTreeItemViewModel.ValueColumnVisibility), 1));
row.Children.Add(CreateValueTextBlock(item, nameof(SensorTreeItemViewModel.Min), nameof(SensorTreeItemViewModel.MinColumnVisibility), 2));
row.Children.Add(CreateValueTextBlock(item, nameof(SensorTreeItemViewModel.Max), nameof(SensorTreeItemViewModel.MaxColumnVisibility), 3));
row.ContextFlyout = BuildSensorContextMenu(item);
row.DoubleTapped += async (_, _) =>
{
if (item.Sensor?.Parameters.Count > 0)
await _dialogService.ShowParametersAsync(item);
};
return row;
}
private MenuFlyout BuildSensorContextMenu(SensorTreeItemViewModel item)
{
MenuFlyout flyout = new();
if (item.Sensor?.Parameters.Count > 0)
flyout.Items.Add(CreateMenuItem("Parameters...", async (_, _) => await _dialogService.ShowParametersAsync(item)));
MenuFlyoutItem rename = CreateMenuItem("Rename", async (_, _) => await _dialogService.RenameAsync(item));
rename.IsEnabled = item.CanRename;
flyout.Items.Add(rename);
ToggleMenuFlyoutItem plot = new() { Text = "Show in Plot", IsEnabled = item.Sensor != null };
Bind(plot, ToggleMenuFlyoutItem.IsCheckedProperty, item, nameof(SensorTreeItemViewModel.Plot), BindingMode.TwoWay);
plot.Click += (_, _) => QueuePlotSeriesRefresh();
flyout.Items.Add(plot);
ToggleMenuFlyoutItem tray = new()
{
Text = "Show in Tray",
IsChecked = ViewModel.IsSensorInTray(item),
IsEnabled = item.Sensor != null
};
tray.Click += (_, _) =>
{
ViewModel.SetSensorInTray(item, tray.IsChecked);
SyncTraySensors();
};
flyout.Items.Add(tray);
ToggleMenuFlyoutItem gadget = new()
{
Text = "Show in Gadget",
IsChecked = ViewModel.IsSensorInGadget(item),
IsEnabled = item.Sensor != null
};
gadget.Click += (_, _) =>
{
ViewModel.SetSensorInGadget(item, gadget.IsChecked);
SyncGadgetSensors();
};
flyout.Items.Add(gadget);
MenuFlyoutItem hide = CreateMenuItem(item.IsVisible ? "Hide Sensor" : "Unhide Sensor", (_, _) =>
{
if (item.CanHide)
{
item.IsVisible = !item.IsVisible;
RebuildSensorTree();
}
});
hide.IsEnabled = item.CanHide;
flyout.Items.Add(hide);
flyout.Items.Add(new MenuFlyoutSeparator());
MenuFlyoutItem penColor = CreateMenuItem("Pen Color...", async (_, _) => await _dialogService.ShowPenColorAsync(item));
penColor.IsEnabled = item.Sensor != null;
flyout.Items.Add(penColor);
MenuFlyoutItem resetPenColor = CreateMenuItem("Reset Pen Color", (_, _) => ViewModel.SetSensorPenColor(item, null));
resetPenColor.IsEnabled = item.PenColor != null;
flyout.Items.Add(resetPenColor);
if (item.Sensor?.Control != null)
{
flyout.Items.Add(new MenuFlyoutSeparator());
flyout.Items.Add(BuildControlMenu(item.Sensor.Control));
}
flyout.Items.Add(new MenuFlyoutSeparator());
flyout.Items.Add(CreateMenuItem("Reset Min/Max", (_, _) => ViewModel.ResetMinMax()));
return flyout;
}
private void QueuePlotSeriesRefresh()
{
if (!DispatcherQueue.TryEnqueue(ViewModel.RefreshPlotSeries))
ViewModel.RefreshPlotSeries();
}
private static MenuFlyoutSubItem BuildControlMenu(IControl control)
{
MenuFlyoutSubItem controlItem = new() { Text = "Control" };
ToggleMenuFlyoutItem defaultItem = new() { Text = "Default", IsChecked = control.ControlMode == ControlMode.Default };
defaultItem.Click += (_, _) => control.SetDefault();
controlItem.Items.Add(defaultItem);
MenuFlyoutSubItem manual = new() { Text = "Manual" };
for (int value = 0; value <= 100; value += 5)
{
if (value > control.MaxSoftwareValue || value < control.MinSoftwareValue)
continue;
int controlValue = value;
ToggleMenuFlyoutItem item = new()
{
Text = $"{controlValue} %",
IsChecked = control.ControlMode == ControlMode.Software && Math.Abs(control.SoftwareValue - controlValue) < 0.1f
};
item.Click += (_, _) => control.SetSoftware(controlValue);
manual.Items.Add(item);
}
controlItem.Items.Add(manual);
return controlItem;
}
private void SensorTree_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
{
if (args.AddedItems.Count == 0)
{
ViewModel.SelectedItem = null;
return;
}
object selected = args.AddedItems[0];
if (selected is TreeViewNode node && node.Content is FrameworkElement element)
ViewModel.SelectedItem = element.DataContext as SensorTreeItemViewModel;
}
private void ResetPlot()
{
ViewModel.ResetPlot();
_plotView.ResetZoom();
_secondaryWindows.RedrawPlot();
}
private void DrawPlot()
{
if (ViewModel.PlotLocation == PlotLocation.Window)
_plotView.Clear();
else
_plotView.Redraw();
_secondaryWindows.RedrawPlot();
}
private void UpdatePlotLayout()
{
ContentGrid.ColumnDefinitions[1].Width = ViewModel.ShowPlot && ViewModel.PlotLocation == PlotLocation.Right ? new GridLength(320) : new GridLength(0);
ContentGrid.RowDefinitions[1].Height = ViewModel.ShowPlot && ViewModel.PlotLocation == PlotLocation.Bottom ? new GridLength(220) : new GridLength(0);
_plotPane.Visibility = ViewModel.PlotVisibility;
Grid.SetRow(_plotPane, ViewModel.PlotGridRow);
Grid.SetColumn(_plotPane, ViewModel.PlotGridColumn);
Grid.SetRowSpan(_plotPane, ViewModel.PlotGridRowSpan);
Grid.SetColumnSpan(_plotPane, ViewModel.PlotGridColumnSpan);
Grid.SetRowSpan(SensorPane, ViewModel.SensorGridRowSpan);
Grid.SetColumnSpan(SensorPane, ViewModel.SensorGridColumnSpan);
UpdatePlotWindowVisibility();
DrawPlot();
}
private void ApplyTheme()
{
RootGrid.RequestedTheme = ViewModel.ThemeMode switch
{
AppThemeMode.Light => ElementTheme.Light,
AppThemeMode.Dark or AppThemeMode.Black => ElementTheme.Dark,
_ => ElementTheme.Default
};
RootGrid.Background = ViewModel.ThemeMode switch
{
AppThemeMode.Black => new SolidColorBrush(Colors.Black),
AppThemeMode.Dark => new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 30, 30, 30)),
_ => (Brush)Application.Current.Resources["ApplicationPageBackgroundThemeBrush"]
};
_plotView.ApplyTheme(ViewModel.ThemeMode);
_secondaryWindows.ApplyTheme(ViewModel.ThemeMode);
}
private void UpdateSensorColumnWidths()
{
MeasureStartup("MainWindow.UpdateSensorColumnWidths", _columnMeasurer.UpdateWidths, () => FormattableString.Invariant($"rows={_columnMeasurer.RowCount}, cacheEntries={_columnMeasurer.CacheEntryCount}, deviceWidth={_columnMeasurer.DeviceColumnWidth:F0}, settled={_columnMeasurer.IsSettled}"));
}
private TextBlock CreateHeaderText(string text, int column, Visibility visibility, string? bindingPath = null)
{
TextBlock textBlock = new()
{
Text = text,
FontWeight = new global::Windows.UI.Text.FontWeight { Weight = 600 },
Visibility = visibility
};
if (bindingPath != null)
Bind(textBlock, UIElement.VisibilityProperty, ViewModel, bindingPath);
Grid.SetColumn(textBlock, column);
return textBlock;
}
private static TextBlock CreateValueTextBlock(SensorTreeItemViewModel item, string textPath, string visibilityPath, int column)
{
TextBlock textBlock = new() { TextTrimming = TextTrimming.CharacterEllipsis };
Bind(textBlock, TextBlock.TextProperty, item, textPath);
Bind(textBlock, UIElement.VisibilityProperty, item, visibilityPath);
Grid.SetColumn(textBlock, column);
return textBlock;
}
private static MenuFlyoutItem CreateMenuItem(string text, RoutedEventHandler handler)
{
MenuFlyoutItem item = new() { Text = text };
item.Click += handler;
return item;
}
private static void Bind(DependencyObject target, DependencyProperty property, object source, string path, BindingMode mode = BindingMode.OneWay)
{
BindingOperations.SetBinding(target, property, new Binding
{
Source = source,
Path = new PropertyPath(path),
Mode = mode,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
});
}
}