From 3f52e3e662219439b73c21f5ff55c2399f1d3985 Mon Sep 17 00:00:00 2001 From: davejupp Date: Wed, 28 May 2025 20:33:12 -0500 Subject: [PATCH 1/2] Updates to menu items Adds Shortcut and hotkeys to almost all the menu items Updates "open file" to show the open file in the title menu Monitors configuration for changes (not the assist levels yet) Adds a prompt to save changes (or not, or cancel) if you try to exit with a modified config Config modification is shown in the title bar Fixes a small issue closing files (That would have likely been impossible to trigger without a hotkey) --- src/tool/App.config | 18 ++ src/tool/Model/Configuration.cs | 18 +- src/tool/Properties/Settings.Designer.cs | 14 +- src/tool/Properties/Settings.settings | 5 +- src/tool/View/MainWindow.xaml | 54 ++-- src/tool/View/MainWindow.xaml.cs | 15 ++ src/tool/ViewModel/MainViewModel.cs | 310 ++++++++++++++++++++++- 7 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 src/tool/App.config diff --git a/src/tool/App.config b/src/tool/App.config new file mode 100644 index 00000000..e6206dc0 --- /dev/null +++ b/src/tool/App.config @@ -0,0 +1,18 @@ + + + + +
+ + + + + + False + + + + + + + \ No newline at end of file diff --git a/src/tool/Model/Configuration.cs b/src/tool/Model/Configuration.cs index b8b74d2a..794addb4 100644 --- a/src/tool/Model/Configuration.cs +++ b/src/tool/Model/Configuration.cs @@ -726,6 +726,17 @@ public byte[] WriteToBuffer() } } + /// + /// Creates a new Configuration, copies the contents to it, and returns the new config object + /// + /// A completely new config object with the same values as this configuration + public Configuration Clone() + { + Configuration newConfig = new Configuration(); + newConfig.CopyFrom(this); + return newConfig; + } + public void CopyFrom(Configuration cfg) { Target = cfg.Target; @@ -796,9 +807,14 @@ public void WriteToFile(string filepath) { var serializer = new XmlSerializer(typeof(Configuration)); var settings = new XmlWriterSettings { Encoding = Encoding.UTF8, Indent = true }; - using (var xmlWriter = XmlWriter.Create(new StreamWriter(filepath), settings)) + // This line used to create a new xmlWriter directly, which seems like it should work, + // but it doesn't. (saving the same file repeatedly very fast causes exceptions) + // So we create a streamWriter and pass it to the xmlWriter so we can close it explicitly + using (var streamWriter = new StreamWriter(filepath)) { + var xmlWriter = XmlWriter.Create(streamWriter, settings); serializer.Serialize(xmlWriter, this); + xmlWriter.Close(); } } diff --git a/src/tool/Properties/Settings.Designer.cs b/src/tool/Properties/Settings.Designer.cs index 5c5cc2be..7eb3ab3a 100644 --- a/src/tool/Properties/Settings.Designer.cs +++ b/src/tool/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace BBSFW.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.7.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.13.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -34,5 +34,17 @@ public bool UseFreedomUnits { this["UseFreedomUnits"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string LastLoadedFile { + get { + return ((string)(this["LastLoadedFile"])); + } + set { + this["LastLoadedFile"] = value; + } + } } } diff --git a/src/tool/Properties/Settings.settings b/src/tool/Properties/Settings.settings index 6e0000b8..81e46b22 100644 --- a/src/tool/Properties/Settings.settings +++ b/src/tool/Properties/Settings.settings @@ -1,9 +1,12 @@ - + False + + + \ No newline at end of file diff --git a/src/tool/View/MainWindow.xaml b/src/tool/View/MainWindow.xaml index 8d9d0e5a..fc74759a 100644 --- a/src/tool/View/MainWindow.xaml +++ b/src/tool/View/MainWindow.xaml @@ -7,8 +7,9 @@ xmlns:vm="clr-namespace:BBSFW.ViewModel" xmlns:vw="clr-namespace:BBSFW.View" mc:Ignorable="d" - Title="BBS-FW Tool" Height="640" Width="860" - Background="#FFE8E8E8"> + Title="{Binding ApplicationTitle}" Height="640" Width="860" + Background="#FFE8E8E8" + Closing="Window_Closing"> @@ -20,30 +21,32 @@ - + - - - + + + + + - + - + - - - + + + - + - - - - + + + + - - + + @@ -64,6 +67,19 @@ - + + + + + + + + + + + + + + diff --git a/src/tool/View/MainWindow.xaml.cs b/src/tool/View/MainWindow.xaml.cs index 0c1c0831..68ce3bc5 100644 --- a/src/tool/View/MainWindow.xaml.cs +++ b/src/tool/View/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -24,5 +25,19 @@ public MainWindow() { InitializeComponent(); } + + /// + /// Application closing callback. + /// + /// + /// + private void Window_Closing(object sender, CancelEventArgs e) + { + // Get the view model + if (DataContext is ViewModel.MainViewModel viewModel) + { + e.Cancel = viewModel.OnExitCancellable(); + } + } } } diff --git a/src/tool/ViewModel/MainViewModel.cs b/src/tool/ViewModel/MainViewModel.cs index e872a698..4734f977 100644 --- a/src/tool/ViewModel/MainViewModel.cs +++ b/src/tool/ViewModel/MainViewModel.cs @@ -2,6 +2,8 @@ using BBSFW.ViewModel.Base; using Microsoft.Win32; using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Reflection; using System.Windows; using System.Windows.Input; @@ -11,6 +13,102 @@ namespace BBSFW.ViewModel public class MainViewModel : ObservableObject { + private const string APP_TITLE = "BBS-FW Tool"; + // Stores the name of the open config file. It's explicitly not a file handle, just a filename. + private string _configFileName; + + // Has the configuration been modified by a reload or UI changes? + private bool _configModified; + public bool ConfigModified + { + get { return _configModified; } + set + { + if (value != _configModified) + { + _configModified = value; + OnPropertyChanged(nameof(ConfigModified)); + } + } + } + + // Stores a set of the changed UI properties so we can mark the config unmodified if it's reverted + private HashSet ChangedProperties = new HashSet(); + + // Store a set of properties to ignore because they're triggered by a related metric update + private HashSet ImperialProperties = new HashSet { + "PretensionSpeedCutoffMph", "MaxSpeedMph" }; + + public string ConfigFileName + { + get { return _configFileName; } + set + { + if (_configFileName != value) + { + _configFileName = value; + OnPropertyChanged(nameof(ConfigFileName)); + OnPropertyChanged(nameof(ConfigFilenameExists)); + } + } + } + + /// + /// Not sure if there's a better way of doing this, But it's used to hide certain menus when there is no file loaded. + /// This check is trivial but allows us to do IsEnabled="{Binding ConfigFilenameExists}" in MainViewModel.xaml to show/hide menus. + /// + public bool ConfigFilenameExists + { + get + { + return !String.IsNullOrEmpty(ConfigFileName); + } + } + + private string _applicationTitle = "BBS - FW Tool"; + + public string ApplicationTitle { + get { return _applicationTitle; } + set { + if (_applicationTitle != value) + { + _applicationTitle = value; + OnPropertyChanged(nameof(ApplicationTitle)); + } + } + } + + private string GetAppTitle() + { + string modified = ConfigModified ? " (modified)" : String.Empty; + if (ConfigFilenameExists) + { + string fileName; + try + { + // this would likely only fail if the config file was deleted or moved. + fileName = System.IO.Path.GetFileName(ConfigFileName); + } catch + { + // and if that's the case it's still valid to use that filename, we'll just make it explicit what the path is also. + fileName = ConfigFileName; + } + + return $"{APP_TITLE} - {fileName}{modified}"; + } + else + { + return $"{APP_TITLE}{modified}"; + } + } + + /// + /// Original state of the configuration, so we can check for modifications + /// + private Configuration OriginalConfigurationState + { + get; set; + } public ConfigurationViewModel ConfigVm { get; private set; } @@ -31,6 +129,24 @@ public ICommand OpenConfigCommand get { return new DelegateCommand(OnOpenConfig); } } + public ICommand OpenConfigDirectCommand + { + get { return new DelegateCommand(OnOpenConfigDirect); } + } + + /** + * This doesnt actually close the config, it just clears the saved file name so you can't accidentally overwrite + */ + public ICommand CloseConfigCommand + { + get { return new DelegateCommand(OnCloseConfig); } + } + + public ICommand SaveAsConfigCommand + { + get { return new DelegateCommand(OnSaveAsConfig); } + } + public ICommand SaveConfigCommand { get { return new DelegateCommand(OnSaveConfig); } @@ -66,22 +182,85 @@ public ICommand ShowAboutCommand get { return new DelegateCommand(OnShowAbout); } } + public ICommand UseMetricUnitsCommand + { + get { return new DelegateCommand(OnUseMetric); } + } + public ICommand UseImperialUnitsCommand + { + get { return new DelegateCommand(OnUseImperial); } + } public MainViewModel() { ConfigVm = new ConfigurationViewModel(); + this.PropertyChanged += (sender, args) => + { + // Updates the title when the config is modified (to mark it so), and the filename when it's set. + if (args.PropertyName == nameof(ConfigModified) || + args.PropertyName == nameof(ConfigFileName)) + { + ApplicationTitle = GetAppTitle(); + } + }; + ConnectionVm = new ConnectionViewModel(); SystemVm = new SystemViewModel(ConfigVm); AssistLevelsVm = new AssistLevelsViewModel(ConfigVm); CalibrationVm = new CalibrationViewModel(ConnectionVm); EventLogVm = new EventLogViewModel(); - - + if (!String.IsNullOrEmpty(Properties.Settings.Default.LastLoadedFile)) + { + ConfigFileName = Properties.Settings.Default.LastLoadedFile; + OpenConfigDirectCommand.Execute(ConfigFileName); + } + // start monitoring after load so we don't trigger all the events + StartConfigMonitoring(); ConnectionVm.EventLogReceived += EventLogVm.AddEvent; } + /// + /// Updates the OriginalConfigurationState to match the current config, for when loading/reseting etc. + /// + private void ResetOriginalConfigurationState() + { + OriginalConfigurationState = ConfigVm.GetConfig().Clone(); + } + + /// + /// Start monitoring the ConfigurationViewModel for changes so we can mark it as modified when the user changes something in the UI. + /// + private void ConfigMonitoring(object? sender, PropertyChangedEventArgs args) + { + var propertyName = args.PropertyName; + if (propertyName != null && sender != null && !ImperialProperties.Contains(propertyName)) + { + var property = sender.GetType().GetProperty(propertyName); + if (property != null) + { + var value = property.GetValue(sender) ?? "NULL"; + string originalValue = OriginalConfigurationState.GetType().GetField(propertyName)?.GetValue(OriginalConfigurationState).ToString() ?? "NULL"; + string newValue = value?.ToString(); + + if (newValue != originalValue) + { + ConfigModified = true; + ChangedProperties.Add(propertyName); + } + else + { + ChangedProperties.Remove(propertyName); + // no values left means we have the original config again + if (ChangedProperties.Count == 0) + { + ConfigModified = false; + } + } + } + } + } private void OnSaveLog() { @@ -98,13 +277,31 @@ private void OnSaveLog() { EventLogVm.ExportLog(dialog.FileName); } - catch(Exception e) + catch (Exception e) { MessageBox.Show(e.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); } } } + private void OnCloseConfig() + { + ConfigFileName = null; + ConfigModified = false; + } + + private void StopConfigMonitoring() + { + ChangedProperties.Clear(); + ConfigVm.PropertyChanged -= ConfigMonitoring; + } + private void StartConfigMonitoring() + { + ChangedProperties.Clear(); + ResetOriginalConfigurationState(); + ConfigVm.PropertyChanged += ConfigMonitoring; + } + private void OnOpenConfig() { var dialog = new OpenFileDialog(); @@ -116,12 +313,34 @@ private void OnOpenConfig() { try { + StopConfigMonitoring(); ConfigVm.ReadConfiguration(dialog.FileName); + ConfigFileName = dialog.FileName; + ConfigModified = false; } catch (Exception e) { MessageBox.Show(e.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); } + finally + { + StartConfigMonitoring(); + } + } + } + private void OnOpenConfigDirect() + { + try + { + StopConfigMonitoring(); + ConfigVm.ReadConfiguration(_configFileName); + ConfigFileName = _configFileName; + ConfigModified = false; + StartConfigMonitoring(); + } + catch (Exception e) + { + MessageBox.Show(e.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); } } @@ -132,11 +351,44 @@ private void OnSaveConfig() return; } + if (!ConfigFilenameExists) + { + OnSaveAsConfig(); + return; + } + + try + { + ConfigVm.WriteConfiguration(ConfigFileName); + ConfigModified = false; + } + catch (Exception e) + { + MessageBox.Show(e.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void OnSaveAsConfig() + { + if (!ValidateConfig()) + { + return; + } + var dialog = new SaveFileDialog(); dialog.Filter = "XML File|*.xml"; dialog.Title = "Save Configuration"; - dialog.FileName = "bbsfw.xml"; + if (String.IsNullOrEmpty(ConfigFileName)) + { + dialog.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + dialog.FileName = "bbsfw.xml"; + } + else + { + dialog.InitialDirectory = System.IO.Path.GetDirectoryName(ConfigFileName); + dialog.FileName = System.IO.Path.GetFileName(ConfigFileName); + } var result = dialog.ShowDialog(); if (result.HasValue && result.Value) @@ -144,6 +396,9 @@ private void OnSaveConfig() try { ConfigVm.WriteConfiguration(dialog.FileName); + // Updating the config file name will also update the application title. + ConfigFileName = dialog.FileName; + ConfigModified = false; } catch (Exception e) { @@ -167,12 +422,15 @@ private async void OnReadFlash() var res = await ConnectionVm.GetConnection().ReadConfiguration(TimeSpan.FromSeconds(5)); if (!res.Timeout && res.Result != null) { + StopConfigMonitoring(); ConfigVm.UpdateFrom(res.Result); + StartConfigMonitoring(); } else { MessageBox.Show("Failed to read configuration from flash, timeout occured.", "Error", MessageBoxButton.OK, MessageBoxImage.Error); } + ConfigModified = true; } private async void OnWriteFlash() @@ -235,6 +493,15 @@ private async void OnResetFlash() } } + private void OnUseMetric() + { + ConfigVm.UseMetricUnits = true; + } + + private void OnUseImperial() + { + ConfigVm.UseImperialUnits = true; + } private void OnShowAbout() { @@ -242,9 +509,42 @@ private void OnShowAbout() MessageBox.Show($"Version: {version.Major}.{version.Minor}.{version.Build}\nAuthor: Daniel Nilsson", "BBS-FW Tool", MessageBoxButton.OK, MessageBoxImage.Information); } + /// + /// Displays the save/save as dialog as necessary. This should probably be in a service class? + /// + /// True if this exit was cancelled + public bool OnExitCancellable() + { + if (ConfigModified) + { + var result = MessageBox.Show($"You have unsaved changes, would you like to save them?", + "BBS-FW Tool", MessageBoxButton.YesNoCancel, MessageBoxImage.Information); + + if (result == MessageBoxResult.Yes) + { + if (ConfigFilenameExists) + { + OnSaveConfig(); + } + else + { + OnSaveAsConfig(); + } + } + return (result == MessageBoxResult.Cancel); + } + else + { + Properties.Settings.Default.LastLoadedFile = ConfigFileName ?? ""; + Properties.Settings.Default.Save(); + return false; + } + } + private void OnExit() { - Application.Current.Shutdown(); + + Application.Current.MainWindow.Close(); } private bool VerifyConfigVersionForRead() From 329b65a4b2626a52b2a4875074cee982003369c8 Mon Sep 17 00:00:00 2001 From: davejupp Date: Tue, 3 Jun 2025 14:40:37 -0500 Subject: [PATCH 2/2] Make the closer button call the correct method --- src/tool/View/MainWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tool/View/MainWindow.xaml b/src/tool/View/MainWindow.xaml index fc74759a..5c66de0d 100644 --- a/src/tool/View/MainWindow.xaml +++ b/src/tool/View/MainWindow.xaml @@ -25,7 +25,7 @@ - +