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..5c66de0d 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()