From c9696eaa9aed89b9be0a63413ab0762dd5fb482a Mon Sep 17 00:00:00 2001
From: Haden Smith <123974306+HadenSmith@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:00:25 +0000
Subject: [PATCH 01/13] Trim FrameworkUI.Demo to the hazard function example
Remove the stubbed response and consequence function elements, the
orphaned undo and theme demo controls, and the unused generic element
document/properties controls. The demo now focuses on a single fully
implemented hazard function element.
---
.../ConsequenceElement.cs | 129 --------
.../ConsequenceElementCollection.cs | 113 -------
src/FrameworkUI.Demo/Model/DemoProject.cs | 24 +-
.../Response Elements/ResponseElement.cs | 129 --------
.../ResponseElementCollection.cs | 113 -------
.../Model/Undo Demo/UndoDemoElement.cs | 276 ------------------
.../Undo Demo/UndoDemoElementCollection.cs | 187 ------------
.../UI/ElementDocumentControl.xaml | 12 -
.../UI/ElementDocumentControl.xaml.cs | 44 ---
.../UI/ElementPropertiesControl.xaml | 15 -
.../UI/ElementPropertiesControl.xaml.cs | 44 ---
src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml | 267 -----------------
.../UI/ThemeDemoControl.xaml.cs | 200 -------------
src/FrameworkUI.Demo/UI/UndoDemoControl.xaml | 78 -----
.../UI/UndoDemoControl.xaml.cs | 259 ----------------
15 files changed, 7 insertions(+), 1883 deletions(-)
delete mode 100644 src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs
delete mode 100644 src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs
delete mode 100644 src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs
delete mode 100644 src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs
delete mode 100644 src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs
delete mode 100644 src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs
delete mode 100644 src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml
delete mode 100644 src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs
delete mode 100644 src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml
delete mode 100644 src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs
delete mode 100644 src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml
delete mode 100644 src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs
delete mode 100644 src/FrameworkUI.Demo/UI/UndoDemoControl.xaml
delete mode 100644 src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs
diff --git a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs b/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs
deleted file mode 100644
index 93d77c52..00000000
--- a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Represents a consequence element in the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ConsequenceElement : ElementBase
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the consequence element.
- /// The parent collection that contains this element.
- public ConsequenceElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- _description = "Consequence Element";
- _creationDate = DateTime.Now;
- _lastModified = _creationDate;
- SetIsDirty(false);
- }
-
- ///
- /// Gets or sets the name of the consequence element.
- ///
- public override string Name { get; set; }
-
- ///
- /// Gets or sets the description of the consequence element.
- ///
- public override string Description { get => _description; set => _description = value; }
-
- ///
- /// Gets the creation date of the consequence element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the consequence element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => "Consequence Element";
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => true;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid { get => true; }
-
- ///
- /// Creates a copy of the consequence element.
- ///
- /// The name for the copied element. If null, a default name will be used.
- /// A copy of the element, or null if copying is not implemented.
- public override IElement Copy(string newName = null)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// A copy of the external element, or null if not implemented.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Deletes the consequence element.
- ///
- public override void Delete()
- {
- //throw new NotImplementedException();
- }
-
-
- ///
- /// Opens the consequence element for editing.
- ///
- public override void Open()
- {
- //throw new NotImplementedException();
- }
-
- ///
- /// Saves the consequence element to disk.
- ///
- public override void Save()
- {
- //throw new NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs b/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs
deleted file mode 100644
index 86d65e88..00000000
--- a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Collection of consequence function elements for the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ConsequenceElementCollection : ElementCollectionBase
- {
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent project that contains this collection.
- public ConsequenceElementCollection(IProject parentProject) : base(parentProject)
- {
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name
- {
- get
- {
- return "Consequence Functions";
- }
- }
-
- ///
- /// Opens the collection and loads elements from disk.
- ///
- public override void Open()
- {
- _opening = true;
- // read from disk
- _opening = false;
- }
-
- ///
- /// Adds a consequence element to the collection.
- ///
- /// The element to add to the collection.
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Add((ConsequenceElement)item);
- // Save to disk
- if (_opening == false)
- {
- //item.IsValid();
- SetIsDirty(true);
- }
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts a consequence element at the specified index in the collection.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The element to insert.
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Insert(index, (ConsequenceElement)item);
- // save to disk
- SetIsDirty(true);
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Deletes the collection from disk.
- ///
- public override void Delete()
- {
- // Delete from disk
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- throw new System.NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index cfb002fd..227a48d2 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -32,7 +32,7 @@ namespace FrameworkUI.Demo
///
[Category("Project")]
[DisplayName("Demo Project")]
- [Description("The Demo project includes hazard, response, and consequence input functions.")]
+ [Description("The Demo project showcases a fully implemented hazard function element.")]
[Browsable(true)]
public class DemoProject : ProjectBase
{
@@ -51,15 +51,11 @@ private DemoProject()
InitializeMessages();
_hazardFunctions = new HazardElementCollection(this);
- _responseFunctions = new ResponseElementCollection(this);
- _consequenceFunctions = new ConsequenceElementCollection(this);
_hazardFunctions.ObjectSaved += ElementCollection_Saved;
- _responseFunctions.ObjectSaved += ElementCollection_Saved;
- _consequenceFunctions.ObjectSaved += ElementCollection_Saved;
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
}
///
@@ -101,8 +97,6 @@ private void InitializeMessages()
private static readonly char[] _InvalidNameCharacters = new List(Path.GetInvalidFileNameChars()) { '\'', '[', ']' }.ToArray();
private HazardElementCollection _hazardFunctions;
- private ResponseElementCollection _responseFunctions;
- private ConsequenceElementCollection _consequenceFunctions;
private bool _nameValid = false;
private List _messages = new List();
private Messenger _messenger = Messenger.GetInstance();
@@ -233,7 +227,7 @@ public override void CreateNew(string fullFileName)
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ { _hazardFunctions });
Save();
}
@@ -252,7 +246,7 @@ public void CreateNewDummyProject()
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ { _hazardFunctions });
Save();
}
@@ -270,12 +264,10 @@ public override void Open()
// Open element collections.
_hazardFunctions.Open();
- _responseFunctions.Open();
- _consequenceFunctions.Open();
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ { _hazardFunctions });
NameOnDisk = Name;
IsUndoEnabled = true;
@@ -289,10 +281,8 @@ public override void Close()
{
_messenger.Clear(this);
- // Close all project element collections..
+ // Close all project element collections.
_hazardFunctions.Clear();
- _responseFunctions.Clear();
- _consequenceFunctions.Clear();
}
///
diff --git a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs b/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs
deleted file mode 100644
index 7f8f0b17..00000000
--- a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Represents a response element in the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ResponseElement : ElementBase
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the response element.
- /// The parent collection that contains this element.
- public ResponseElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- _description = "Response Element";
- _creationDate = DateTime.Now;
- _lastModified = _creationDate;
- SetIsDirty(false);
- }
-
- ///
- /// Gets or sets the name of the response element.
- ///
- public override string Name { get; set; }
-
- ///
- /// Gets or sets the description of the response element.
- ///
- public override string Description { get => _description; set => _description = value; }
-
- ///
- /// Gets the creation date of the response element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the response element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => "Response Element";
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => true;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid { get => true; }
-
- ///
- /// Creates a copy of the response element.
- ///
- /// The name for the copied element. If null, a default name will be used.
- /// A copy of the element, or null if copying is not implemented.
- public override IElement Copy(string newName = null)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// A copy of the external element, or null if not implemented.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Deletes the response element.
- ///
- public override void Delete()
- {
- //throw new NotImplementedException();
- }
-
-
- ///
- /// Opens the response element for editing.
- ///
- public override void Open()
- {
- //throw new NotImplementedException();
- }
-
- ///
- /// Saves the response element to disk.
- ///
- public override void Save()
- {
- //throw new NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs b/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs
deleted file mode 100644
index 6863aaf7..00000000
--- a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Collection of response function elements for the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ResponseElementCollection : ElementCollectionBase
- {
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent project that contains this collection.
- public ResponseElementCollection(IProject parentProject) : base(parentProject)
- {
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name
- {
- get
- {
- return "Response Functions";
- }
- }
-
- ///
- /// Opens the collection and loads elements from disk.
- ///
- public override void Open()
- {
- _opening = true;
- // read from disk
- _opening = false;
- }
-
- ///
- /// Adds a response element to the collection.
- ///
- /// The element to add to the collection.
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Add((ResponseElement)item);
- // Save to disk
- if (_opening == false)
- {
- //item.IsValid();
- SetIsDirty(true);
- }
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts a response element at the specified index in the collection.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The element to insert.
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Insert(index, (ResponseElement)item);
- // save to disk
- SetIsDirty(true);
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Deletes the collection from disk.
- ///
- public override void Delete()
- {
- // Delete from disk
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- throw new System.NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs b/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs
deleted file mode 100644
index 25ccb24b..00000000
--- a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs
+++ /dev/null
@@ -1,276 +0,0 @@
-using FrameworkInterfaces;
-using FrameworkInterfaces.Undo;
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// A demo element that showcases the undo/redo functionality. This element demonstrates how to use RecordPropertyChange for undo support.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public class UndoDemoElement : ElementBase, IUndoableElement
- {
- private string _customValue = "Initial Value";
- private int _numericValue = 0;
- private bool _booleanValue = false;
- private DateTime _dateValue = DateTime.Today;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the element.
- /// The parent collection that contains this element.
- public UndoDemoElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- SetIsDirty(false);
- }
-
- #region Properties with Undo Support
-
- ///
- /// Gets or sets the name of the element with undo support.
- ///
- public override string Name
- {
- get => NameField;
- set
- {
- if (NameField != value)
- {
- var oldValue = NameField;
- NameField = value;
- RecordPropertyChange(nameof(Name), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets the description with undo support.
- ///
- public override string Description
- {
- get => _description ?? "Undo Demo Element";
- set
- {
- if (_description != value)
- {
- var oldValue = _description;
- _description = value;
- RecordPropertyChange(nameof(Description), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a custom string value with undo support.
- ///
- public string CustomValue
- {
- get => _customValue;
- set
- {
- if (_customValue != value)
- {
- var oldValue = _customValue;
- _customValue = value;
- RecordPropertyChange(nameof(CustomValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a numeric value with undo support.
- ///
- public int NumericValue
- {
- get => _numericValue;
- set
- {
- if (_numericValue != value)
- {
- var oldValue = _numericValue;
- _numericValue = value;
- RecordPropertyChange(nameof(NumericValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a boolean value with undo support.
- ///
- public bool BooleanValue
- {
- get => _booleanValue;
- set
- {
- if (_booleanValue != value)
- {
- var oldValue = _booleanValue;
- _booleanValue = value;
- RecordPropertyChange(nameof(BooleanValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a date value with undo support.
- ///
- public DateTime DateValue
- {
- get => _dateValue;
- set
- {
- if (_dateValue != value)
- {
- var oldValue = _dateValue;
- _dateValue = value;
- RecordPropertyChange(nameof(DateValue), oldValue, value);
- }
- }
- }
-
- #endregion
-
- #region Required Abstract Properties
-
- ///
- /// Gets the creation date of the element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => Name;
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- ///
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => false;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid => true;
-
- #endregion
-
- #region Required Abstract Methods
-
- ///
- /// Creates a copy of the element.
- ///
- /// The name for the copied element. If null, a default name will be generated.
- /// A copy of the element.
- public override IElement Copy(string newName = null)
- {
- var copy = new UndoDemoElement(newName ?? $"{Name}_Copy", ParentCollection);
- copy.CustomValue = this.CustomValue;
- copy.NumericValue = this.NumericValue;
- copy.BooleanValue = this.BooleanValue;
- copy.DateValue = this.DateValue;
- copy.Description = this.Description;
- return copy;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// Null, as external copying is not supported for this element type.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- return null;
- }
-
- ///
- /// Deletes the element and raises the appropriate events.
- ///
- public override void Delete()
- {
- bool cancel = false;
- RaisePreviewDeleted(this, ref cancel);
- if (!cancel)
- {
- RaiseDeleted(this);
- }
- }
-
- ///
- /// Opens the element and loads its data from disk. Clears the undo history after loading.
- ///
- public override void Open()
- {
- // Simulate loading from disk
- IsUndoEnabled = false;
- // ... load data ...
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
- }
-
- ///
- /// Saves the element to disk and marks the current state as the undo save point.
- ///
- public override void Save()
- {
- bool cancel = false;
- RaisePreviewObjectSaved(this, ref cancel);
- if (!cancel)
- {
- // Simulate saving to disk
- // ... save data ...
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
- }
- }
-
- #endregion
-
- ///
- /// Performs a batch update using a transaction. All changes within the transaction can be undone with a single Undo.
- ///
- /// The new custom string value.
- /// The new numeric value.
- /// The new boolean value.
- public void PerformBatchUpdate(string newCustomValue, int newNumericValue, bool newBooleanValue)
- {
- // Using statement ensures transaction is committed when disposed
- using (UndoManager.BeginTransaction("Batch Update"))
- {
- CustomValue = newCustomValue;
- NumericValue = newNumericValue;
- BooleanValue = newBooleanValue;
- }
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs b/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs
deleted file mode 100644
index 74b46537..00000000
--- a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs
+++ /dev/null
@@ -1,187 +0,0 @@
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// A demo element collection that showcases the undo/redo functionality for collection operations. This collection demonstrates how to use RecordAddElement and RecordRemoveElement for undo support.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public class UndoDemoElementCollection : ElementCollectionBase
- {
- ///
- /// Initializes a new instance of the class and adds initial demo elements.
- ///
- /// The parent project that contains this collection.
- public UndoDemoElementCollection(IProject parentProject) : base(parentProject)
- {
- // Add some initial demo elements
- Add(new UndoDemoElement("Demo Element 1", this));
- Add(new UndoDemoElement("Demo Element 2", this));
- Add(new UndoDemoElement("Demo Element 3", this));
-
- // Mark as clean after initialization
- SetIsDirty(false);
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name => "Undo Demo Elements";
-
- ///
- /// Opens the collection and loads elements from disk. Clears the undo history after loading.
- ///
- public override void Open()
- {
- _opening = true;
- // Simulate reading from disk
- // ...
- _opening = false;
- ClearUndoHistory();
- SetIsDirty(false);
- }
-
- ///
- /// Adds an element to the collection with undo support.
- ///
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- ElementList.Add((UndoDemoElement)item);
-
- // Record the add action for undo support (only if not opening)
- if (!_opening)
- {
- RecordAddElement(item);
- SetIsDirty(true);
- }
-
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts an element at the specified index with undo support.
- ///
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- ElementList.Insert(index, (UndoDemoElement)item);
-
- // Record the add action for undo support
- RecordAddElement(item, index);
- SetIsDirty(true);
-
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Removes an element from the collection with undo support.
- ///
- public override bool Remove(IElement item)
- {
- if (item == null) return false;
-
- int index = ElementList.IndexOf((UndoDemoElement)item);
- if (index < 0) return false;
-
- // Record the remove action for undo support before actually removing
- RecordRemoveElement(item);
-
- item.Deleted -= ElementDeleted;
- ElementList.Remove((UndoDemoElement)item);
- SetIsDirty(true);
-
- RaiseElementRemoved(item);
- return true;
- }
-
- ///
- /// Removes the element at the specified index with undo support.
- ///
- public override void RemoveAt(int index)
- {
- if (index < 0 || index >= ElementList.Count) return;
-
- var item = ElementList[index];
-
- // Record the remove action for undo support
- RecordRemoveElement(item);
-
- item.Deleted -= ElementDeleted;
- ElementList.RemoveAt(index);
- SetIsDirty(true);
-
- RaiseElementRemoved(item);
- }
-
- ///
- /// Moves an element within the collection with undo support.
- ///
- /// The current index of the element.
- /// The new index where the element should be moved.
- public void MoveElement(int oldIndex, int newIndex)
- {
- if (oldIndex < 0 || oldIndex >= ElementList.Count) return;
- if (newIndex < 0 || newIndex >= ElementList.Count) return;
- if (oldIndex == newIndex) return;
-
- var item = ElementList[oldIndex];
-
- // Record the move action for undo support
- RecordMoveElement(item, oldIndex, newIndex);
-
- ElementList.RemoveAt(oldIndex);
- ElementList.Insert(newIndex, (UndoDemoElement)item);
- SetIsDirty(true);
- }
-
- ///
- /// Deletes the collection and all its elements.
- ///
- public override void Delete()
- {
- // Clear all elements
- foreach (var element in ElementList)
- {
- element.Delete();
- }
- ElementList.Clear();
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- Insert(index, new UndoDemoElement(elementName, this));
- }
-
- ///
- /// Creates a new demo element with undo support and adds it to the collection.
- ///
- /// The name of the new element.
- /// The newly created element.
- public UndoDemoElement CreateNewElement(string name)
- {
- var element = new UndoDemoElement(name, this);
- Add(element);
- return element;
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml b/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml
deleted file mode 100644
index d7fdd16d..00000000
--- a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- I am a document control
-
-
diff --git a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs b/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs
deleted file mode 100644
index a575de5c..00000000
--- a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control for displaying element documents in the main window.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ElementDocumentControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ElementDocumentControl()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml b/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml
deleted file mode 100644
index b20d8162..00000000
--- a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- Prop 1
- Prop 2
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs b/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs
deleted file mode 100644
index d332dec3..00000000
--- a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control for displaying and editing element properties.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ElementPropertiesControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ElementPropertiesControl()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml b/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml
deleted file mode 100644
index 8183483f..00000000
--- a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml
+++ /dev/null
@@ -1,267 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs b/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs
deleted file mode 100644
index b4caa84f..00000000
--- a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-using System.Collections.Generic;
-using System.Windows;
-using System.Windows.Controls;
-using Themes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control that demonstrates the runtime theme switching capability provided by the Themes library.
- ///
- ///
- ///
- /// This control demonstrates the runtime theme switching capability provided by
- /// the Themes library. It showcases how all WPF controls automatically update
- /// when the theme is changed via .
- ///
- ///
- /// The control includes:
- ///
- ///
- /// - Theme selection radio buttons (Light, Blue, Dark)
- /// - A comprehensive showcase of styled WPF controls
- /// - Sample data for DataGrid demonstration
- ///
- ///
- /// When a theme is selected, the control calls
- /// which in turn updates and loads the appropriate
- /// color palette. All controls using DynamicResource bindings update automatically.
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ThemeDemoControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ThemeDemoControl()
- {
- InitializeComponent();
- LoadSampleData();
- UpdateThemeRadioButton();
-
- Loaded += OnLoaded;
- Unloaded += OnUnloaded;
- }
-
- ///
- /// Handles the Loaded event. Subscribes to theme changes.
- ///
- private void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
- {
- // Subscribe to theme changes to keep UI in sync
- ThemeService.Instance.ThemeChanged += OnThemeChanged;
- }
-
- ///
- /// Handles the Unloaded event. Unsubscribes from theme changes to prevent memory leaks.
- ///
- private void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
- {
- ThemeService.Instance.ThemeChanged -= OnThemeChanged;
- }
-
- ///
- /// Loads sample data into the DataGrid for demonstration purposes.
- ///
- private void LoadSampleData()
- {
- var sampleData = new List
- {
- new SampleDataItem { Name = "Item A", Value = 100, IsActive = true },
- new SampleDataItem { Name = "Item B", Value = 200, IsActive = false },
- new SampleDataItem { Name = "Item C", Value = 150, IsActive = true },
- new SampleDataItem { Name = "Item D", Value = 300, IsActive = true },
- };
- SampleDataGrid.ItemsSource = sampleData;
- }
-
- ///
- /// Updates the radio button selection to match the current theme.
- ///
- private void UpdateThemeRadioButton()
- {
- var currentTheme = ThemeService.Instance.CurrentTheme;
- switch (currentTheme)
- {
- case Theme.Light:
- LightThemeRadio.IsChecked = true;
- break;
- case Theme.Blue:
- BlueThemeRadio.IsChecked = true;
- break;
- case Theme.Dark:
- DarkThemeRadio.IsChecked = true;
- break;
- }
- UpdateCurrentThemeText(currentTheme);
- }
-
- ///
- /// Updates the current theme display text.
- ///
- /// The current theme.
- private void UpdateCurrentThemeText(Theme theme)
- {
- CurrentThemeText.Text = $"Current Theme: {theme}";
- }
-
- ///
- /// Handles theme changes from external sources.
- ///
- /// The event sender.
- /// The theme changed event arguments.
- private void OnThemeChanged(object sender, ThemeChangedEventArgs e)
- {
- // Update the radio button and text when theme changes externally
- Dispatcher.Invoke(() =>
- {
- switch (e.NewTheme)
- {
- case Theme.Light:
- LightThemeRadio.IsChecked = true;
- break;
- case Theme.Blue:
- BlueThemeRadio.IsChecked = true;
- break;
- case Theme.Dark:
- DarkThemeRadio.IsChecked = true;
- break;
- }
- UpdateCurrentThemeText(e.NewTheme);
- });
- }
-
- ///
- /// Handles the Checked event of theme radio buttons.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void ThemeRadio_Checked(object sender, RoutedEventArgs e)
- {
- if (sender is RadioButton radio && radio.IsChecked == true)
- {
- FrameworkUI.ThemeColor themeColor;
-
- if (radio == LightThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Light;
- }
- else if (radio == BlueThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Blue;
- }
- else if (radio == DarkThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Dark;
- }
- else
- {
- return;
- }
-
- // Use FrameworkUI.ThemeManager which bridges to ThemeService
- // This ensures both Themes library colors AND ProjectUI-specific resources are updated
- FrameworkUI.ThemeManager.SetTheme(themeColor);
- }
- }
-
- ///
- /// Sample data class for DataGrid demonstration.
- ///
- private class SampleDataItem
- {
- ///
- /// Gets or sets the name of the sample data item.
- ///
- public string Name { get; set; }
-
- ///
- /// Gets or sets the numeric value of the sample data item.
- ///
- public int Value { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the item is active.
- ///
- public bool IsActive { get; set; }
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml b/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml
deleted file mode 100644
index fe3a3ee1..00000000
--- a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs b/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs
deleted file mode 100644
index f8a9a4fd..00000000
--- a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-using System;
-using System.Windows;
-using System.Windows.Controls;
-using FrameworkInterfaces.Undo;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// A demo user control that showcases the undo/redo functionality with interactive UI elements and stack visualization.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class UndoDemoControl : UserControl
- {
- private UndoDemoElement _element;
- private bool _isUpdatingUI = false;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public UndoDemoControl()
- {
- InitializeComponent();
- }
-
- ///
- /// Gets or sets the element being edited.
- ///
- public UndoDemoElement Element
- {
- get => _element;
- set
- {
- if (_element != null)
- {
- _element.PropertyChanged -= Element_PropertyChanged;
- }
- _element = value;
- if (_element != null)
- {
- _element.PropertyChanged += Element_PropertyChanged;
- RefreshUI();
- }
- }
- }
-
- ///
- /// Handles the PropertyChanged event of the element to refresh the UI.
- ///
- /// The event sender.
- /// The property changed event arguments.
- private void Element_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
- {
- if (!_isUpdatingUI)
- {
- RefreshUI();
- }
- }
-
- ///
- /// Refreshes the UI to reflect the current element state.
- ///
- private void RefreshUI()
- {
- if (_element == null) return;
-
- _isUpdatingUI = true;
- try
- {
- NameTextBox.Text = _element.Name ?? string.Empty;
- CustomValueTextBox.Text = _element.CustomValue ?? string.Empty;
- NumericValueTextBox.Text = _element.NumericValue.ToString();
- BooleanValueCheckBox.IsChecked = _element.BooleanValue;
- DateValuePicker.SelectedDate = _element.DateValue;
-
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- finally
- {
- _isUpdatingUI = false;
- }
- }
-
- ///
- /// Updates the undo/redo stack displays.
- ///
- private void RefreshUndoRedoStacks()
- {
- UndoStackListBox.Items.Clear();
- RedoStackListBox.Items.Clear();
-
- if (_element?.UndoManager == null) return;
-
- var undoStack = _element.UndoManager.UndoStack;
- var redoStack = _element.UndoManager.RedoStack;
-
- foreach (var action in undoStack)
- {
- UndoStackListBox.Items.Add(action.Description);
- }
-
- foreach (var action in redoStack)
- {
- RedoStackListBox.Items.Add(action.Description);
- }
- }
-
- ///
- /// Updates the enabled state of the undo/redo buttons.
- ///
- private void UpdateButtonStates()
- {
- UndoButton.IsEnabled = _element?.UndoManager?.CanUndo == true;
- RedoButton.IsEnabled = _element?.UndoManager?.CanRedo == true;
- }
-
- ///
- /// Handles text property changes for the name and custom value text boxes.
- ///
- /// The event sender.
- /// The text changed event arguments.
- private void Property_Changed(object sender, TextChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- var textBox = sender as TextBox;
- if (textBox == NameTextBox)
- {
- _element.Name = textBox.Text;
- }
- else if (textBox == CustomValueTextBox)
- {
- _element.CustomValue = textBox.Text;
- }
-
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
-
- ///
- /// Handles numeric property changes for the numeric value text box.
- ///
- /// The event sender.
- /// The text changed event arguments.
- private void NumericProperty_Changed(object sender, TextChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- if (int.TryParse(NumericValueTextBox.Text, out int value))
- {
- _element.NumericValue = value;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-
- ///
- /// Handles boolean property changes for the checkbox.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void BooleanProperty_Changed(object sender, RoutedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- _element.BooleanValue = BooleanValueCheckBox.IsChecked == true;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
-
- ///
- /// Handles date property changes for the date picker.
- ///
- /// The event sender.
- /// The selection changed event arguments.
- private void DateProperty_Changed(object sender, SelectionChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- if (DateValuePicker.SelectedDate.HasValue)
- {
- _element.DateValue = DateValuePicker.SelectedDate.Value;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-
- ///
- /// Handles the undo button click to undo the last change.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void UndoButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element?.UndoManager?.CanUndo == true)
- {
- _element.UndoManager.Undo();
- RefreshUI();
- }
- }
-
- ///
- /// Handles the redo button click to redo the last undone change.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void RedoButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element?.UndoManager?.CanRedo == true)
- {
- _element.UndoManager.Redo();
- RefreshUI();
- }
- }
-
- ///
- /// Handles the batch update button click to perform multiple changes in a single transaction.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void BatchUpdateButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element == null) return;
-
- // Demonstrate batch update - all changes will be undone together
- _element.PerformBatchUpdate(
- "Batch Updated Value",
- 999,
- !_element.BooleanValue
- );
-
- RefreshUI();
- }
-
- ///
- /// Handles the clear history button click to clear the undo/redo history.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void ClearHistoryButton_Click(object sender, RoutedEventArgs e)
- {
- _element?.UndoManager?.Clear();
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-}
From 055b8ca0081f3915dbcb188d47d106be6d609ad2 Mon Sep 17 00:00:00 2001
From: Haden Smith <123974306+HadenSmith@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:02:07 +0000
Subject: [PATCH 02/13] Add WPF Framework Demo app identity and USACE castle
icon
Set the assembly title, product name, and version to WPF Framework
Demo 1.0.0 so the framework shell reports the correct software name.
Generate a multi-resolution application icon from the USACE castle
logo, use it as the project image, and standardize the project file
extension on .demo.
---
src/FrameworkUI.Demo/App.xaml.cs | 4 +--
src/FrameworkUI.Demo/FrameworkUI.Demo.csproj | 7 +++++
src/FrameworkUI.Demo/Model/DemoProject.cs | 26 ++++++++++++++----
.../Resources/FrameworkUIDemo.ico | Bin 0 -> 27159 bytes
4 files changed, 30 insertions(+), 7 deletions(-)
create mode 100644 src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico
diff --git a/src/FrameworkUI.Demo/App.xaml.cs b/src/FrameworkUI.Demo/App.xaml.cs
index 7a8e37b4..26481606 100644
--- a/src/FrameworkUI.Demo/App.xaml.cs
+++ b/src/FrameworkUI.Demo/App.xaml.cs
@@ -104,8 +104,8 @@ private void Application_Startup(object sender, StartupEventArgs e)
// ThemeManager bridges to ThemeService and loads ProjectUI-specific resources
FrameworkUI.ThemeManager.SetTheme(FrameworkUI.ThemeColor.Light);
- FrameworkUI.ShellPublicVariables.SoftwareVersionDate = "December 2025";
- FrameworkUI.ShellPublicVariables.SoftwareExtension = ".fun";
+ FrameworkUI.ShellPublicVariables.SoftwareVersionDate = "July 2026";
+ FrameworkUI.ShellPublicVariables.SoftwareExtension = ".demo";
FrameworkUI.UserSettings.CreateAutoRecoverBackup = false;
// Create the project model. Use TryFindResource so a missing TreeViewItemStyle
diff --git a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
index c716ad90..96b9f140 100644
--- a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
+++ b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
@@ -8,6 +8,12 @@
FrameworkUI.Demo
A demonstration application for the FrameworkUI WPF framework, showcasing project management, AvalonDock layout, theming, and standard controls.
U.S. Army Corps of Engineers
+ WPF Framework Demo
+ WPF Framework Demo
+ 1.0.0
+ 1.0.0.0
+ 1.0.0.0
+ Resources\FrameworkUIDemo.ico
disable
true
enable
@@ -47,6 +53,7 @@
+
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index 227a48d2..1e2bf970 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -193,11 +193,27 @@ public override string Description
}
///
- [Category("Meta Data"), DisplayName("Software Version"), Description("The version of RMC-TotalRisk used to last modify the project file."), Browsable(true)]
- public override string SoftwareVersion => "1.0";
+ [Category("Meta Data"), DisplayName("Software Version"), Description("The version of WPF Framework Demo used to last modify the project file."), Browsable(true)]
+ public override string SoftwareVersion => "1.0.0";
+
+ ///
+ /// Lazy-loaded project icon. Returns null if the icon resource cannot be loaded
+ /// so a missing resource degrades gracefully instead of crashing the explorer tree.
+ ///
+ private static readonly Lazy s_projectIcon = new(() =>
+ {
+ try
+ {
+ var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/FrameworkUIDemo.ico"));
+ img.Freeze();
+ return img;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ });
- ///
- private static readonly Lazy s_projectIcon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
///
public override ImageSource ProjectImage => s_projectIcon.Value;
@@ -301,7 +317,7 @@ public override void Save()
if (winExpName != Name && Name != "Blank Project")
{
- var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ".fun");
+ var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ShellPublicVariables.SoftwareExtension);
try
{
File.Move(winExpFullFileName, newFullFileName);
diff --git a/src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico b/src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico
new file mode 100644
index 0000000000000000000000000000000000000000..d2bbd3f51ba409ce316b89919f52cf8f20d3e3fe
GIT binary patch
literal 27159
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
Ccw!0w
literal 0
HcmV?d00001
From 9522137396aa3ab08dd17d9aafc9a3463a63ccdc Mon Sep 17 00:00:00 2001
From: Haden Smith <123974306+HadenSmith@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:13:29 +0000
Subject: [PATCH 03/13] Add SQLite persistence, element-owned plotting, and
plot undo to the demo
Implement full project and element persistence following the parametric
hazard function conventions: a Project table with additive column
migration, a Hazard Functions table keyed by element name, G17 invariant
formatting for doubles, pipe-delimited probability ordinates, the parent
distribution as XML, and the bootstrap results as a compressed BLOB so
parameter sets survive reopening.
Move ownership of the frequency plot from the view to the hazard
element so plot styling can be recorded for undo and persisted with the
element. Port the PlotUndoManager, AggregateDisposable, and
CollectionPersistenceHelper support classes, wire ordinate and plot
undo bridges through SetupBridges, and refactor HazardControl to host
the element-owned plot in a ContentControl with series looked up by
name so user styling survives Save/Open.
Also fix latent bugs: NameOnDisk, CreationDate, and LastModified were
get-only auto-property overrides that never read the base backing
fields, project name validation could never become valid, and the
control's plot click handlers were never wired in XAML.
---
src/FrameworkUI.Demo/Model/DemoProject.cs | 511 +++++++++++---
.../Model/Hazard Elements/HazardElement.cs | 634 +++++++++++++++---
.../HazardElementCollection.cs | 184 ++++-
.../Model/Support/AggregateDisposable.cs | 52 ++
.../Support/CollectionPersistenceHelper.cs | 97 +++
.../Model/Support/PlotUndoManager.cs | 549 +++++++++++++++
src/FrameworkUI.Demo/UI/DemoProjectNode.cs | 1 -
.../UI/Hazard Controls/HazardControl.xaml | 84 +--
.../UI/Hazard Controls/HazardControl.xaml.cs | 216 +++---
9 files changed, 1909 insertions(+), 419 deletions(-)
create mode 100644 src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs
create mode 100644 src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs
create mode 100644 src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index 1e2bf970..a77ceecd 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -5,10 +5,10 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
+using System.Data;
+using System.Data.SQLite;
using System.Windows.Media.Imaging;
using System.IO;
-using System.IO.Compression;
-using System.Reflection;
using System.Windows.Media;
@@ -52,10 +52,10 @@ private DemoProject()
_hazardFunctions = new HazardElementCollection(this);
- _hazardFunctions.ObjectSaved += ElementCollection_Saved;
-
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
{ _hazardFunctions });
+
+ SubscribeCollectionEvents();
}
///
@@ -125,41 +125,7 @@ public override string Name
item.SourceName = value;
}
- // Check if name is nothing.
- if (string.IsNullOrEmpty(Name))
- {
- _nameValid = false;
- _messenger.Add(_noNameMsg);
- }
- else
- {
- _messenger.Remove(_noNameMsg);
- }
-
- // Check the length of the name.
- if (Name.Length > 50)
- {
- _nameValid = false;
- _messenger.Add(_longNameMsg);
- }
- else
- {
- _messenger.Remove(_longNameMsg);
- }
-
- // Check if there are bad characters.
- _messenger.Remove(_badCharMsg);
- foreach (char badChar in DemoProject.InvalidNameCharacters)
- {
- if (Name.Contains(badChar))
- {
- _nameValid = false;
- string badCharacters = "<>:" + (char)34 + "/\\|?*";
- _badCharMsg.Description = $"Invalid character in project name: '{badChar}'. Invalid characters are: {badCharacters}";
- _messenger.Add(_badCharMsg);
- break;
- }
- }
+ ValidateName();
RecordPropertyChange(nameof(Name), oldValue, value);
}
@@ -227,6 +193,190 @@ public override string Description
public static char[] InvalidNameCharacters => _InvalidNameCharacters;
+ #endregion
+
+ #region Validation
+
+ ///
+ /// Validates the project name against all naming rules and updates validation messages.
+ ///
+ ///
+ /// Checks that the name is not blank, does not exceed 50 characters, and contains
+ /// no invalid file name characters.
+ ///
+ private void ValidateName()
+ {
+ _nameValid = true;
+
+ // Check if name is nothing.
+ if (string.IsNullOrEmpty(Name))
+ {
+ _nameValid = false;
+ _messenger.Add(_noNameMsg);
+ }
+ else
+ {
+ _messenger.Remove(_noNameMsg);
+ }
+
+ // Check the length of the name.
+ if (Name != null && Name.Length > 50)
+ {
+ _nameValid = false;
+ _messenger.Add(_longNameMsg);
+ }
+ else
+ {
+ _messenger.Remove(_longNameMsg);
+ }
+
+ // Check if there are bad characters.
+ _messenger.Remove(_badCharMsg);
+ if (Name != null)
+ {
+ foreach (char badChar in DemoProject.InvalidNameCharacters)
+ {
+ if (Name.Contains(badChar))
+ {
+ _nameValid = false;
+ string badCharacters = "<>:" + (char)34 + "/\\|?*";
+ _badCharMsg.Description = $"Invalid character in project name: '{badChar}'. Invalid characters are: {badCharacters}";
+ _messenger.Add(_badCharMsg);
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ public override bool IsValid()
+ {
+ if (!_nameValid) return false;
+ return true;
+ }
+
+ #endregion
+
+ #region Collection Events
+
+ ///
+ /// Subscribes to the per-collection events that drive project-level save cascades
+ /// and dirty aggregation.
+ ///
+ private void SubscribeCollectionEvents()
+ {
+ _hazardFunctions.ObjectSaved += ElementCollection_Saved;
+ _hazardFunctions.ElementIsDirtyChanged += OnElementIsDirtyChanged;
+ _hazardFunctions.PropertyChanged += OnCollectionPropertyChanged;
+ }
+
+ ///
+ /// Unsubscribes from every per-collection event subscribed by .
+ ///
+ private void UnsubscribeCollectionEvents()
+ {
+ _hazardFunctions.ObjectSaved -= ElementCollection_Saved;
+ _hazardFunctions.ElementIsDirtyChanged -= OnElementIsDirtyChanged;
+ _hazardFunctions.PropertyChanged -= OnCollectionPropertyChanged;
+ }
+
+ ///
+ /// Aggregates element-level dirty transitions into the project-level
+ /// flag so the Save button reflects unsaved child edits.
+ ///
+ /// The event sender.
+ /// The event arguments.
+ private void OnElementIsDirtyChanged(object sender, EventArgs e)
+ {
+ if (_openingProject) return;
+ SetIsDirty(true);
+ }
+
+ ///
+ /// Aggregates collection-level dirty transitions (structural changes such as
+ /// add, remove, move, and sort) into the project-level flag.
+ ///
+ /// The event sender.
+ /// The property changed event arguments.
+ private void OnCollectionPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (_openingProject) return;
+ if (e.PropertyName != nameof(IElementCollection.IsDirty)) return;
+ if (sender is IElementCollection collection && collection.IsDirty)
+ SetIsDirty(true);
+ }
+
+ #endregion
+
+ #region SQLite Persistence
+
+ ///
+ /// The name of the SQLite table storing the project meta data.
+ ///
+ private readonly string _tableName = "Project";
+
+ ///
+ /// Gets the required columns for the SQLite project table.
+ ///
+ ///
+ /// If you want to add a new column, add it to the end of the dictionary to maintain
+ /// backward compatibility with existing project files.
+ ///
+ private static Dictionary RequiredColumns { get; } = new Dictionary() {
+ { nameof(Name), typeof(string) },
+ { nameof(Description), typeof(string) },
+ { nameof(CreationDate), typeof(string) },
+ { nameof(LastModified), typeof(string) },
+ { nameof(FullFileName), typeof(string) },
+ { nameof(SoftwareVersion), typeof(string) },
+ { nameof(AvalonDockLayout), typeof(string) },
+ { nameof(ProjectExplorerLayout), typeof(string) } };
+
+ ///
+ /// Creates or updates the SQLite project table with required columns.
+ ///
+ /// The SQLite database manager instance.
+ ///
+ /// If the table doesn't exist, it is created. If the table exists but is missing
+ /// columns, the missing columns are added. This supports forward compatibility
+ /// when opening older project files.
+ ///
+ private void CreateTable(SQLiteManager sqlite)
+ {
+ if (sqlite.TableNames.Contains(_tableName) == false)
+ {
+ // If the table does not exist, then create the table
+ var dataTable = new DataTable(_tableName);
+ foreach (KeyValuePair column in RequiredColumns)
+ dataTable.Columns.Add(column.Key, column.Value);
+ sqlite.SaveDataTable(dataTable);
+ }
+ else
+ {
+ // Add any required columns that don't exist
+ var dt = sqlite.GetTableManager(_tableName);
+ int columnIndex;
+ foreach (KeyValuePair column in RequiredColumns)
+ {
+ columnIndex = Array.IndexOf(dt.ColumnNames, column.Key);
+ // If the column doesn't exist in the database then create it.
+ if (columnIndex < 0)
+ {
+ dt.AddColumn(column.Key, column.Value);
+ }
+ else
+ {
+ if (dt.ColumnTypes[columnIndex] != column.Value)
+ {
+ dt.DeleteColumn(columnIndex);
+ dt.AddColumn(column.Key, column.Value);
+ }
+ }
+ }
+ dt.ApplyEdits();
+ }
+ }
+
#endregion
#region Methods
@@ -234,18 +384,27 @@ public override string Description
///
public override void CreateNew(string fullFileName)
{
- // Set project meta data & properties
- FullFileName = fullFileName;
- Name = Path.GetFileNameWithoutExtension(fullFileName);
- Description = "";
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
-
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions });
-
- Save();
+ IsUndoEnabled = false;
+ try
+ {
+ // Set project meta data & properties
+ FullFileName = fullFileName;
+ Name = Path.GetFileNameWithoutExtension(fullFileName);
+ Description = "";
+ CreationDate = DateTime.Now;
+ LastModified = DateTime.Now;
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+
+ Save();
+ }
+ finally
+ {
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ }
}
///
@@ -253,18 +412,27 @@ public override void CreateNew(string fullFileName)
///
public void CreateNewDummyProject()
{
- // Set project meta data and properties
- FullFileName = System.IO.Path.GetTempPath() + Guid.NewGuid().ToString() + ".demo";
- Name = "Blank Project";
- Description = "This is a blank project file.";
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
-
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions });
-
- Save();
+ IsUndoEnabled = false;
+ try
+ {
+ // Set project meta data and properties
+ FullFileName = System.IO.Path.GetTempPath() + Guid.NewGuid().ToString() + ShellPublicVariables.SoftwareExtension;
+ Name = "Blank Project";
+ Description = "This is a blank project file.";
+ CreationDate = DateTime.Now;
+ LastModified = DateTime.Now;
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+
+ Save();
+ }
+ finally
+ {
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ }
}
///
@@ -272,24 +440,89 @@ public override void Open()
{
_openingProject = true;
IsUndoEnabled = false;
+ SetIsDirty(false);
+ try
+ {
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Open();
+
+ // The user might have renamed the SQLite file from Windows Explorer.
+ // In this case, the FullFileName will not match the meta data stored within
+ // the database. Check if the values are different, and if so, update the
+ // meta data table.
+ string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
+ string winExpFullFileName = FullFileName;
+
+ var dtView = sqlite.GetTableManager(_tableName);
+ if (dtView.NumberOfRows != 1)
+ {
+ sqlite.Close();
+ }
+ else
+ {
+ // Get name and full file name using backing fields to avoid undo recording
+ if (dtView.ColumnNames.Contains(nameof(Name))) _name = dtView.GetCell(nameof(Name), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(FullFileName))) _fullFileName = dtView.GetCell(nameof(FullFileName), 0).ToString();
- //
- // Load from disk or database
- //
+ // Reconcile a Windows Explorer rename with the stored meta data.
+ if (FullFileName != winExpFullFileName)
+ {
+ FullFileName = winExpFullFileName;
+ dtView.EditCell(0, nameof(FullFileName), winExpFullFileName);
+ dtView.ApplyEdits();
+ }
+ if (_name != winExpName)
+ {
+ _name = winExpName;
+ foreach (var item in _messages)
+ item.SourceName = _name;
+ ValidateName();
+ dtView.EditCell(0, nameof(Name), winExpName);
+ dtView.ApplyEdits();
+ }
- // Open element collections.
- _hazardFunctions.Open();
+ // Get the rest of the properties using backing fields
+ if (dtView.ColumnNames.Contains(nameof(Description))) _description = dtView.GetCell(nameof(Description), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(CreationDate))) CreationDate = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(CreationDate), 0).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(LastModified))) LastModified = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(LastModified), 0).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(AvalonDockLayout))) AvalonDockLayout = dtView.GetCell(nameof(AvalonDockLayout), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(ProjectExplorerLayout))) _projectExplorerLayout = dtView.GetCell(nameof(ProjectExplorerLayout), 0).ToString();
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions });
+ // Opening from disk is not a layout edit; clear the flag that the
+ // AvalonDockLayout setter above may have just flipped.
+ LayoutDirty = false;
- NameOnDisk = Name;
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
- _openingProject = false;
+ sqlite.Close();
+ }
+
+ // Validate description message
+ if (string.IsNullOrEmpty(_description))
+ _messenger.Add(_descriptionMsg);
+ else
+ _messenger.Remove(_descriptionMsg);
+
+ // Open element collections.
+ _hazardFunctions.Open();
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+
+ NameOnDisk = Name;
+
+ // Raise PropertyChanged for properties loaded via backing fields
+ // so that WPF bindings (e.g., ProjectNode header) update correctly.
+ RaisePropertyChange(nameof(Name), false);
+ RaisePropertyChange(nameof(Description), false);
+ }
+ finally
+ {
+ _openingProject = false;
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ SetIsDirty(false);
+ }
}
///
@@ -302,6 +535,16 @@ public override void Close()
}
///
+ ///
+ /// Two paths are possible:
+ ///
+ /// - Layout-only save (IsDirty == false and LayoutDirty == true):
+ /// only the two layout columns are rewritten and LastModified is not stamped, so
+ /// window-layout changes persist without drifting the project's modification timestamp.
+ /// - Full save (all other cases): project row, layout, and element
+ /// collections are all written.
+ ///
+ ///
public override void Save()
{
bool cancel = false;
@@ -311,50 +554,89 @@ public override void Save()
// Must have a file name to save.
if (FullFileName == null) return;
- // Check if the file name needs to be renamed
- string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
- string winExpFullFileName = FullFileName;
+ bool layoutOnly = !IsDirty && LayoutDirty;
- if (winExpName != Name && Name != "Blank Project")
+ // Filename-rename check is meaningful only when we are writing project meta.
+ if (!layoutOnly)
{
- var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ShellPublicVariables.SoftwareExtension);
- try
- {
- File.Move(winExpFullFileName, newFullFileName);
- FullFileName = newFullFileName;
- }
- catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is FileNotFoundException)
+ string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
+ string winExpFullFileName = FullFileName;
+
+ if (winExpName != Name && Name != "Blank Project")
{
- FrameworkInterfaces.Messaging.Messenger.GetInstance().Add(
- new BasicMessageItem(FrameworkInterfaces.MessageType.Warning,
+ var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ShellPublicVariables.SoftwareExtension);
+ try
+ {
+ File.Move(winExpFullFileName, newFullFileName);
+ FullFileName = newFullFileName;
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is FileNotFoundException)
+ {
+ _messenger.Add(new BasicMessageItem(MessageType.Warning,
$"Failed to rename project file: {ex.Message}",
this, "DemoProject", Name, "Save"));
- return;
+ return;
+ }
}
}
- // Update last edited
- _lastModified = DateTime.Now;
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (layoutOnly)
+ {
+ // Layout-only save: only persist the two layout columns.
+ // Do not stamp LastModified, do not iterate element collections.
+ CreateTable(sqlite);
+ var layoutView = sqlite.GetTableManager(_tableName);
+ if (layoutView.NumberOfRows == 0) layoutView.AddRow();
+ layoutView.EditCell(0, nameof(AvalonDockLayout), AvalonDockLayout ?? string.Empty);
+ layoutView.EditCell(0, nameof(ProjectExplorerLayout), ProjectExplorerLayout ?? string.Empty);
+ layoutView.ApplyEdits();
+ sqlite.Close();
+ LayoutDirty = false;
+ return;
+ }
- // Save project meta data
+ // Only update LastModified if user data has actually changed at the
+ // project level. Saves triggered solely by dirty children end up
+ // stamping LastModified via the ElementCollection_Saved handler.
+ if (IsDirty)
+ LastModified = DateTime.Now;
+ CreateTable(sqlite);
+
+ var dtView = sqlite.GetTableManager(_tableName);
+ if (dtView.NumberOfRows == 0) dtView.AddRow();
+ dtView.EditCell(0, nameof(Name), Name);
+ dtView.EditCell(0, nameof(Description), Description);
+ dtView.EditCell(0, nameof(CreationDate), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(CreationDate));
+ dtView.EditCell(0, nameof(LastModified), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(LastModified));
+ dtView.EditCell(0, nameof(FullFileName), FullFileName);
+ dtView.EditCell(0, nameof(SoftwareVersion), SoftwareVersion);
+ dtView.EditCell(0, nameof(AvalonDockLayout), AvalonDockLayout ?? string.Empty);
+ dtView.EditCell(0, nameof(ProjectExplorerLayout), ProjectExplorerLayout ?? string.Empty);
+ dtView.ApplyEdits();
+
+ // Next, save element collections
+ for (int i = 0; i < ElementCollections.Count; i++)
+ {
+ ElementCollections[i].Save();
+ }
- // Next, save element collections
- for (int i = 0; i < ElementCollections.Count; i++)
+ sqlite.Close();
+ NameOnDisk = Name;
+ SetIsDirty(false);
+ LayoutDirty = false;
+ MarkUndoSavePoint();
+ RaiseObjectSaved(this);
+ }
+ finally
{
- ElementCollections[i].Save();
+ // Defensive close — handles the case where any of the dtView edits,
+ // child collection saves, or post-success state changes throw.
+ if (sqlite.DataBaseOpen) sqlite.Close();
}
-
- NameOnDisk = Name;
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
- }
-
- ///
- public override bool IsValid()
- {
- if (!_nameValid) return false;
- return true;
}
#region Compact and Optimize Project File
@@ -362,13 +644,22 @@ public override bool IsValid()
///
public override void Compact()
{
- // Compact or vacuum SQLite database to reduce file size.
+ // A basic connection is set so the size of the "-journal" temp file can be obtained.
+ var sqlite = new SQLiteManager(FullFileName);
+ var connectionBuilder = new SQLiteConnectionStringBuilder
+ {
+ Version = 3,
+ DataSource = FullFileName
+ };
+ sqlite.SetDatabaseConnection(connectionBuilder);
+ sqlite.Vacuum();
}
///
public override void Optimize()
{
- // Optimize the SQLite database for better performance.
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Optimize();
}
#endregion
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
index 9da02535..b34c8aa5 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
@@ -1,14 +1,20 @@
-using FrameworkInterfaces;
+using DatabaseManager;
+using FrameworkInterfaces;
using FrameworkInterfaces.Messaging;
using FrameworkInterfaces.Undo;
using Numerics.Distributions;
using Numerics.Data;
+using OxyPlot.Wpf;
+using OxyPlot.Wpf.Serialization;
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
+using System.Data;
+using System.Globalization;
using System.Windows.Media;
using System.Windows.Media.Imaging;
+using System.Xml.Linq;
namespace FrameworkUI.Demo
{
@@ -40,39 +46,46 @@ public class HazardElement : ElementBase, IUndoableElement
/// Optional parameter to open the function from disk upon construction.
public HazardElement(string name, IElementCollection parentCollection, bool openFromFile = false) : base(name, parentCollection)
{
- Name = name;
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
+ IsUndoEnabled = false;
+ try
+ {
+ Name = name;
+ _nameOnDisk = name;
+ _creationDate = DateTime.Now;
+ _lastModified = DateTime.Now;
- ProbabilityOrdinates = new ProbabilityOrdinates();
-
+ // The default constructor seeds the standard 25 probability ordinates.
+ _probabilityOrdinates.CollectionChanged += ProbabilityOrdinates_CollectionChanged;
- // Create the undo bridge for collection changes
- // Note: Use UndoManager property (not _undoManager field) to ensure lazy initialization
- _ordinatesBridge = new UndoableCollectionBridge(
- ProbabilityOrdinates,
- () => IsUndoEnabled ? UndoManager : null,
- "probability ordinates",
- this
- );
+ // Create the element-owned frequency plot. The plot object lives on the
+ // element so its visual state can be tracked for undo and persisted.
+ _frequencyPlot = CreateDefaultFrequencyPlot();
+ InitializeMessages();
- InitializeMessages();
+ if (openFromFile)
+ {
+ Open();
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ }
+ else
+ {
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ _messenger.Add(_descriptionMsg);
+ _messenger.Add(_estimatedMsg);
+ }
- if (openFromFile)
- {
- Open();
- _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ SetElementValidation();
+ SetIsDirty(false);
}
- else
+ finally
{
- _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
- _messenger.Add(_descriptionMsg);
- _messenger.Add(_estimatedMsg);
+ // Open() creates the bridges itself; only create them here when
+ // constructing a fresh element.
+ if (!openFromFile) SetupBridges();
+ IsUndoEnabled = true;
+ ClearUndoHistory();
}
-
- SetElementValidation();
- SetIsDirty(false);
}
///
@@ -202,11 +215,11 @@ public override string Description
///
[Category("Meta Data"), DisplayName("Creation Date"), Description("The date and time this hazard function was first created."), Browsable(true)]
- public override DateTime CreationDate { get; }
+ public override DateTime CreationDate => _creationDate;
///
[Category("Meta Data"), DisplayName("Last Edited"), Description("The date and time this hazard function was last modified."), Browsable(true)]
- public override DateTime LastModified { get; }
+ public override DateTime LastModified => _lastModified;
#endregion
@@ -221,7 +234,7 @@ public override string Description
public override bool CanCopyFromExternal => true;
///
- public override string NameOnDisk { get; }
+ public override string NameOnDisk => _nameOnDisk;
///
public override bool IsValid => _isValid;
@@ -238,7 +251,8 @@ public override string Description
private ParameterEstimationMethod _estimationMethod = ParameterEstimationMethod.MethodOfMoments;
private ProbabilityOrdinates _probabilityOrdinates = new ProbabilityOrdinates();
private UndoableCollectionBridge _ordinatesBridge;
- private string _plotSettings;
+ private Plot _frequencyPlot;
+ private PlotUndoManager _frequencyPlotUndo;
private bool _isEstimated = false;
private bool _minmaxComputed = false;
private double[] _minmax = new double[2];
@@ -485,40 +499,19 @@ public ParameterEstimationMethod EstimationMethod
}
///
- /// Gets and sets the exceedance probability values used for plotting the distribution.
+ /// Gets the exceedance probability values used for plotting the distribution.
+ /// The collection is mutated in place (e.g., via the ordinates table or
+ /// );
+ /// edits are recorded for undo through the collection bridge.
///
- public ProbabilityOrdinates ProbabilityOrdinates
- {
- get => _probabilityOrdinates;
- set
- {
- if (_probabilityOrdinates != null)
- _probabilityOrdinates.CollectionChanged -= ProbabilityOrdinates_CollectionChanged;
-
- _probabilityOrdinates = value ?? new ProbabilityOrdinates();
-
- _probabilityOrdinates.CollectionChanged += ProbabilityOrdinates_CollectionChanged;
-
- RaisePropertyChange(nameof(ProbabilityOrdinates));
- }
- }
+ public ProbabilityOrdinates ProbabilityOrdinates => _probabilityOrdinates;
///
- /// Gets and sets the plot settings.
+ /// Gets the element-owned frequency plot. The view hosts this plot directly, so
+ /// user styling changes are recorded for undo through the plot undo manager and
+ /// persisted with the element.
///
- public string PlotSettings
- {
- get => _plotSettings;
- set
- {
- if (_plotSettings != value)
- {
- var oldValue = _plotSettings;
- _plotSettings = value;
- RecordPropertyChange(nameof(PlotSettings), oldValue, value);
- }
- }
- }
+ public Plot FrequencyPlot => _frequencyPlot;
///
/// Determines whether the distribution has been bootstrapped.
@@ -565,29 +558,34 @@ private set
///
public override IElement Copy(string newName = null)
{
- var element = new HazardElement(newName ?? Name, ParentCollection)
- {
- Description = Description,
- ParentDistribution = ParentDistribution.Clone(),
- IsUncertain = IsUncertain,
- EffectiveRecordLength = EffectiveRecordLength,
- ConfidenceIntervalWidth = ConfidenceIntervalWidth,
- Realizations = Realizations,
- PRNGSeed = PRNGSeed,
- EstimationMethod = EstimationMethod
- };
+ var element = new HazardElement(newName ?? Name, ParentCollection);
- // Disable undo recording while copying collection data
+ // Disable undo recording while copying data
element.IsUndoEnabled = false;
- element._probabilityOrdinates.Clear();
- foreach (var p in ProbabilityOrdinates)
+ try
{
- element._probabilityOrdinates.Add(p);
+ element.Description = Description;
+ element.ParentDistribution = ParentDistribution.Clone();
+ element.IsUncertain = IsUncertain;
+ element.EffectiveRecordLength = EffectiveRecordLength;
+ element.ConfidenceIntervalWidth = ConfidenceIntervalWidth;
+ element.Realizations = Realizations;
+ element.PRNGSeed = PRNGSeed;
+ element.EstimationMethod = EstimationMethod;
+
+ element._probabilityOrdinates.Clear();
+ element._probabilityOrdinates.AddRange(ProbabilityOrdinates);
+ element._isEstimated = false;
+
+ // Copy the plot visual state via an XElement round-trip.
+ PlotSerializer.FromXElement(element._frequencyPlot, PlotSerializer.ToXElement(_frequencyPlot));
+ }
+ finally
+ {
+ element.SetupBridges();
+ element.IsUndoEnabled = true;
+ element.ClearUndoHistory();
}
- element._plotSettings = _plotSettings;
- element._isEstimated = false;
- element.IsUndoEnabled = true;
- element.ClearUndoHistory();
return element;
}
@@ -595,37 +593,188 @@ public override IElement Copy(string newName = null)
///
public override IElement CopyFromExternal(string itemName, string fullFileName)
{
- // Copy from external file
- return null;
+ // Create SQLite connection to the external project file
+ var sqlite = new SQLiteManager(fullFileName);
+ var element = new HazardElement(itemName, ParentCollection);
+ element.Open(sqlite);
+ return element;
}
///
public override void Delete()
{
+ if (Name == null) return;
+ DisposeBridges();
+ SetIsDirty(false);
+
+ var sqlite = new SQLiteManager(ParentCollection.ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(ParentCollection.Name))
+ {
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex >= 0 && rowIndex < dtView.NumberOfRows) dtView.DeleteRow(rowIndex);
+ dtView.ApplyEdits();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
+
_messenger.Clear(this);
_undoManager.Clear();
- _ordinatesBridge?.Dispose();
- SetIsDirty(false);
- //
- // Delete from disk or database
- //
RaiseDeleted(this);
}
///
public override void Open()
{
- _messenger.Clear(this);
- IsUndoEnabled =false;
+ Open(new SQLiteManager(ParentCollection.ParentProject.FullFileName));
+ }
- //
- // Load from disk or database
- //
+ ///
+ /// Opens the element from disk using the specified SQLite manager.
+ ///
+ /// The SQLite manager to use for opening the element.
+ public void Open(SQLiteManager sqlite)
+ {
+ var wasUndoEnabled = IsUndoEnabled;
+ IsUndoEnabled = false;
+ try
+ {
+ _messenger.Clear(this);
- SetElementValidation();
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
+ var wasOpen = sqlite.DataBaseOpen;
+ if (wasOpen == false) sqlite.Open();
+
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex != -1)
+ {
+ // Read scalar meta data using backing fields to avoid undo recording.
+ if (dtView.ColumnNames.Contains(nameof(Name)))
+ {
+ _name = dtView.GetCell(nameof(Name), rowIndex).ToString();
+ foreach (var item in _messages) item.SourceName = _name;
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ }
+ if (dtView.ColumnNames.Contains(nameof(Description)))
+ {
+ _description = dtView.GetCell(nameof(Description), rowIndex).ToString();
+ if (string.IsNullOrEmpty(_description))
+ _messenger.Add(_descriptionMsg);
+ else
+ _messenger.Remove(_descriptionMsg);
+ }
+ if (dtView.ColumnNames.Contains(nameof(CreationDate))) _creationDate = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(CreationDate), rowIndex).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(LastModified))) _lastModified = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(LastModified), rowIndex).ToString()) ?? DateTime.MinValue;
+
+ // Load the probability ordinates before the results so the collection
+ // changed handler (which clears results) cannot wipe restored output.
+ if (dtView.ColumnNames.Contains(nameof(ProbabilityOrdinates)))
+ {
+ ProbabilityOrdinates.FromDelimitedString(
+ dtView.GetCell(nameof(ProbabilityOrdinates), rowIndex).ToString(), "|");
+ }
+
+ // Parent distribution from XML, with a try/catch guard for legacy or
+ // corrupted files.
+ if (dtView.ColumnNames.Contains(nameof(ParentDistribution)))
+ {
+ try
+ {
+ var distXml = dtView.GetCell(nameof(ParentDistribution), rowIndex).ToString();
+ if (!string.IsNullOrEmpty(distXml))
+ _parentDistribution = UnivariateDistributionFactory.CreateDistribution(XElement.Parse(distXml));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the parent distribution for '{Name}': {ex.Message}");
+ }
+ }
+ _distributionValid = _parentDistribution != null && _parentDistribution.ParametersValid;
+ if (!_distributionValid) _messenger.Add(_parentDistMsg);
+
+ // Scalar simulation inputs.
+ if (dtView.ColumnNames.Contains(nameof(IsUncertain))) bool.TryParse(dtView.GetCell(nameof(IsUncertain), rowIndex).ToString(), out _isUncertain);
+ if (dtView.ColumnNames.Contains(nameof(EffectiveRecordLength))) int.TryParse(dtView.GetCell(nameof(EffectiveRecordLength), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _effectiveRecordLength);
+ if (dtView.ColumnNames.Contains(nameof(ConfidenceIntervalWidth))) double.TryParse(dtView.GetCell(nameof(ConfidenceIntervalWidth), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _confidenceIntervalWidth);
+ if (dtView.ColumnNames.Contains(nameof(Realizations))) int.TryParse(dtView.GetCell(nameof(Realizations), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _realizations);
+ if (dtView.ColumnNames.Contains(nameof(PRNGSeed))) int.TryParse(dtView.GetCell(nameof(PRNGSeed), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _prngSeed);
+ if (dtView.ColumnNames.Contains(nameof(EstimationMethod))) Enum.TryParse(dtView.GetCell(nameof(EstimationMethod), rowIndex).ToString(), out _estimationMethod);
+ if (dtView.ColumnNames.Contains(nameof(IsEstimated))) bool.TryParse(dtView.GetCell(nameof(IsEstimated), rowIndex).ToString(), out _isEstimated);
+
+ // Bootstrap results from a compressed byte array BLOB.
+ if (dtView.ColumnNames.Contains(nameof(Results)))
+ {
+ try
+ {
+ if (dtView.GetCell(nameof(Results), rowIndex) is byte[] bytes && bytes.Length > 0)
+ Results = ResultsFromByteArray(Numerics.Tools.Decompress(bytes));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the results for '{Name}': {ex.Message}");
+ Results = null;
+ }
+ }
+
+ // Check for corrupted results.
+ if (_isEstimated && _isUncertain && (Results == null || Results.ParameterSets == null || Results.ParameterSets.Length == 0))
+ {
+ _isEstimated = false;
+ Results = new UncertaintyAnalysisResults();
+ }
+ if (Results == null) Results = new UncertaintyAnalysisResults();
+ if (!_isEstimated) _messenger.Add(_estimatedMsg);
+
+ // Deserialize the element-owned frequency plot.
+ if (dtView.ColumnNames.Contains("FrequencyPlotSettings"))
+ {
+ var plotXml = dtView.GetCell("FrequencyPlotSettings", rowIndex).ToString();
+ if (!string.IsNullOrEmpty(plotXml))
+ {
+ try
+ {
+ PlotSerializer.FromXElement(_frequencyPlot, XElement.Parse(plotXml));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the frequency plot for '{Name}': {ex.Message}");
+ }
+ }
+ }
+ }
+
+ if (wasOpen == false) sqlite.Close();
+ SetupBridges();
+
+ ValidateEstimationMethod();
+ SetElementValidation();
+ SetIsDirty(false);
+
+ // Notify bindings for properties loaded via backing fields.
+ RaisePropertyChange(nameof(Name), setDirty: false);
+ RaisePropertyChange(nameof(ProbabilityOrdinates), setDirty: false);
+ RaisePropertyChange(nameof(IsEstimated), setDirty: false);
+ }
+ finally
+ {
+ IsUndoEnabled = wasUndoEnabled;
+ if (wasUndoEnabled) ClearUndoHistory();
+ }
+ }
+
+ ///
+ /// Raises the PreviewObjectSaved event before saving the element.
+ ///
+ /// Output parameter that determines if the save operation should be canceled.
+ public void RaisePreviewSaved(ref bool cancel)
+ {
+ RaisePreviewObjectSaved(this, ref cancel);
}
///
@@ -636,17 +785,156 @@ public override void Save()
RaisePreviewObjectSaved(this, ref cancel);
if (cancel) return;
- // Update last edited
- _lastModified = DateTime.Now;
+ // Create SQLite connection
+ var sqlite = new SQLiteManager(ParentCollection.ParentProject.FullFileName);
+ sqlite.Open();
+ DateTime previousLastModified = _lastModified;
+ bool committed = false;
+ try
+ {
+ // Only update last edited if user data actually changed
+ if (IsDirty)
+ {
+ _lastModified = DateTime.Now;
+ RaisePropertyChange(nameof(LastModified));
+ }
- //
- // Save to disk or database
- //
+ // Create the element collection table if it doesn't exist.
+ CreateTable(sqlite);
- _nameOnDisk = Name;
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex < 0 || rowIndex >= dtView.NumberOfRows)
+ {
+ dtView.AddRow();
+ rowIndex = dtView.NumberOfRows - 1;
+ }
+
+ dtView.EditCell(rowIndex, nameof(Name), Name);
+ dtView.EditCell(rowIndex, nameof(Description), Description);
+ dtView.EditCell(rowIndex, nameof(CreationDate), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(CreationDate));
+ dtView.EditCell(rowIndex, nameof(LastModified), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(LastModified));
+ dtView.EditCell(rowIndex, nameof(ParentDistribution), ParentDistribution.ToXElement().ToString());
+ dtView.EditCell(rowIndex, nameof(IsUncertain), IsUncertain);
+ dtView.EditCell(rowIndex, nameof(EffectiveRecordLength), EffectiveRecordLength);
+ dtView.EditCell(rowIndex, nameof(ConfidenceIntervalWidth), ConfidenceIntervalWidth.ToString("G17", CultureInfo.InvariantCulture));
+ dtView.EditCell(rowIndex, nameof(Realizations), Realizations);
+ dtView.EditCell(rowIndex, nameof(PRNGSeed), PRNGSeed);
+ dtView.EditCell(rowIndex, nameof(EstimationMethod), EstimationMethod.ToString());
+ dtView.EditCell(rowIndex, nameof(ProbabilityOrdinates), ProbabilityOrdinates?.ToDelimitedString("|") ?? "");
+ dtView.EditCell(rowIndex, nameof(IsEstimated), IsEstimated);
+ dtView.EditCell(rowIndex, nameof(Results), Numerics.Tools.Compress(ResultsToByteArray()));
+ dtView.EditCell(rowIndex, "FrequencyPlotSettings", _frequencyPlot != null ? PlotSerializer.ToXElement(_frequencyPlot).ToString() : "");
+
+ dtView.ApplyEdits();
+ sqlite.Close();
+ committed = true;
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ if (!committed && _lastModified != previousLastModified)
+ {
+ _lastModified = previousLastModified;
+ RaisePropertyChange(nameof(LastModified));
+ }
+ }
+
+ // Only mark clean / fire ObjectSaved when the commit actually succeeded.
+ if (committed)
+ {
+ SetIsDirty(false);
+ MarkUndoSavePoint();
+ _nameOnDisk = Name;
+ RaiseObjectSaved(this);
+ }
+ }
+
+ #endregion
+
+ #region SQLite Persistence
+
+ ///
+ /// The required columns for the SQLite table.
+ /// If you want to add a new column, add it to the end of the dictionary.
+ ///
+ private static Dictionary RequiredColumns { get; } = new Dictionary() {
+ { nameof(Name), typeof(string) },
+ { nameof(Description), typeof(string) },
+ { nameof(CreationDate), typeof(string) },
+ { nameof(LastModified), typeof(string) },
+ { nameof(ParentDistribution), typeof(string) },
+ { nameof(IsUncertain), typeof(bool) },
+ { nameof(EffectiveRecordLength), typeof(int) },
+ { nameof(ConfidenceIntervalWidth), typeof(string) },
+ { nameof(Realizations), typeof(int) },
+ { nameof(PRNGSeed), typeof(int) },
+ { nameof(EstimationMethod), typeof(string) },
+ { nameof(ProbabilityOrdinates), typeof(string) },
+ { nameof(IsEstimated), typeof(bool) },
+ { nameof(Results), typeof(byte[]) },
+ { "FrequencyPlotSettings", typeof(string) } };
+
+ ///
+ /// Creates or updates the SQLite database table for storing hazard elements.
+ ///
+ /// The SQLite database manager instance.
+ internal void CreateTable(SQLiteManager sqlite)
+ {
+ if (sqlite.TableNames.Contains(ParentCollection.Name) == false)
+ {
+ // If the table does not exist, then create the table
+ var dataTable = new DataTable(ParentCollection.Name);
+ foreach (KeyValuePair column in RequiredColumns)
+ dataTable.Columns.Add(column.Key, column.Value);
+ sqlite.SaveDataTable(dataTable);
+ }
+ else
+ {
+ // Add any required columns that don't exist
+ var dt = sqlite.GetTableManager(ParentCollection.Name);
+ int columnIndex;
+ foreach (KeyValuePair column in RequiredColumns)
+ {
+ columnIndex = Array.IndexOf(dt.ColumnNames, column.Key);
+ // If the column doesn't exist in the database then create it.
+ if (columnIndex < 0)
+ {
+ dt.AddColumn(column.Key, column.Value);
+ }
+ else
+ {
+ if (dt.ColumnTypes[columnIndex] != column.Value)
+ {
+ dt.DeleteColumn(columnIndex);
+ dt.AddColumn(column.Key, column.Value);
+ }
+ }
+ }
+ dt.ApplyEdits();
+ }
+ }
+
+ ///
+ /// Converts the bootstrap results to a byte array for BLOB storage. The byte array
+ /// format retains the bootstrap parameter sets, which the XML format does not.
+ ///
+ /// The serialized results, or an empty array when there are no results.
+ private byte[] ResultsToByteArray()
+ {
+ if (Results == null) return Array.Empty();
+ return UncertaintyAnalysisResults.ToByteArray(Results);
+ }
+
+ ///
+ /// Reconstructs the bootstrap results from a byte array.
+ ///
+ /// The serialized results.
+ /// The reconstructed results, or null when the array is empty.
+ private static UncertaintyAnalysisResults ResultsFromByteArray(byte[] bytes)
+ {
+ if (bytes == null || bytes.Length == 0) return null;
+ return UncertaintyAnalysisResults.FromByteArray(bytes);
}
#endregion
@@ -1002,6 +1290,144 @@ private void ComputeMinMax(bool meanOnly)
#endregion
+ #region Plot Factory Methods
+
+ ///
+ /// Applies the default plot style used for the element-owned plot.
+ ///
+ /// The plot to style.
+ private static void ApplyDefaultPlotStyle(Plot plot)
+ {
+ plot.BorderThickness = new System.Windows.Thickness(0);
+ plot.Background = System.Windows.Media.Brushes.Transparent;
+ plot.LegendBackground = (Color)ColorConverter.ConvertFromString("#8CFFFFFF");
+ plot.LegendBorder = Colors.DarkGray;
+ plot.LegendPosition = OxyPlot.Legends.LegendPosition.TopRight;
+ plot.Padding = new System.Windows.Thickness(10, 10, 14, 10);
+ plot.PlotAreaBackground = new SolidColorBrush(Colors.White);
+ }
+
+ ///
+ /// Creates the default frequency plot with a logarithmic hazard axis and a normal
+ /// probability exceedance axis.
+ ///
+ /// A new configured for hazard frequency display.
+ private static Plot CreateDefaultFrequencyPlot()
+ {
+ var plot = new Plot();
+ ApplyDefaultPlotStyle(plot);
+ plot.Title = "Hazard Distribution";
+ plot.LegendPosition = OxyPlot.Legends.LegendPosition.TopLeft;
+ plot.Axes.Add(new LogarithmicAxis
+ {
+ Key = "Yaxis",
+ Position = OxyPlot.Axes.AxisPosition.Left,
+ PowerPadding = true,
+ Title = "Discharge",
+ Unit = "cfs",
+ AxisTitleDistance = 20,
+ TitleFontSize = 16,
+ FontSize = 12,
+ MajorGridlineStyle = OxyPlot.LineStyle.Solid,
+ MinorGridlineStyle = OxyPlot.LineStyle.Dash,
+ StringFormat = "N0"
+ });
+ plot.Axes.Add(new NormalProbabilityAxis
+ {
+ Key = "Xaxis",
+ Title = "Exceedance Probability ",
+ Unit = "P(X > x)",
+ Position = OxyPlot.Axes.AxisPosition.Bottom,
+ AxisTitleDistance = 20,
+ TitleFontSize = 16,
+ FontSize = 12,
+ });
+ return plot;
+ }
+
+ #endregion
+
+ #region Undo Bridge Management
+
+ ///
+ /// Creates undo bridges for the ordinates collection and the element-owned plot.
+ /// Disposes any existing bridges before creating new ones.
+ ///
+ private void SetupBridges()
+ {
+ DisposeBridges();
+
+ // Subscribe to UndoManager.StateChanged to revalidate after undo/redo completes.
+ UndoManager.StateChanged += UndoManager_StateChanged;
+
+ // Collection bridge for probability ordinates.
+ // Note: Use UndoManager property (not _undoManager field) to ensure lazy initialization.
+ _ordinatesBridge = new UndoableCollectionBridge(
+ _probabilityOrdinates,
+ () => IsUndoEnabled ? UndoManager : null,
+ "probability ordinates",
+ this);
+
+ // Plot undo manager for the element-owned frequency plot.
+ if (_frequencyPlot != null)
+ {
+ _frequencyPlotUndo = new PlotUndoManager(
+ _frequencyPlot,
+ () => IsUndoEnabled ? UndoManager : null,
+ "frequency plot",
+ this,
+ () => SetIsDirty(true));
+ }
+ }
+
+ ///
+ /// Handles UndoManager.StateChanged to revalidate the element after undo/redo.
+ ///
+ /// The event sender.
+ /// The event arguments.
+ private void UndoManager_StateChanged(object sender, EventArgs e)
+ {
+ SetElementValidation();
+ }
+
+ ///
+ /// Disposes all undo bridges and sets their references to null.
+ ///
+ private void DisposeBridges()
+ {
+ UndoManager.StateChanged -= UndoManager_StateChanged;
+
+ _ordinatesBridge?.Dispose();
+ _ordinatesBridge = null;
+
+ _frequencyPlotUndo?.Dispose();
+ _frequencyPlotUndo = null;
+ }
+
+ ///
+ /// Suspends the plot undo bridges so that programmatic plot changes (series population,
+ /// plot settings restoration) do not create undo entries.
+ ///
+ /// An that resumes recording when disposed.
+ public IDisposable SuspendPlotBridges()
+ {
+ var suspensions = new List();
+ if (_frequencyPlotUndo != null) suspensions.Add(_frequencyPlotUndo.SuspendRecording());
+ return new AggregateDisposable(suspensions);
+ }
+
+ ///
+ /// Rebuilds series and annotation bridges for the specified plot after a bulk series update.
+ /// Call this after populating series inside a block.
+ ///
+ /// The plot whose bridges should be rebuilt.
+ public void RebuildSeriesAndAnnotationBridges(Plot plot)
+ {
+ if (plot == _frequencyPlot) _frequencyPlotUndo?.RebuildSeriesAndAnnotationBridges();
+ }
+
+ #endregion
+
#endregion
}
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
index e62ccac0..97e8f982 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
@@ -1,4 +1,6 @@
-using FrameworkInterfaces;
+using DatabaseManager;
+using FrameworkInterfaces;
+using System;
namespace FrameworkUI.Demo
{
@@ -25,85 +27,203 @@ public class HazardElementCollection : ElementCollectionBase
/// Initializes a new instance of the class.
///
/// The parent project that contains this collection.
- public HazardElementCollection(IProject parentProject) : base(parentProject)
- {
- }
+ public HazardElementCollection(IProject parentProject) : base(parentProject) { }
+
+ ///
+ /// Gets the name of the collection. This is also the name of the SQLite table
+ /// storing the hazard elements.
+ ///
+ public override string Name => "Hazard Functions";
+
+ ///
+ /// Tracks whether the loaded collection table contained blank or duplicate rows.
+ ///
+ private bool _needsTableCompaction;
///
- /// Gets the name of the collection.
+ /// Saves all hazard elements in the collection to disk.
///
- public override string Name
+ ///
+ /// This method raises preview events for each element, allowing cancellation, and
+ /// only saves elements that are marked as dirty.
+ ///
+ public override void Save()
{
- get
+ bool cancel = false;
+ RaisePreviewObjectSaved(this, ref cancel);
+ if (cancel == true) return;
+
+ _savingAll = true;
+ try
+ {
+ if (_needsTableCompaction)
+ {
+ // The loaded table contained stale rows. Rewrite it from scratch so
+ // the on-disk table matches the in-memory collection exactly.
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ ((HazardElement)ElementList[i]).RaisePreviewSaved(ref cancel);
+ if (cancel == true) return;
+ }
+
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(Name))
+ {
+ sqlite.DeleteTable(Name);
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
+
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ ElementList[i].Save();
+ }
+
+ _needsTableCompaction = false;
+ }
+ else
+ {
+ // Save all elements.
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ // Always raise preview saved. This allows the plot settings to always be saved.
+ ((HazardElement)ElementList[i]).RaisePreviewSaved(ref cancel);
+ if (cancel == true) continue;
+ // Only save if dirty
+ if (ElementList[i].IsDirty)
+ ElementList[i].Save();
+ }
+ }
+
+ SetIsDirty(false);
+ RaiseObjectSaved();
+ }
+ finally
{
- return "Hazard Functions";
+ _savingAll = false;
}
}
///
- /// Opens the collection and loads elements from disk.
+ /// Loads all hazard elements from disk into the collection.
///
+ ///
+ /// This method reads the SQLite database table and creates a
+ /// instance for each row found.
+ ///
public override void Open()
{
_opening = true;
- // read from disk
- _opening = false;
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(Name) == true)
+ {
+ var dtView = sqlite.GetTableManager(Name);
+ bool needsRewrite;
+ foreach (string elementName in CollectionPersistenceHelper.BuildSingleTableLoadEntries(dtView, out needsRewrite))
+ {
+ var element = new HazardElement(elementName, this, true);
+ Add(element);
+ }
+
+ _needsTableCompaction = needsRewrite;
+ }
+ sqlite.Close();
+ SetIsDirty(_needsTableCompaction);
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ _opening = false;
+ }
}
///
/// Adds a hazard element to the collection.
///
/// The element to add to the collection.
+ ///
+ /// This method subscribes to property change and deletion events, saves the element
+ /// to disk if needed, and raises the ElementAdded event.
+ ///
public override void Add(IElement item)
{
+ item.PropertyChanged += ElementPropertyChanged;
item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
ElementList.Add((HazardElement)item);
- // Save to disk
if (_opening == false)
{
- //item.IsValid();
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ sqlite.Close();
SetIsDirty(true);
}
RaiseElementAddedEvent(item);
}
///
- /// Inserts a hazard element at the specified index in the collection.
+ /// Inserts a hazard element into the collection at the specified index.
///
/// The zero-based index at which the element should be inserted.
- /// The element to insert.
+ /// The element to insert into the collection.
+ ///
+ /// This method subscribes to property change and deletion events, saves the element
+ /// to disk if needed, and raises the ElementAdded event.
+ ///
public override void Insert(int index, IElement item)
{
+ item.PropertyChanged += ElementPropertyChanged;
item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
ElementList.Insert(index, (HazardElement)item);
- // save to disk
- SetIsDirty(true);
+ if (_opening == false)
+ {
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ sqlite.Close();
+ SetIsDirty(true);
+ }
RaiseElementAddedEvent(item);
}
///
- /// Deletes the collection from disk.
+ /// Copies a hazard element from an external project and inserts it into this
+ /// collection at the specified index.
///
- public override void Delete()
+ /// The zero-based index at which the element should be inserted.
+ /// The name of the element to copy.
+ /// The fully qualified type name of the element.
+ /// The full file path to the external project database.
+ public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
{
- // Delete from disk
+ var element = new HazardElement(elementName, this);
+ Insert(index, element.CopyFromExternal(elementName, fullFileName));
}
///
- /// Inserts an element from an external project at the specified index.
+ /// Deletes the entire hazard element collection and removes all associated data from disk.
///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
+ public override void Delete()
{
- Insert(index, new HazardElement(elementName, this));
- //throw new System.NotImplementedException();
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ sqlite.DeleteTable(Name);
+ sqlite.Close();
}
}
}
diff --git a/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs b/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs
new file mode 100644
index 00000000..bc92e5df
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace FrameworkUI.Demo
+{
+ ///
+ /// Aggregates multiple instances into a single disposable that
+ /// disposes them all in reverse order when disposed. Used by SuspendPlotBridges()
+ /// methods across UI element classes to collect and manage suspension tokens from
+ /// multiple instances.
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// This class is safe against double-dispose: once disposed, subsequent calls to
+ /// are no-ops. Disposes are performed in reverse insertion order
+ /// to mirror try/finally unwinding semantics.
+ ///
+ ///
+ internal sealed class AggregateDisposable : IDisposable
+ {
+ ///
+ /// The list of disposable objects to aggregate.
+ ///
+ private readonly List _disposables;
+
+ ///
+ /// Indicates whether this instance has already been disposed.
+ ///
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The list of disposable objects to aggregate.
+ public AggregateDisposable(List disposables) => _disposables = disposables;
+
+ ///
+ /// Disposes all aggregated objects in reverse order. Safe to call multiple times.
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ for (int i = _disposables.Count - 1; i >= 0; i--)
+ _disposables[i]?.Dispose();
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs b/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs
new file mode 100644
index 00000000..54f8237c
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs
@@ -0,0 +1,97 @@
+using DatabaseManager;
+using System;
+using System.Collections.Generic;
+
+namespace FrameworkUI.Demo
+{
+ ///
+ /// Provides safe SQLite load-entry helpers for element collections.
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// Collection tables can become stale independently from the elements they index. These
+ /// helpers implement a consistent first-row-wins repair policy while preserving true
+ /// distinct element-name conflicts for the normal validation layer.
+ ///
+ ///
+ internal static class CollectionPersistenceHelper
+ {
+ ///
+ /// Builds load entries from a single-table collection using first duplicate name wins.
+ ///
+ /// The collection table view.
+ /// Receives true when blank or duplicate rows were skipped.
+ /// The ordered loadable element names.
+ public static List BuildSingleTableLoadEntries(DataTableView dtView, out bool needsRewrite)
+ {
+ needsRewrite = false;
+ var entries = new List();
+ var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (int i = 0; i < dtView.NumberOfRows; i++)
+ {
+ string elementName = ReadName(dtView, i);
+ if (string.IsNullOrWhiteSpace(elementName))
+ {
+ needsRewrite = true;
+ continue;
+ }
+
+ if (seenNames.Add(elementName) == false)
+ {
+ needsRewrite = true;
+ continue;
+ }
+
+ entries.Add(elementName);
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Determines whether a table contains a row with the specified element name.
+ ///
+ /// The SQLite project connection.
+ /// The table name to search.
+ /// The element name to find.
+ /// true when a matching row exists; otherwise, false.
+ public static bool NamedRowExists(SQLiteManager sqlite, string tableName, string elementName)
+ {
+ if (string.IsNullOrWhiteSpace(elementName) ||
+ sqlite.TableNames.Contains(tableName) == false)
+ {
+ return false;
+ }
+
+ DataTableView dtView = sqlite.GetTableManager(tableName);
+ if (dtView.ColumnNames.Contains("Name") == false)
+ {
+ return false;
+ }
+
+ return dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", elementName, true, true) != -1;
+ }
+
+ ///
+ /// Reads an element name from a single-table collection row.
+ ///
+ /// The table view to read.
+ /// The row index to read.
+ /// The row name, or an empty string when absent.
+ private static string ReadName(DataTableView dtView, int rowIndex)
+ {
+ if (dtView.ColumnNames.Contains("Name"))
+ {
+ return dtView.GetCell("Name", rowIndex)?.ToString() ?? string.Empty;
+ }
+
+ object[] row = dtView.GetRow(rowIndex);
+ return row.Length != 0 ? row[0]?.ToString() ?? string.Empty : string.Empty;
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs b/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs
new file mode 100644
index 00000000..66f5fca3
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs
@@ -0,0 +1,549 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) 2024 USACE Risk Management Center. All rights reserved.
+//
+//
+// Manages undo bridges for a single OxyPlot Plot and all its child elements.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace FrameworkUI.Demo
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Collections.Specialized;
+ using System.ComponentModel;
+ using System.Linq;
+ using FrameworkInterfaces;
+ using FrameworkInterfaces.Undo;
+ using FrameworkInterfaces.Undo.Actions;
+ using OxyPlot.Wpf;
+ using OxyPlot.Wpf.Serialization;
+
+ ///
+ /// Manages undo bridges for a single OxyPlot and all its child elements
+ /// (axes, series, annotations).
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// Each element creates one per plot. The manager automatically
+ /// monitors and collection changes
+ /// to rebuild bridges when axes change type or annotations are added/removed via the toolbar.
+ ///
+ ///
+ /// Series bridges are NOT auto-monitored because is cleared and
+ /// repopulated during data update operations. Instead, call
+ /// explicitly after each UpdateXxxPlot() method, outside of any scope.
+ ///
+ ///
+ public class PlotUndoManager : IDisposable
+ {
+ ///
+ /// WPF theming properties that change automatically when
+ /// cascades through the visual tree (e.g., disabled state styling). These must NOT be tracked for undo
+ /// because they are not user-initiated changes. For example, when DisableOpenWindows() sets
+ /// IsEnabled = false on all document controls during MCMC estimation, WPF coerces
+ /// Foreground/Background on OxyPlot Series objects, which would create spurious undo entries
+ /// on every open element.
+ ///
+ private static readonly HashSet WpfThemingProperties = new()
+ {
+ "Foreground", "Background", "IsEnabled"
+ };
+
+ ///
+ /// Plot visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList PlotUndoProperties =
+ PlotSerializer.PlotVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Axis visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList AxisUndoProperties =
+ AxisSerializer.AxisVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Series visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList SeriesUndoProperties =
+ SeriesSerializer.SeriesVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Annotation visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList AnnotationUndoProperties =
+ AnnotationSerializer.AnnotationVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// The plot being managed.
+ ///
+ private readonly Plot _plot;
+
+ ///
+ /// Lambda that returns the when undo is enabled, or null otherwise.
+ ///
+ private readonly Func _getUndoManager;
+
+ ///
+ /// Human-readable description for undo action labels (e.g. "time series plot").
+ ///
+ private readonly string _description;
+
+ ///
+ /// The owning element, used as the undo action target.
+ ///
+ private readonly object _target;
+
+ ///
+ /// Callback invoked when any bridge records an undo action.
+ ///
+ private readonly Action _onActionRecorded;
+
+ ///
+ /// Bridge for plot-level visual properties (Title, LegendPosition, Background, etc.).
+ ///
+ private UndoableStateBridge _plotBridge;
+
+ ///
+ /// Per-axis state bridges. Rebuilt when collection changes.
+ ///
+ private readonly List _axisBridges = new List();
+
+ ///
+ /// Per-series state bridges. Rebuilt explicitly via .
+ ///
+ private readonly List _seriesBridges = new List();
+
+ ///
+ /// Per-annotation state bridges. Rebuilt when collection changes
+ /// or explicitly via .
+ ///
+ private readonly List _annotationBridges = new List();
+
+ ///
+ /// Whether recording is currently suspended.
+ ///
+ private bool _isSuspended;
+
+ ///
+ /// Reference count for nested calls.
+ /// Only the outermost scope actually suspends/resumes the bridges;
+ /// inner scopes are no-ops that just increment/decrement the depth.
+ ///
+ private int _suspensionDepth;
+
+ ///
+ /// Whether was called while suspended.
+ /// When true, the outermost will perform the deferred
+ /// rebuild upon dispose.
+ ///
+ private bool _needsRebuild;
+
+ ///
+ /// Whether this instance has been disposed.
+ ///
+ private bool _isDisposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The plot to manage undo bridges for.
+ /// Lambda returning the or null.
+ /// Human-readable description for undo action labels.
+ /// The owning element for undo action association.
+ /// Callback when any bridge records an undo action.
+ ///
+ /// Thrown when , ,
+ /// or is null.
+ ///
+ public PlotUndoManager(Plot plot, Func getUndoManager, string description, object target, Action onActionRecorded)
+ {
+ _plot = plot ?? throw new ArgumentNullException(nameof(plot));
+ _getUndoManager = getUndoManager ?? throw new ArgumentNullException(nameof(getUndoManager));
+ _description = description ?? "plot";
+ _target = target;
+ _onActionRecorded = onActionRecorded ?? throw new ArgumentNullException(nameof(onActionRecorded));
+
+ CreateAllBridges();
+
+ // Auto-monitor axis collection for type changes (axis object replacement).
+ _plot.Axes.CollectionChanged += OnAxesCollectionChanged;
+
+ // Auto-monitor annotation collection for toolbar add/remove.
+ _plot.Annotations.CollectionChanged += OnAnnotationsCollectionChanged;
+
+ // Listen for axis type changes to record undoable replacement actions.
+ _plot.AxisReplaced += OnAxisReplaced;
+
+ // Listen for bulk annotation modification notifications (e.g. after suppressed
+ // drag placement completes and Plot.NotifyAnnotationsModified() is called).
+ _plot.PropertyChanged += OnPlotPropertyChanged;
+ }
+
+ ///
+ /// Gets the plot that this manager is tracking.
+ ///
+ public Plot Plot => _plot;
+
+ ///
+ /// Suspends undo recording on all bridges managed by this instance.
+ ///
+ /// An that resumes recording when disposed.
+ ///
+ ///
+ /// Use this during bulk UpdateXxxPlot() operations to prevent spurious undo entries.
+ /// After disposing, call to reconnect
+ /// bridges to the new series and annotations.
+ ///
+ ///
+ /// Supports nesting: only the outermost call suspends the bridges; inner calls are
+ /// lightweight no-ops. The bridges resume only when the outermost scope disposes.
+ ///
+ ///
+ public IDisposable SuspendRecording()
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(PlotUndoManager),
+ "Cannot suspend recording on a disposed PlotUndoManager. Re-create the manager " +
+ "via the owning element's SetupBridges if a fresh suspension is needed after Dispose().");
+ _suspensionDepth++;
+ _isSuspended = true;
+
+ // Nested call — bridges are already suspended by the outer scope.
+ if (_suspensionDepth > 1)
+ return new SuspensionScope(this, new List());
+
+ // Outermost call — actually suspend all bridges.
+ var suspensions = new List();
+
+ if (_plotBridge != null)
+ suspensions.Add(_plotBridge.SuspendRecording());
+
+ foreach (var b in _axisBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ foreach (var b in _seriesBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ foreach (var b in _annotationBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ return new SuspensionScope(this, suspensions);
+ }
+
+ ///
+ /// Rebuilds per-item state bridges for series and annotations.
+ ///
+ ///
+ ///
+ /// Call this after UpdateXxxPlot() methods that clear and repopulate
+ /// and .
+ ///
+ ///
+ /// If called while recording is suspended (inside a scope),
+ /// the rebuild is deferred until the outermost scope disposes. This prevents creating
+ /// live bridges that would fire PropertyChanged events during bulk updates.
+ ///
+ ///
+ public void RebuildSeriesAndAnnotationBridges()
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(PlotUndoManager),
+ "Cannot rebuild bridges on a disposed PlotUndoManager. Calling this on a " +
+ "stale instance after Dispose() typically indicates a wrong-reference bug — " +
+ "the App-side control should use the owning element's current bridge manager.");
+
+ // Defer rebuild until the outermost suspension scope exits.
+ // Creating bridges while suspended would leave them live (unsuspended),
+ // causing PropertyChanged recording during subsequent plot updates.
+ if (_isSuspended)
+ {
+ _needsRebuild = true;
+ return;
+ }
+
+ DisposeSeriesBridges();
+ DisposeAnnotationBridges();
+
+ foreach (Series series in _plot.Series)
+ {
+ _seriesBridges.Add(new UndoableStateBridge(
+ series, _getUndoManager, $"{_description} series", _target,
+ includedProperties: SeriesUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Releases all bridges and unsubscribes from collection events.
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+ _isDisposed = true;
+
+ _plot.Axes.CollectionChanged -= OnAxesCollectionChanged;
+ _plot.Annotations.CollectionChanged -= OnAnnotationsCollectionChanged;
+ _plot.AxisReplaced -= OnAxisReplaced;
+ _plot.PropertyChanged -= OnPlotPropertyChanged;
+
+ _plotBridge?.Dispose();
+ _plotBridge = null;
+
+ DisposeAxisBridges();
+ DisposeSeriesBridges();
+ DisposeAnnotationBridges();
+ }
+
+ ///
+ /// Creates all bridges for the plot, axes, series, and annotations.
+ ///
+ private void CreateAllBridges()
+ {
+ _plotBridge = new UndoableStateBridge(
+ _plot, _getUndoManager, _description, _target,
+ includedProperties: PlotUndoProperties,
+ onActionRecorded: _onActionRecorded);
+
+ foreach (Axis axis in _plot.Axes)
+ {
+ _axisBridges.Add(new UndoableStateBridge(
+ axis, _getUndoManager, $"{_description} axis", _target,
+ includedProperties: AxisUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Series series in _plot.Series)
+ {
+ _seriesBridges.Add(new UndoableStateBridge(
+ series, _getUndoManager, $"{_description} series", _target,
+ includedProperties: SeriesUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Handles collection changes to rebuild axis bridges.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the user changes an axis type (e.g. Linear to Logarithmic), the
+ /// AxesControl removes the old axis and adds a new one.
+ /// This handler rebuilds axis bridges so the new axis object is monitored.
+ /// Changes during suspended recording are ignored; the caller should
+ /// call after resuming.
+ ///
+ private void OnAxesCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ RebuildAxisBridges();
+ }
+
+ ///
+ /// Handles events to record an undoable axis type change.
+ ///
+ /// The axis that was removed from the collection.
+ /// The axis that was inserted in its place.
+ ///
+ /// When the user changes an axis type (e.g. Linear to Logarithmic) via AxisControl,
+ /// fires this event after atomically swapping the axis objects.
+ /// This handler records a so the swap can be undone with Ctrl+Z.
+ /// The guard prevents re-recording during undo/redo replay,
+ /// since the DelegateAction's execute/undo lambdas also call which
+ /// re-fires this event.
+ ///
+ private void OnAxisReplaced(Axis oldAxis, Axis newAxis)
+ {
+ if (_isDisposed || _isSuspended) return;
+ var undoManager = _getUndoManager();
+ if (undoManager == null || undoManager.IsExecutingAction) return;
+
+ var action = new DelegateAction(
+ $"Change {_description} axis type",
+ () => _plot.ReplaceAxis(oldAxis, newAxis),
+ () => _plot.ReplaceAxis(newAxis, oldAxis),
+ _target);
+ undoManager.RecordAction(action);
+ _onActionRecorded?.Invoke();
+ }
+
+ ///
+ /// Handles collection changes to rebuild annotation bridges.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the user adds or removes an annotation via the toolbar, the
+ /// AnnotationSelectorControl modifies .
+ /// This handler rebuilds annotation bridges for the new set of annotations.
+ /// Changes during suspended recording are ignored.
+ ///
+ private void OnAnnotationsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ RebuildAnnotationBridgesFromCollection();
+ }
+
+ ///
+ /// Handles events to detect bulk annotation modifications.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the OxyPlotToolbar finishes placing an annotation, it calls
+ /// which fires PropertyChanged("Annotations").
+ /// This handler rebuilds annotation bridges so they pick up the final property values
+ /// as their shadow baseline, rather than the initial values from before the drag.
+ ///
+ private void OnPlotPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ if (e.PropertyName == "Annotations")
+ {
+ RebuildAnnotationBridgesFromCollection();
+ }
+ }
+
+ ///
+ /// Disposes and rebuilds all axis bridges from the current collection.
+ ///
+ private void RebuildAxisBridges()
+ {
+ DisposeAxisBridges();
+ foreach (Axis axis in _plot.Axes)
+ {
+ _axisBridges.Add(new UndoableStateBridge(
+ axis, _getUndoManager, $"{_description} axis", _target,
+ includedProperties: AxisUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Disposes and rebuilds annotation bridges from the current collection.
+ ///
+ private void RebuildAnnotationBridgesFromCollection()
+ {
+ DisposeAnnotationBridges();
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Disposes all axis bridges and clears the list.
+ ///
+ private void DisposeAxisBridges()
+ {
+ foreach (var b in _axisBridges) b.Dispose();
+ _axisBridges.Clear();
+ }
+
+ ///
+ /// Disposes all series bridges and clears the list.
+ ///
+ private void DisposeSeriesBridges()
+ {
+ foreach (var b in _seriesBridges) b.Dispose();
+ _seriesBridges.Clear();
+ }
+
+ ///
+ /// Disposes all annotation bridges and clears the list.
+ ///
+ private void DisposeAnnotationBridges()
+ {
+ foreach (var b in _annotationBridges) b.Dispose();
+ _annotationBridges.Clear();
+ }
+
+ ///
+ /// Aggregates multiple suspension tokens and
+ /// manages the flag via reference counting.
+ ///
+ ///
+ /// Only the outermost scope (the one that holds the actual bridge tokens)
+ /// resumes recording on dispose. Inner (nested) scopes hold empty lists
+ /// and simply decrement the depth counter.
+ ///
+ private class SuspensionScope : IDisposable
+ {
+ ///
+ /// The owning .
+ ///
+ private readonly PlotUndoManager _owner;
+
+ ///
+ /// The individual bridge suspension tokens. Empty for nested scopes.
+ ///
+ private readonly List _suspensions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The owning .
+ /// The individual bridge suspension tokens.
+ public SuspensionScope(PlotUndoManager owner, List suspensions)
+ {
+ _owner = owner;
+ _suspensions = suspensions;
+ }
+
+ ///
+ /// Decrements the suspension depth. When the outermost scope disposes,
+ /// resumes recording on all bridges, clears the suspended flag, and
+ /// performs any deferred call.
+ ///
+ public void Dispose()
+ {
+ _owner._suspensionDepth--;
+ if (_owner._suspensionDepth <= 0)
+ {
+ // Resume the original bridge suspension tokens.
+ for (int i = _suspensions.Count - 1; i >= 0; i--)
+ _suspensions[i].Dispose();
+ _owner._isSuspended = false;
+ _owner._suspensionDepth = 0;
+
+ // Perform deferred rebuild if any UpdateXxxPlot called
+ // RebuildSeriesAndAnnotationBridges while we were suspended.
+ if (_owner._needsRebuild)
+ {
+ _owner._needsRebuild = false;
+ _owner.RebuildSeriesAndAnnotationBridges();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
index ee08bffe..84b0ecc4 100644
--- a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
+++ b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
@@ -289,7 +289,6 @@ public override void DocumentClosed(UIElement documentControl)
if (documentControl as HazardControl != null)
{
var cntrl = (HazardControl)documentControl;
- //cntrl.Element.UpdatePlotSettings(OxyPlotSettingsSerializer.ToXelement(cntrl.Plot).ToString());
cntrl.PlotToolbar.PropertiesCalled -= PlotPropertiesCalled;
cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked;
}
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
index 6fb884e1..0d6abf5f 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
@@ -5,7 +5,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:FrameworkUI.Demo.UI"
xmlns:cntrls="clr-namespace:GenericControls;assembly=GenericControls"
- xmlns:oxy="http://oxyplot.org/wpf"
xmlns:oxyCntrls="clr-namespace:OxyPlotControls;assembly=OxyPlotControls"
xmlns:dbCntrls="clr-namespace:DatabaseControls;assembly=DatabaseControls"
xmlns:glob="clr-namespace:System.Globalization;assembly=mscorlib"
@@ -15,6 +14,8 @@
d:DesignHeight="450" d:DesignWidth="800"
Loaded="HazardFunctionControl_Loaded"
Unloaded="HazardFunctionControl_Unloaded"
+ PreviewMouseDown="HazardFunctionControl_PreviewMouseDown"
+ LostFocus="HazardFunctionControl_LostFocus"
Background="{DynamicResource EnvironmentWindowBackground}"
Foreground="{DynamicResource EnvironmentWindowText}"
DataContext="{Binding RelativeSource={RelativeSource Self}}">
@@ -35,83 +36,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+ ToolBarOrientation="Vertical"
+ VerticalAlignment="Stretch"/>
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
index 7868e04e..e0f39d86 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
@@ -1,5 +1,4 @@
using DatabaseManager;
-using DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
using FrameworkInterfaces;
using OxyPlot;
using OxyPlot.Wpf;
@@ -44,12 +43,14 @@ public partial class HazardControl : UserControl
public HazardControl()
{
InitializeComponent();
- // Update plot series tracker format string
- ConfidenceInterval.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
- MeanLine.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
- ModeLine.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
}
+ ///
+ /// The tracker format string applied to the frequency curve series.
+ ///
+ private static readonly string s_trackerFormatString =
+ "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
+
#endregion
#region Members
@@ -133,6 +134,8 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
if (oldElement != null)
{
oldElement.PropertyChanged -= thisControl.HazardFunctionPropertyChanged;
+ thisControl.FrequencyPlotHost.Content = null;
+ thisControl.PlotToolbar.Plot = null;
}
}
@@ -143,7 +146,14 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
newElement.PropertyChanged += thisControl.HazardFunctionPropertyChanged;
- thisControl.LoadPlotSettings();
+ // Host the element-owned plot. Suspend the plot undo bridges so the
+ // programmatic hookup does not create undo entries.
+ using (newElement.SuspendPlotBridges())
+ {
+ thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot;
+ thisControl.PlotToolbar.Plot = newElement.FrequencyPlot;
+ }
+
thisControl.UpdatePlot();
}
@@ -159,6 +169,12 @@ public HazardElement Element
set => SetValue(ElementProperty, value);
}
+ ///
+ /// Gets the element-owned frequency plot hosted by this control, or null when no
+ /// element is bound.
+ ///
+ public Plot Plot => Element?.FrequencyPlot;
+
#endregion
#region Methods
@@ -172,7 +188,6 @@ private void HazardFunctionControl_Loaded(object sender, RoutedEventArgs e)
{
if (!_isLoaded)
{
- LoadPlotSettings();
UpdatePlot();
SetColumnStringFormats();
BindFrequencyCurveTable();
@@ -240,6 +255,15 @@ private void OnThemeChanged(object sender, ThemeChangedEventArgs e)
/// The instance containing the property name.
private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
+ // Property changes can arrive on a worker thread (e.g., after a background
+ // estimation completes). Marshal to the UI thread before touching the plot
+ // or the data grids.
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(() => HazardFunctionPropertyChanged(sender, e)));
+ return;
+ }
+
if (e.PropertyName == nameof(Element.IsEstimated))
{
UpdatePlot();
@@ -287,119 +311,113 @@ private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventAr
#region Plot Methods
- private bool _probabilityOnX = true;
-
- ///
- /// Loads and applies the saved plot settings from the element's chart settings.
- ///
- ///
- /// This method restores axis configurations, bindings, and series settings from XML.
- /// It also handles backward compatibility for logarithmic axis conversion.
- ///
- private void LoadPlotSettings()
- {
- try
- {
- // This is a hack to make the log-scale plot behave when the series data is negative
- // Without this hack, when the axis is later changed to linear, the negative values will not render correctly
- // We need to fix this in OxyPlot later
- OxyPlot.Wpf.Axis oldAxis = null;
- OxyPlot.Wpf.Axis newAxis = null;
-
- foreach (var axis in Plot.Axes)
- {
- if (axis.Key == "Hazard")
- {
- oldAxis = axis;
- newAxis = AxisControl.ConvertAxisToLogarithmicAxis(oldAxis);
- break;
- }
- }
-
- Plot.Axes.Remove(oldAxis);
- Plot.Axes.Add(newAxis);
- Plot.InvalidatePlot();
-
- if (Element == null || Element.PlotSettings == null) return;
-
- // OxyPlotControls.FromXElement(Plot, System.Xml.Linq.XElement.Parse(Element.ChartSettings));
-
- foreach (var series in Plot.Series)
- {
- if (series.Name == ModeLine.Name) series.ItemsSource = ModeLinePoints;
- if (series.Name == MeanLine.Name) series.ItemsSource = MeanLinePoints;
- if (series.Name == ConfidenceInterval.Name) series.ItemsSource = ConfidencePoints;
-
- if (_probabilityOnX)
- {
- series.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
- }
- else
- {
- series.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.####E+0}";
- }
- }
- }
- catch (Exception ex)
- {
- Debug.WriteLine(ex);
- // Couldn't parse the chart settings xml. It doesn't necessarily mean an error since it could have never been set.
- }
- }
-
///
/// Updates the plot with the current hazard function results.
///
///
- /// This method clears existing series data and repopulates the plot with:
- ///
- /// - Confidence interval area (if uncertainty is enabled)
- /// - Mean curve line (if uncertainty is enabled)
- /// - Mode (user-specified) curve line
- ///
- /// The axis orientation is determined by the ProbabilityAxisCheckBox state.
+ ///
+ /// Series are looked up by name and only created when missing, so user-customized
+ /// styling survives Save/Open round trips. Series are populated inside a
+ /// scope so the bulk update does not
+ /// create undo entries, and the bridges are rebuilt afterwards so the new series
+ /// objects are tracked.
+ ///
+ ///
+ /// The plot shows the confidence interval area and mean curve (when uncertainty is
+ /// enabled) plus the mode (user-specified) curve.
+ ///
///
public void UpdatePlot()
{
- if (Plot == null) return;
-
- Plot.Series.Clear();
- ConfidencePoints.Clear();
- MeanLinePoints.Clear();
- ModeLinePoints.Clear();
-
// Element can be null during the swap-out phase of ElementPropertyChanged if
// the DP callback fires between the old value being nulled and the new value
// being assigned. Bail instead of NRE'ing on Element.Results below.
- if (Element == null || Element.Results == null) return;
+ var plot = Element?.FrequencyPlot;
+ if (plot == null) return;
- if (Element.IsEstimated)
+ // Lookup-or-create named series so user-customized styling survives Save/Open.
+ var confidenceInterval = plot.Series.OfType().FirstOrDefault(s => s.Name == "ConfidenceInterval")
+ ?? new OxyPlot.Wpf.AreaSeries
+ {
+ Name = "ConfidenceInterval",
+ Title = "90% Confidence Interval",
+ Fill = (Color)ColorConverter.ConvertFromString("#4A688CAF"),
+ Color = (Color)ColorConverter.ConvertFromString("#FF353B7A"),
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ DataFieldX = nameof(AreaPoint.X1),
+ DataFieldX2 = nameof(AreaPoint.X2),
+ DataFieldY = nameof(AreaPoint.Y1),
+ DataFieldY2 = nameof(AreaPoint.Y2),
+ };
+ var meanLine = plot.Series.OfType().FirstOrDefault(s => s.Name == "MeanLine")
+ ?? new OxyPlot.Wpf.LineSeries
+ {
+ Name = "MeanLine",
+ Title = "Mean",
+ Color = Colors.Blue,
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ LineStyle = OxyPlot.LineStyle.Dash,
+ };
+ var modeLine = plot.Series.OfType().FirstOrDefault(s => s.Name == "ModeLine")
+ ?? new OxyPlot.Wpf.LineSeries
+ {
+ Name = "ModeLine",
+ Title = "User-Specified",
+ Color = Colors.Black,
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ LineStyle = OxyPlot.LineStyle.Solid,
+ };
+
+ // Always rewire the item sources and tracker formats; deserialized series
+ // restore visual styling only.
+ confidenceInterval.ItemsSource = ConfidencePoints;
+ confidenceInterval.TrackerFormatString = s_trackerFormatString;
+ meanLine.ItemsSource = MeanLinePoints;
+ meanLine.TrackerFormatString = s_trackerFormatString;
+ modeLine.ItemsSource = ModeLinePoints;
+ modeLine.TrackerFormatString = s_trackerFormatString;
+
+ using (Element.SuspendPlotBridges())
{
- for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
+ plot.Series.Clear();
+ ConfidencePoints.Clear();
+ MeanLinePoints.Clear();
+ ModeLinePoints.Clear();
+
+ if (Element.IsEstimated && Element.Results != null)
{
- double aep = Element.ProbabilityOrdinates[i];
+ for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
+ {
+ double aep = Element.ProbabilityOrdinates[i];
+
+ if (Element.IsUncertain)
+ {
+ ConfidencePoints.Add(new AreaPoint(
+ new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 0]),
+ new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 1])));
+ MeanLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.MeanCurve[i]));
+ }
+ ModeLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.ModeCurve[i]));
+ }
if (Element.IsUncertain)
{
- ConfidencePoints.Add(new AreaPoint(
- new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 0]),
- new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 1])));
- MeanLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.MeanCurve[i]));
+ confidenceInterval.Title = (Element.ConfidenceIntervalWidth * 100).ToString("F0", CultureInfo.CurrentCulture) + "% Confidence Interval";
+ plot.Series.Add(confidenceInterval);
+ plot.Series.Add(meanLine);
}
- ModeLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.ModeCurve[i]));
- }
- if (Element.IsUncertain)
- {
- ConfidenceInterval.Title = (Element.ConfidenceIntervalWidth * 100).ToString("F0", CultureInfo.CurrentCulture) + "% Confidence Interval";
- Plot.Series.Add(ConfidenceInterval);
- Plot.Series.Add(MeanLine);
+ plot.Series.Add(modeLine);
}
- Plot.Series.Add(ModeLine);
+ plot.InvalidatePlot(true);
}
- Plot.InvalidatePlot(true);
+ // Reconnect undo tracking to the new series objects.
+ Element.RebuildSeriesAndAnnotationBridges(plot);
}
///
@@ -409,6 +427,8 @@ public void UpdatePlot()
/// The instance containing the event data.
private void HazardFunctionControl_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
+ if (Plot == null) return;
+
var plotHitResult = VisualTreeHelper.HitTest(Plot, e.GetPosition(Plot));
var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar));
From 93d11afb43bad2eef9c2f9572050aed2fbac808b Mon Sep 17 00:00:00 2001
From: Haden Smith <123974306+HadenSmith@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:15:59 +0000
Subject: [PATCH 04/13] Add themed vector icon for the hazard element
Replace the raster hazard PNG with a theme-aware 16x16 DrawingImage in
a new demo icon dictionary, following the frequency-curve motif of the
RMC-TotalRisk parametric hazard icon: plot axes, an ascending
confidence band, and an accent diamond node marker. The element exposes
ElementImageResourceKey so the project explorer re-resolves the icon on
theme changes, and the New Hazard Element menu items bind their icons
with SetResourceReference for the same reason.
Also harden the element's Open path so scalar values that fail to parse
leave the element defaults in place instead of zeroing them.
---
src/FrameworkUI.Demo/App.xaml | 3 ++
src/FrameworkUI.Demo/FrameworkUI.Demo.csproj | 1 -
.../Model/Hazard Elements/HazardElement.cs | 29 ++++++----
.../Resources/Hazard_Icon.png | Bin 475 -> 0 bytes
.../Resources/IconDictionary.xaml | 51 ++++++++++++++++++
src/FrameworkUI.Demo/UI/DemoProjectNode.cs | 18 ++++++-
6 files changed, 88 insertions(+), 14 deletions(-)
delete mode 100644 src/FrameworkUI.Demo/Resources/Hazard_Icon.png
create mode 100644 src/FrameworkUI.Demo/Resources/IconDictionary.xaml
diff --git a/src/FrameworkUI.Demo/App.xaml b/src/FrameworkUI.Demo/App.xaml
index cbf204ec..3c9f6e68 100644
--- a/src/FrameworkUI.Demo/App.xaml
+++ b/src/FrameworkUI.Demo/App.xaml
@@ -9,6 +9,9 @@
+
+
+
diff --git a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
index 96b9f140..ddc0cee6 100644
--- a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
+++ b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
@@ -52,7 +52,6 @@
-
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
index b34c8aa5..4fcf40a4 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
@@ -226,9 +226,15 @@ public override string Description
#region IElement Properties
///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- ///
- public override ImageSource ElementImage => s_icon.Value;
+ public override ImageSource ElementImage =>
+ System.Windows.Application.Current?.TryFindResource(ElementImageResourceKey) as ImageSource;
+
+ ///
+ /// Gets the resource key for the theme-aware element icon. The framework's project
+ /// explorer and window menus bind to this key with SetResourceReference so the
+ /// icon re-resolves when the theme changes.
+ ///
+ public string ElementImageResourceKey => "HazardElementIcon";
///
public override bool CanCopyFromExternal => true;
@@ -698,14 +704,15 @@ public void Open(SQLiteManager sqlite)
_distributionValid = _parentDistribution != null && _parentDistribution.ParametersValid;
if (!_distributionValid) _messenger.Add(_parentDistMsg);
- // Scalar simulation inputs.
- if (dtView.ColumnNames.Contains(nameof(IsUncertain))) bool.TryParse(dtView.GetCell(nameof(IsUncertain), rowIndex).ToString(), out _isUncertain);
- if (dtView.ColumnNames.Contains(nameof(EffectiveRecordLength))) int.TryParse(dtView.GetCell(nameof(EffectiveRecordLength), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _effectiveRecordLength);
- if (dtView.ColumnNames.Contains(nameof(ConfidenceIntervalWidth))) double.TryParse(dtView.GetCell(nameof(ConfidenceIntervalWidth), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _confidenceIntervalWidth);
- if (dtView.ColumnNames.Contains(nameof(Realizations))) int.TryParse(dtView.GetCell(nameof(Realizations), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _realizations);
- if (dtView.ColumnNames.Contains(nameof(PRNGSeed))) int.TryParse(dtView.GetCell(nameof(PRNGSeed), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _prngSeed);
- if (dtView.ColumnNames.Contains(nameof(EstimationMethod))) Enum.TryParse(dtView.GetCell(nameof(EstimationMethod), rowIndex).ToString(), out _estimationMethod);
- if (dtView.ColumnNames.Contains(nameof(IsEstimated))) bool.TryParse(dtView.GetCell(nameof(IsEstimated), rowIndex).ToString(), out _isEstimated);
+ // Scalar simulation inputs. Parse into temporaries so a value that
+ // fails to parse leaves the element's default in place.
+ if (dtView.ColumnNames.Contains(nameof(IsUncertain)) && bool.TryParse(dtView.GetCell(nameof(IsUncertain), rowIndex).ToString(), out bool isUncertain)) _isUncertain = isUncertain;
+ if (dtView.ColumnNames.Contains(nameof(EffectiveRecordLength)) && int.TryParse(dtView.GetCell(nameof(EffectiveRecordLength), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int erl)) _effectiveRecordLength = erl;
+ if (dtView.ColumnNames.Contains(nameof(ConfidenceIntervalWidth)) && double.TryParse(dtView.GetCell(nameof(ConfidenceIntervalWidth), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out double ciWidth)) _confidenceIntervalWidth = ciWidth;
+ if (dtView.ColumnNames.Contains(nameof(Realizations)) && int.TryParse(dtView.GetCell(nameof(Realizations), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int realizations)) _realizations = realizations;
+ if (dtView.ColumnNames.Contains(nameof(PRNGSeed)) && int.TryParse(dtView.GetCell(nameof(PRNGSeed), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int seed)) _prngSeed = seed;
+ if (dtView.ColumnNames.Contains(nameof(EstimationMethod)) && Enum.TryParse(dtView.GetCell(nameof(EstimationMethod), rowIndex).ToString(), out ParameterEstimationMethod method)) _estimationMethod = method;
+ if (dtView.ColumnNames.Contains(nameof(IsEstimated)) && bool.TryParse(dtView.GetCell(nameof(IsEstimated), rowIndex).ToString(), out bool isEstimated)) _isEstimated = isEstimated;
// Bootstrap results from a compressed byte array BLOB.
if (dtView.ColumnNames.Contains(nameof(Results)))
diff --git a/src/FrameworkUI.Demo/Resources/Hazard_Icon.png b/src/FrameworkUI.Demo/Resources/Hazard_Icon.png
deleted file mode 100644
index e3f7f4099f37b19c5fd661a2dffb4c794766edac..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 475
zcmV<10VMv3P)_~zpuMpTvml$i(m)?W6l
zy`0BoR^q9(8w;v;0GY`4(f$3ypAwB~4~T%Mn14UD7spA-_qTSsUD@e&rR4ity*N&O
z6^+RGVi;EH%_j9`lf^Ks7?JZI#lE$>s=DrmL-H&m&obO_h^nslt=*k1iU_J_h(s<;
zF~(qw!KEpPLsa5|JEu8?(oo8m5&EgJN7l
R2l4;_002ovPDHLkV1nk?#~lCw
diff --git a/src/FrameworkUI.Demo/Resources/IconDictionary.xaml b/src/FrameworkUI.Demo/Resources/IconDictionary.xaml
new file mode 100644
index 00000000..4842b694
--- /dev/null
+++ b/src/FrameworkUI.Demo/Resources/IconDictionary.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
index 84b0ecc4..d4e2af19 100644
--- a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
+++ b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
@@ -78,6 +78,20 @@ public DemoProjectNode(IProject project) : base(project)
/// run operations for analysis collections. The method disables the default "Create New" context item and
/// replaces it with custom, type-specific menu items.
///
+ ///
+ /// Creates an Image whose Source is bound to a theme-aware icon resource key.
+ /// SetResourceReference keeps the icon live across theme changes, unlike a
+ /// one-time StaticResource lookup.
+ ///
+ /// The icon resource key to bind to.
+ /// An Image bound to the themed icon resource.
+ private static Image CreateThemedIcon(string resourceKey)
+ {
+ var img = new Image();
+ img.SetResourceReference(Image.SourceProperty, resourceKey);
+ return img;
+ }
+
protected override void DefineProjectExplorerMenuItems()
{
for (int i = 0; i < ChildNodes.Count; i++)
@@ -91,7 +105,7 @@ protected override void DefineProjectExplorerMenuItems()
// Hazard Element
if (elementNodeCollection.ElementCollection.GetType() == typeof(HazardElementCollection))
{
- MenuItem menuItem = new MenuItem() { Header = "New HazardElement...", Icon = new Image() { Source = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")) } };
+ MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = CreateThemedIcon("HazardElementIcon") };
menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewHazardElement(elementNodeCollection);
elementNodeCollection.CustomContextItems.Add(menuItem);
}
@@ -118,7 +132,7 @@ protected override void DefineProjectMenuItems()
// Hazard Element
if (collection.GetType() == typeof(HazardElementCollection))
{
- MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = new Image() { Source = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")) } };
+ MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = CreateThemedIcon("HazardElementIcon") };
menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewHazardElement_Click(collection);
_projectMenuItems.Add(menuItem);
}
From e4a8574cf2ee8dc218abcac26b6bc8cdb4fea545 Mon Sep 17 00:00:00 2001
From: HadenSmith
Date: Tue, 21 Jul 2026 13:40:06 -0600
Subject: [PATCH 05/13] Stabilize demo hazard workflow
---
src/FrameworkUI.Demo/App.xaml | 7 +-
src/FrameworkUI.Demo/App.xaml.cs | 41 +--
src/FrameworkUI.Demo/Model/DemoProject.cs | 11 +
.../Model/Hazard Elements/HazardElement.cs | 172 ++++++++---
.../HazardElementCollection.cs | 37 ++-
src/FrameworkUI.Demo/UI/DemoProjectNode.cs | 3 +-
.../UI/Hazard Controls/HazardControl.xaml.cs | 118 +++++---
.../HazardPropertiesControl.xaml | 3 +-
.../HazardPropertiesControl.xaml.cs | 176 ++++++++---
.../FrameworkUI.Tests.csproj | 1 +
.../FrameworkUIDemo/HazardWorkflowTests.cs | 284 ++++++++++++++++++
11 files changed, 669 insertions(+), 184 deletions(-)
create mode 100644 tests/FrameworkUI.Tests/FrameworkUIDemo/HazardWorkflowTests.cs
diff --git a/src/FrameworkUI.Demo/App.xaml b/src/FrameworkUI.Demo/App.xaml
index 3c9f6e68..6dbc900f 100644
--- a/src/FrameworkUI.Demo/App.xaml
+++ b/src/FrameworkUI.Demo/App.xaml
@@ -2,7 +2,9 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:FrameworkUI.Demo"
- Startup="Application_Startup">
+ Startup="Application_Startup"
+ ShutdownMode="OnMainWindowClose"
+ DispatcherUnhandledException="Application_DispatcherUnhandledException">
@@ -21,6 +23,9 @@
+
+
+
diff --git a/src/FrameworkUI.Demo/App.xaml.cs b/src/FrameworkUI.Demo/App.xaml.cs
index 26481606..0a1d7bb3 100644
--- a/src/FrameworkUI.Demo/App.xaml.cs
+++ b/src/FrameworkUI.Demo/App.xaml.cs
@@ -2,8 +2,7 @@
using SoftwareUpdate;
using SoftwareUpdate.GitHub;
using SoftwareUpdate.Utilities;
-using System;
-using System.IO;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Shell;
@@ -50,23 +49,6 @@ public App()
System.Windows.Markup.XmlLanguage.GetLanguage(
System.Globalization.CultureInfo.CurrentCulture.IetfLanguageTag)));
- // Set up global exception handlers for debugging
- AppDomain.CurrentDomain.UnhandledException += (s, args) =>
- {
- var ex = args.ExceptionObject as Exception;
- LogException("AppDomain.UnhandledException", ex);
- };
-
- DispatcherUnhandledException += (s, args) =>
- {
- LogException("Dispatcher.UnhandledException", args.Exception);
- args.Handled = true; // Prevent immediate crash to see the error
- };
-
- TaskScheduler.UnobservedTaskException += (s, args) =>
- {
- LogException("TaskScheduler.UnobservedTaskException", args.Exception);
- };
var jList = new JumpList { ShowRecentCategory = false };
jList.Apply();
@@ -74,22 +56,17 @@ public App()
}
///
- /// Logs an exception to both debug output and a file for diagnosis.
+ /// Handles dispatcher exceptions that are known to be benign.
///
- private static void LogException(string source, Exception? ex)
+ /// The event sender.
+ /// The dispatcher exception event arguments.
+ private void Application_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
- var separator = new string('=', 50);
- var message = $"\n{separator}\n{source}\n{separator}\n{ex}\n";
- System.Diagnostics.Debug.WriteLine(message);
-
- // Also write to a file so we can see it
- try
+ System.Diagnostics.Debug.WriteLine($"Application.DispatcherUnhandledException: {e.Exception}");
+ if (e.Exception is COMException comException && comException.ErrorCode == -2147221040)
{
- var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "FrameworkUI_Demo_Error.txt");
- File.AppendAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n{message}\n\n");
- GenericControls.MessageBox.Show($"Exception logged to: {logPath}\n\n{ex?.Message}", source, MessageBoxButton.OK, MessageBoxImage.Error);
+ e.Handled = true;
}
- catch (Exception logEx) { System.Diagnostics.Debug.WriteLine($"Failed to write exception log: {logEx.Message}"); }
}
///
@@ -139,7 +116,7 @@ private void Application_Startup(object sender, StartupEventArgs e)
// =================================================================
// Auto-check settings (not part of UpdateOptions, handled locally)
- bool autoCheckOnStartup = true;
+ bool autoCheckOnStartup = false;
int autoCheckDelayMs = 5000; // 5 second delay after startup
// Configure update options for your GitHub repository
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index a77ceecd..89acec49 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -98,6 +98,7 @@ private void InitializeMessages()
private static readonly char[] _InvalidNameCharacters = new List(Path.GetInvalidFileNameChars()) { '\'', '[', ']' }.ToArray();
private HazardElementCollection _hazardFunctions;
private bool _nameValid = false;
+ private bool _collectionEventsSubscribed;
private List _messages = new List();
private Messenger _messenger = Messenger.GetInstance();
private BasicMessageItem _descriptionMsg;
@@ -265,9 +266,12 @@ public override bool IsValid()
///
private void SubscribeCollectionEvents()
{
+ if (_collectionEventsSubscribed) return;
+
_hazardFunctions.ObjectSaved += ElementCollection_Saved;
_hazardFunctions.ElementIsDirtyChanged += OnElementIsDirtyChanged;
_hazardFunctions.PropertyChanged += OnCollectionPropertyChanged;
+ _collectionEventsSubscribed = true;
}
///
@@ -275,9 +279,12 @@ private void SubscribeCollectionEvents()
///
private void UnsubscribeCollectionEvents()
{
+ if (!_collectionEventsSubscribed) return;
+
_hazardFunctions.ObjectSaved -= ElementCollection_Saved;
_hazardFunctions.ElementIsDirtyChanged -= OnElementIsDirtyChanged;
_hazardFunctions.PropertyChanged -= OnCollectionPropertyChanged;
+ _collectionEventsSubscribed = false;
}
///
@@ -397,6 +404,7 @@ public override void CreateNew(string fullFileName)
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
{ _hazardFunctions });
+ SubscribeCollectionEvents();
Save();
}
@@ -425,6 +433,7 @@ public void CreateNewDummyProject()
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
{ _hazardFunctions });
+ SubscribeCollectionEvents();
Save();
}
@@ -508,6 +517,7 @@ public override void Open()
// Load element collections.
_readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
{ _hazardFunctions });
+ SubscribeCollectionEvents();
NameOnDisk = Name;
@@ -528,6 +538,7 @@ public override void Open()
///
public override void Close()
{
+ UnsubscribeCollectionEvents();
_messenger.Clear(this);
// Close all project element collections.
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
index 4fcf40a4..6b052dd6 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
@@ -2,6 +2,7 @@
using FrameworkInterfaces;
using FrameworkInterfaces.Messaging;
using FrameworkInterfaces.Undo;
+using FrameworkInterfaces.Undo.Actions;
using Numerics.Distributions;
using Numerics.Data;
using OxyPlot.Wpf;
@@ -259,6 +260,7 @@ public override string Description
private UndoableCollectionBridge _ordinatesBridge;
private Plot _frequencyPlot;
private PlotUndoManager _frequencyPlotUndo;
+ private XElement _distributionSnapshot;
private bool _isEstimated = false;
private bool _minmaxComputed = false;
private double[] _minmax = new double[2];
@@ -300,25 +302,20 @@ public UnivariateDistributionBase ParentDistribution
get => _parentDistribution;
set
{
- if (_parentDistribution != value)
- {
- var oldValue = _parentDistribution;
- _parentDistribution = value;
+ if (value == null) return;
- // Validate parent distribution parameters
- _distributionValid = true;
- _messenger.Remove(_parentDistMsg);
- if (!_parentDistribution.ParametersValid)
- {
- _distributionValid = false;
- _messenger.Add(_parentDistMsg);
- }
+ var oldSnapshot = _parentDistribution?.ToXElement();
+ var incomingSnapshot = value.ToXElement();
+ if (oldSnapshot != null && XNode.DeepEquals(oldSnapshot, incomingSnapshot)) return;
- IsEstimated = false;
- ClearResults();
- ValidateEstimationMethod();
- RecordPropertyChange(nameof(ParentDistribution), oldValue, value);
- }
+ _parentDistribution = value.Clone();
+ IsEstimated = false;
+ ClearResults();
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
+ RecordDistributionUndo(nameof(ParentDistribution), oldSnapshot, _parentDistribution.ToXElement());
+ _distributionSnapshot = _parentDistribution.ToXElement();
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
}
}
@@ -1002,6 +999,13 @@ private void ValidateEstimationMethod()
_messenger.Remove(_pmomMsg);
_messenger.Remove(_lmomMsg);
+ if (ParentDistribution == null)
+ {
+ _estimationMethodValid = false;
+ SetElementValidation();
+ return;
+ }
+
if (IsUncertain && EstimationMethod == ParameterEstimationMethod.MethodOfMoments)
{
if (ParentDistribution.Type == UnivariateDistributionType.GeneralizedNormal ||
@@ -1024,6 +1028,17 @@ private void ValidateEstimationMethod()
SetElementValidation();
}
+ private void ValidateParentDistribution()
+ {
+ _distributionValid = true;
+ _messenger.Remove(_parentDistMsg);
+ if (_parentDistribution == null || !_parentDistribution.ParametersValid)
+ {
+ _distributionValid = false;
+ _messenger.Add(_parentDistMsg);
+ }
+ }
+
///
/// Sets the IsValid property for the element, this needs to be called whenever a property is changed.
///
@@ -1069,29 +1084,36 @@ private void ClearResults()
/// The list of parameters.
public void SetDistributionParameters(IList parameters)
{
+ if (parameters == null || ParentDistribution == null) return;
if (parameters.Count != ParentDistribution.NumberOfParameters) return;
+
+ var oldSnapshot = ParentDistribution.ToXElement();
var parms = ParentDistribution.GetParameters;
+ bool changed = false;
for (int i = 0; i < ParentDistribution.NumberOfParameters; i++)
{
if (parms[i] != parameters[i])
{
- IsEstimated = false;
- ClearResults();
- RaisePropertyChange("Distribution Parameters");
+ changed = true;
break;
}
}
ParentDistribution.SetParameters(parameters);
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
- _distributionValid = true;
- _messenger.Remove(_parentDistMsg);
- if (!ParentDistribution.ParametersValid)
+ if (changed)
{
- _distributionValid = false;
- _messenger.Add(_parentDistMsg);
+ IsEstimated = false;
+ ClearResults();
+ RecordDistributionUndo("Distribution Parameters", oldSnapshot, ParentDistribution.ToXElement());
+ _distributionSnapshot = ParentDistribution.ToXElement();
}
+
+ RaisePropertyChange("Distribution Parameters", setDirty: false);
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
}
///
@@ -1153,6 +1175,8 @@ public IUnivariateDistribution SampleFunction()
if (IsUncertain)
{
+ if (!HasCompatibleCurveResults()) return null;
+
var xValues = Results.MeanCurve.ToArray();
var pValues = new double[ProbabilityOrdinates.Count];
@@ -1179,17 +1203,16 @@ public IUnivariateDistribution SampleFunction()
public IUnivariateDistribution SampleFunction(double percentile)
{
if (!IsEstimated) return null;
+ if (double.IsNaN(percentile) || percentile < 0 || percentile > 1) return null;
if (IsUncertain)
{
- var distribution = ParentDistribution.Clone();
- // Get parameter set
- int i = Math.Min((int)Math.Floor(percentile * Realizations), Realizations - 1);
- if (Results?.ParameterSets?[i].Values != null)
- {
- distribution.SetParameters(Results.ParameterSets[i].Values);
- }
- return distribution;
+ var parameterSets = Results?.ParameterSets;
+ if (parameterSets == null || parameterSets.Length == 0) return null;
+
+ int index = (int)Math.Floor(percentile * parameterSets.Length);
+ if (index >= parameterSets.Length) index = parameterSets.Length - 1;
+ return SampleFunction(index);
}
else
{
@@ -1204,16 +1227,15 @@ public IUnivariateDistribution SampleFunction(double percentile)
public IUnivariateDistribution SampleFunction(int index)
{
if (!IsEstimated) return null;
- if (index < 0 || index > Realizations - 1) return null;
if (IsUncertain)
{
+ var parameterSets = Results?.ParameterSets;
+ if (parameterSets == null || index < 0 || index >= parameterSets.Length) return null;
+ if (parameterSets[index].Values == null) return null;
+
var distribution = ParentDistribution.Clone();
- // Get parameter set
- if (Results?.ParameterSets?[index].Values != null)
- {
- distribution.SetParameters(Results.ParameterSets[index].Values);
- }
+ distribution.SetParameters(parameterSets[index].Values);
return distribution;
}
else
@@ -1249,6 +1271,14 @@ public double MaxHazard(bool meanOnly)
///
private void ComputeMinMax(bool meanOnly)
{
+ if (ProbabilityOrdinates == null || ProbabilityOrdinates.Count == 0)
+ {
+ _minmax[0] = double.NaN;
+ _minmax[1] = double.NaN;
+ _minmaxComputed = true;
+ return;
+ }
+
double minP = Numerics.Tools.Min(ProbabilityOrdinates);
double maxP = Numerics.Tools.Max(ProbabilityOrdinates);
@@ -1258,16 +1288,13 @@ private void ComputeMinMax(bool meanOnly)
_minmax[0] = dist == null ? double.NaN : dist.InverseCDF(1 - maxP);
_minmax[1] = dist == null ? double.NaN : dist.InverseCDF(1 - minP);
}
- else if (!meanOnly && IsUncertain && Results != null)
+ else if (!meanOnly && IsUncertain && Results?.ParameterSets != null && Results.ParameterSets.Length > 0)
{
- _minmax[0] = double.MaxValue;
- _minmax[1] = double.MinValue;
-
double localMin = double.MaxValue;
double localMax = double.MinValue;
object lockObj = new object();
- Parallel.For(0, Realizations, idx =>
+ Parallel.For(0, Results.ParameterSets.Length, idx =>
{
var dist = ParentDistribution.Clone();
if (Results?.ParameterSets?[idx].Values != null)
@@ -1283,8 +1310,8 @@ private void ComputeMinMax(bool meanOnly)
}
});
- _minmax[0] = localMin;
- _minmax[1] = localMax;
+ _minmax[0] = localMin == double.MaxValue ? double.NaN : localMin;
+ _minmax[1] = localMax == double.MinValue ? double.NaN : localMax;
}
else
{
@@ -1295,6 +1322,20 @@ private void ComputeMinMax(bool meanOnly)
_minmaxComputed = true;
}
+ private bool HasCompatibleCurveResults()
+ {
+ int count = ProbabilityOrdinates?.Count ?? 0;
+ if (count == 0 || Results?.ModeCurve == null || Results.ModeCurve.Length != count) return false;
+
+ if (IsUncertain)
+ {
+ if (Results.MeanCurve == null || Results.MeanCurve.Length != count) return false;
+ if (Results.ConfidenceIntervals == null || Results.ConfidenceIntervals.GetLength(0) != count || Results.ConfidenceIntervals.GetLength(1) < 2) return false;
+ }
+
+ return true;
+ }
+
#endregion
#region Plot Factory Methods
@@ -1354,6 +1395,42 @@ private static Plot CreateDefaultFrequencyPlot()
#endregion
+ #region Distribution Undo
+
+ private void RecordDistributionUndo(string propertyName, XElement oldSnapshot, XElement newSnapshot)
+ {
+ var undoManager = IsUndoEnabled ? UndoManager : null;
+ if (undoManager == null || undoManager.IsExecutingAction) return;
+ if (oldSnapshot == null || newSnapshot == null) return;
+ if (XNode.DeepEquals(oldSnapshot, newSnapshot)) return;
+
+ var undoSnapshot = new XElement(oldSnapshot);
+ var redoSnapshot = new XElement(newSnapshot);
+ var action = new DelegateAction(
+ $"Change {propertyName}",
+ () => RestoreDistributionFromSnapshot(redoSnapshot),
+ () => RestoreDistributionFromSnapshot(undoSnapshot),
+ this);
+ undoManager.RecordAction(action);
+ SetIsDirty(true);
+ }
+
+ private void RestoreDistributionFromSnapshot(XElement snapshot)
+ {
+ if (snapshot == null) return;
+
+ _parentDistribution = UnivariateDistributionFactory.CreateDistribution(new XElement(snapshot));
+ IsEstimated = false;
+ ClearResults();
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
+ _distributionSnapshot = _parentDistribution.ToXElement();
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
+ RaisePropertyChange("Distribution Parameters", setDirty: false);
+ }
+
+ #endregion
+
#region Undo Bridge Management
///
@@ -1375,6 +1452,8 @@ private void SetupBridges()
"probability ordinates",
this);
+ _distributionSnapshot = _parentDistribution?.ToXElement();
+
// Plot undo manager for the element-owned frequency plot.
if (_frequencyPlot != null)
{
@@ -1409,6 +1488,7 @@ private void DisposeBridges()
_frequencyPlotUndo?.Dispose();
_frequencyPlotUndo = null;
+ _distributionSnapshot = null;
}
///
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
index 97e8f982..f0f7469d 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
@@ -163,11 +163,17 @@ public override void Add(IElement item)
{
var sqlite = new SQLiteManager(ParentProject.FullFileName);
sqlite.Open();
- if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ try
{
- item.Save();
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
}
- sqlite.Close();
SetIsDirty(true);
}
RaiseElementAddedEvent(item);
@@ -191,11 +197,17 @@ public override void Insert(int index, IElement item)
{
var sqlite = new SQLiteManager(ParentProject.FullFileName);
sqlite.Open();
- if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ try
{
- item.Save();
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
}
- sqlite.Close();
SetIsDirty(true);
}
RaiseElementAddedEvent(item);
@@ -212,7 +224,8 @@ public override void Insert(int index, IElement item)
public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
{
var element = new HazardElement(elementName, this);
- Insert(index, element.CopyFromExternal(elementName, fullFileName));
+ element.Open(new SQLiteManager(fullFileName));
+ Insert(index, element);
}
///
@@ -222,8 +235,14 @@ public override void Delete()
{
var sqlite = new SQLiteManager(ParentProject.FullFileName);
sqlite.Open();
- sqlite.DeleteTable(Name);
- sqlite.Close();
+ try
+ {
+ sqlite.DeleteTable(Name);
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
}
}
}
diff --git a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
index d4e2af19..e37a71cf 100644
--- a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
+++ b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
@@ -146,10 +146,11 @@ protected override void DefineProjectMenuItems()
/// Creates a new hazard element with a user-specified name.
///
/// The element node collection to add the new element to.
- /// The newly created hazard element.
+ /// The newly created hazard element, or when the dialog is canceled.
private HazardElement CreateNewHazardElement(ElementNodeCollection elementNodes)
{
string newName = CreateNewNameDialog($"Create New {elementNodes.ElementCollection.Name}...", $"{elementNodes.ElementCollection.Name}_{elementNodes.ElementCollection.Count + 1}", elementNodes.ElementCollection.Select(x => x.Name.ToString()).ToList());
+ if (newName == "") return null;
var newHazard = new HazardElement(newName, elementNodes.ElementCollection);
elementNodes.ElementCollection.Add(newHazard);
return newHazard;
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
index e0f39d86..8eca3b05 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
@@ -55,7 +55,7 @@ public HazardControl()
#region Members
- private bool _isLoaded = false;
+ private bool _themeSubscribed;
private List _frequencyCurveList = new List();
private List _summaryStatisticsList = new List();
@@ -186,35 +186,19 @@ public HazardElement Element
/// The instance containing the event data.
private void HazardFunctionControl_Loaded(object sender, RoutedEventArgs e)
{
- if (!_isLoaded)
+ UpdatePlot();
+ SetColumnStringFormats();
+ BindFrequencyCurveTable();
+ SetFrequencyCurveTableColumnHeaders();
+ BindSummaryStatisticsDataGrid();
+ BindParameterSetDataGrid();
+ UpdateUncertaintyVisibility();
+
+ if (!_themeSubscribed)
{
- UpdatePlot();
- SetColumnStringFormats();
- BindFrequencyCurveTable();
- SetFrequencyCurveTableColumnHeaders();
- BindSummaryStatisticsDataGrid();
- BindParameterSetDataGrid();
-
- if (Element != null && Element.IsUncertain)
- {
- UpperColumn.Visibility = Visibility.Visible;
- LowerColumn.Visibility = Visibility.Visible;
- PredictiveColumn.Visibility = Visibility.Visible;
- ParameterSetsTab.Visibility = Visibility.Visible;
- }
- else
- {
- ParameterSetsTab.Visibility = Visibility.Collapsed;
- UpperColumn.Visibility = Visibility.Collapsed;
- LowerColumn.Visibility = Visibility.Collapsed;
- PredictiveColumn.Visibility = Visibility.Collapsed;
- }
-
- // Subscribe to theme changes to refresh plot when theme changes
ThemeService.Instance.ThemeChanged += OnThemeChanged;
+ _themeSubscribed = true;
}
-
- _isLoaded = true;
}
///
@@ -224,8 +208,11 @@ private void HazardFunctionControl_Loaded(object sender, RoutedEventArgs e)
/// The instance containing the event data.
private void HazardFunctionControl_Unloaded(object sender, RoutedEventArgs e)
{
- // Unsubscribe from theme changes to prevent memory leaks
- ThemeService.Instance.ThemeChanged -= OnThemeChanged;
+ if (_themeSubscribed)
+ {
+ ThemeService.Instance.ThemeChanged -= OnThemeChanged;
+ _themeSubscribed = false;
+ }
}
///
@@ -290,20 +277,7 @@ private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventAr
if (e.PropertyName == nameof(Element.IsUncertain))
{
- if (Element != null && Element.IsUncertain)
- {
- UpperColumn.Visibility = Visibility.Visible;
- LowerColumn.Visibility = Visibility.Visible;
- PredictiveColumn.Visibility = Visibility.Visible;
- ParameterSetsTab.Visibility = Visibility.Visible;
- }
- else
- {
- ParameterSetsTab.Visibility = Visibility.Collapsed;
- UpperColumn.Visibility = Visibility.Collapsed;
- LowerColumn.Visibility = Visibility.Collapsed;
- PredictiveColumn.Visibility = Visibility.Collapsed;
- }
+ UpdateUncertaintyVisibility();
}
}
@@ -387,9 +361,11 @@ public void UpdatePlot()
MeanLinePoints.Clear();
ModeLinePoints.Clear();
- if (Element.IsEstimated && Element.Results != null)
+ int resultPointCount = GetCompatibleResultPointCount();
+
+ if (Element.IsEstimated && resultPointCount > 0)
{
- for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
+ for (int i = 0; i < resultPointCount; i++)
{
double aep = Element.ProbabilityOrdinates[i];
@@ -420,6 +396,33 @@ public void UpdatePlot()
Element.RebuildSeriesAndAnnotationBridges(plot);
}
+ private int GetCompatibleResultPointCount()
+ {
+ var element = Element;
+ if (element?.Results == null || !element.IsEstimated) return 0;
+
+ int count = element.ProbabilityOrdinates?.Count ?? 0;
+ if (count == 0) return 0;
+ if (element.Results.ModeCurve == null || element.Results.ModeCurve.Length != count) return 0;
+
+ if (element.IsUncertain)
+ {
+ if (element.Results.MeanCurve == null || element.Results.MeanCurve.Length != count) return 0;
+ if (element.Results.ConfidenceIntervals == null || element.Results.ConfidenceIntervals.GetLength(0) != count || element.Results.ConfidenceIntervals.GetLength(1) < 2) return 0;
+ }
+
+ return count;
+ }
+
+ private void UpdateUncertaintyVisibility()
+ {
+ bool showUncertainty = Element != null && Element.IsUncertain;
+ UpperColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ LowerColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ PredictiveColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ ParameterSetsTab.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ }
+
///
/// Handles the PreviewMouseDown event to detect clicks on the plot or toolbar.
///
@@ -458,6 +461,8 @@ private void HazardFunctionControl_LostFocus(object sender, RoutedEventArgs e)
///
private void SetFrequencyCurveTableColumnHeaders()
{
+ if (Element == null) return;
+
double alpha = (1 - Element.ConfidenceIntervalWidth) / 2;
UpperColumn.Header = ((1 - alpha) * 100).ToString("F1", CultureInfo.CurrentCulture) + "%-ile";
LowerColumn.Header = (alpha * 100).ToString("F1", CultureInfo.CurrentCulture) + "%-ile";
@@ -487,11 +492,16 @@ public void BindFrequencyCurveTable()
FrequencyCurveTable.ItemsSource = null;
_frequencyCurveList.Clear();
- if (Element.Results == null) return;
+ if (Element == null)
+ {
+ FrequencyCurveTable.Items.Refresh();
+ return;
+ }
- if (Element.IsEstimated)
+ int resultPointCount = GetCompatibleResultPointCount();
+ if (Element.IsEstimated && resultPointCount > 0)
{
- for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
+ for (int i = 0; i < resultPointCount; i++)
{
double aep = Element.ProbabilityOrdinates[i];
@@ -569,6 +579,12 @@ public void BindSummaryStatisticsDataGrid()
SummaryStatisticsTable.ItemsSource = null;
_summaryStatisticsList.Clear();
+ if (Element == null)
+ {
+ SummaryStatisticsTable.Items.Refresh();
+ return;
+ }
+
if (Element.IsEstimated)
{
// Get parameters
@@ -648,7 +664,11 @@ private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventAr
///
private void BindParameterSetDataGrid()
{
- if (Element.Results == null) return;
+ if (Element?.Results == null)
+ {
+ ParameterSetTableViewer.DataView = null;
+ return;
+ }
if (!Element.IsUncertain)
{
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
index e401a659..6dafda08 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
@@ -219,7 +219,8 @@
Grid.ColumnSpan="2"
Style="{DynamicResource PropertyExpanderStyle}">
-
+
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
index 038e7b8f..434ebad2 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
@@ -58,6 +58,7 @@ public HazardPropertiesControl()
private ComboBox _combobox = new ComboBox();
private string _previousName = string.Empty;
private ObservableCollection _parameterList = new ObservableCollection();
+ private bool _syncingDistributionSelection;
///
/// Identifies the dependency property.
@@ -80,32 +81,27 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
var thisControl = (HazardPropertiesControl)d;
- // Remove any handlers from the old element
- var oldElement = e.OldValue as HazardElement;
- if (oldElement != null)
+ if (e.OldValue is HazardElement oldElement)
{
oldElement.PropertyChanged -= thisControl.Element_PropertyChanged;
- thisControl._combobox.SelectionChanged -= thisControl.DistributionCombobox_SelectionChanged;
}
- var newElement = e.NewValue as HazardElement;
- if (newElement != null)
- {
- for (int i = 0; i < thisControl.DistributionOptions.Length; i++)
- {
- if (thisControl.DistributionOptions[i].Type == newElement.ParentDistribution.Type)
- {
- thisControl._combobox.SelectedIndex = i;
- break;
- }
- }
+ thisControl._combobox.SelectionChanged -= thisControl.DistributionCombobox_SelectionChanged;
+ thisControl.ClearParameterRows();
- newElement.PropertyChanged += thisControl.Element_PropertyChanged;
- thisControl._combobox.SelectionChanged += thisControl.DistributionCombobox_SelectionChanged;
- thisControl.UpdateParameterDataGrid();
- thisControl.SetDataGridStyle();
- thisControl.ParametersTable.ItemsSource = thisControl._parameterList;
+ if (e.NewValue is not HazardElement newElement)
+ {
+ thisControl.SyncDistributionSelection(null);
+ thisControl.ParametersTable.ItemsSource = null;
+ return;
}
+
+ thisControl.SyncDistributionSelection(newElement.ParentDistribution?.Type);
+ newElement.PropertyChanged += thisControl.Element_PropertyChanged;
+ thisControl._combobox.SelectionChanged += thisControl.DistributionCombobox_SelectionChanged;
+ thisControl.PropertyAttributes.GetClassAttributes(newElement);
+ thisControl.UpdateParameterDataGrid();
+ thisControl.SetDataGridStyle();
}
///
@@ -141,30 +137,63 @@ public string[] ExistingNames
private set => SetValue(ExistingNamesProperty, value);
}
- private static readonly UnivariateDistributionBase[] _distributionOptions = new[] {
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Exponential),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GammaDistribution),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedExtremeValue),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedLogistic),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedPareto),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Gumbel),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.KappaFour),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LnNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LogNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LogPearsonTypeIII),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Normal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.PearsonTypeIII)
+ private static readonly DistributionOption[] _distributionOptions = new[] {
+ CreateDistributionOption(UnivariateDistributionType.Exponential),
+ CreateDistributionOption(UnivariateDistributionType.GammaDistribution),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedExtremeValue),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedLogistic),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedNormal),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedPareto),
+ CreateDistributionOption(UnivariateDistributionType.Gumbel),
+ CreateDistributionOption(UnivariateDistributionType.KappaFour),
+ CreateDistributionOption(UnivariateDistributionType.LnNormal),
+ CreateDistributionOption(UnivariateDistributionType.LogNormal),
+ CreateDistributionOption(UnivariateDistributionType.LogPearsonTypeIII),
+ CreateDistributionOption(UnivariateDistributionType.Normal),
+ CreateDistributionOption(UnivariateDistributionType.PearsonTypeIII)
};
///
/// Gets the available parent distribution options.
///
///
- /// An array of instances representing
- /// the supported probability distributions for parametric hazard functions.
+ /// An array of distribution option items representing the supported probability
+ /// distributions for parametric hazard functions.
///
- public UnivariateDistributionBase[] DistributionOptions => _distributionOptions;
+ public DistributionOption[] DistributionOptions => _distributionOptions;
+
+ ///
+ /// Lightweight distribution choice used by the parent-distribution combo box.
+ ///
+ public sealed class DistributionOption
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The display name shown in the combo box.
+ /// The distribution type to create when selected.
+ public DistributionOption(string displayName, UnivariateDistributionType type)
+ {
+ DisplayName = displayName;
+ Type = type;
+ }
+
+ ///
+ /// Gets the display name shown in the combo box.
+ ///
+ public string DisplayName { get; }
+
+ ///
+ /// Gets the distribution type represented by this option.
+ ///
+ public UnivariateDistributionType Type { get; }
+ }
+
+ private static DistributionOption CreateDistributionOption(UnivariateDistributionType type)
+ {
+ var distribution = UnivariateDistributionFactory.CreateDistribution(type);
+ return new DistributionOption(distribution.DisplayName, type);
+ }
private static readonly ObservableCollection _confidenceIntervalWidthList = new ObservableCollection(new[]
{
@@ -341,6 +370,16 @@ private void DataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEvent
PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution.");
}
+ ///
+ /// Handles the PreviewMouseLeftButtonDown event for the ProbabilityOrdinatesControl.
+ ///
+ /// The source of the event.
+ /// The instance containing the event data.
+ private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution.");
+ }
+
///
/// Handles the PreviewMouseLeftButtonDown event for the TabItem control to display property attributes.
///
@@ -393,7 +432,9 @@ private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Element.ParentDistribution))
{
+ SyncDistributionSelection(Element?.ParentDistribution?.Type);
UpdateParameterDataGrid();
+ SetDataGridStyle();
}
}
@@ -404,9 +445,10 @@ private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e)
/// The instance containing the event data.
private void DistributionCombobox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (_combobox.SelectedIndex != -1)
+ if (_syncingDistributionSelection || Element == null) return;
+ if (_combobox.SelectedItem is DistributionOption option)
{
- Element.ParentDistribution = (UnivariateDistributionBase)_combobox.SelectedItem;
+ Element.ParentDistribution = UnivariateDistributionFactory.CreateDistribution(option.Type);
}
}
@@ -415,7 +457,13 @@ private void DistributionCombobox_SelectionChanged(object sender, SelectionChang
///
private void UpdateParameterDataGrid()
{
- _parameterList.Clear();
+ ClearParameterRows();
+ if (Element?.ParentDistribution == null)
+ {
+ ParametersTable.ItemsSource = null;
+ return;
+ }
+
var names = Element.ParentDistribution.GetParameterPropertyNames;
var display = Element.ParentDistribution.ParametersToString;
var parms = Element.ParentDistribution.GetParameters;
@@ -426,6 +474,8 @@ private void UpdateParameterDataGrid()
parm.PropertyChanged += ParameterChanged;
_parameterList.Add(parm);
}
+
+ ParametersTable.ItemsSource = _parameterList;
}
///
@@ -443,6 +493,8 @@ private void ParameterChanged(object sender, PropertyChangedEventArgs e)
_parameterList[i].ErrorMessage = null;
}
+ if (Element == null) return;
+
Element.SetDistributionParameters(_parameterList.Select(x => x.Value).ToArray());
if (!Element.ParentDistribution.ParametersValid)
@@ -473,8 +525,9 @@ private void SetInvalidParameter(string parameterName, string errorMessage)
///
private void SetDataGridStyle()
{
- NameColumn.CellStyle = (Style)FindResource("Left_CellStyle");
- ValueColumn.CellStyle = (Style)FindResource("Right_CellStyle");
+ if (TryFindResource("Left_CellStyle") is Style leftStyle) NameColumn.CellStyle = leftStyle;
+ if (TryFindResource("Right_CellStyle") is Style rightStyle) ValueColumn.CellStyle = rightStyle;
+ if (ValueColumn.CellStyle == null) return;
var style = new Style();
if (style != null)
@@ -507,9 +560,42 @@ private void SetDataGridStyle()
/// The instance containing the event data.
private void SimulateButton_Click(object sender, RoutedEventArgs e)
{
- Mouse.OverrideCursor = Cursors.Wait;
- Element.Estimate();
- Mouse.OverrideCursor = null;
+ if (Element == null) return;
+
+ try
+ {
+ Mouse.OverrideCursor = Cursors.Wait;
+ Element.Estimate();
+ }
+ finally
+ {
+ Mouse.OverrideCursor = null;
+ }
+ }
+
+ private void SyncDistributionSelection(UnivariateDistributionType? distributionType)
+ {
+ _syncingDistributionSelection = true;
+ try
+ {
+ _combobox.SelectedItem = distributionType.HasValue
+ ? DistributionOptions.FirstOrDefault(x => x.Type == distributionType.Value)
+ : null;
+ }
+ finally
+ {
+ _syncingDistributionSelection = false;
+ }
+ }
+
+ private void ClearParameterRows()
+ {
+ foreach (var parameter in _parameterList)
+ {
+ parameter.PropertyChanged -= ParameterChanged;
+ }
+
+ _parameterList.Clear();
}
}
}
diff --git a/tests/FrameworkUI.Tests/FrameworkUI.Tests.csproj b/tests/FrameworkUI.Tests/FrameworkUI.Tests.csproj
index a7559a0c..d3be993b 100644
--- a/tests/FrameworkUI.Tests/FrameworkUI.Tests.csproj
+++ b/tests/FrameworkUI.Tests/FrameworkUI.Tests.csproj
@@ -26,6 +26,7 @@
+
diff --git a/tests/FrameworkUI.Tests/FrameworkUIDemo/HazardWorkflowTests.cs b/tests/FrameworkUI.Tests/FrameworkUIDemo/HazardWorkflowTests.cs
new file mode 100644
index 00000000..ae524ba0
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FrameworkUIDemo/HazardWorkflowTests.cs
@@ -0,0 +1,284 @@
+using System.Threading;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Threading;
+using FrameworkUI.Demo;
+using FrameworkUI.Demo.UI;
+using NumericControls;
+using Numerics.Distributions;
+using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
+
+namespace FrameworkUI.Tests.FrameworkUIDemo;
+
+public class HazardWorkflowTests
+{
+ [Fact]
+ public void SettingElement_OnPropertiesControl_DoesNotThrowAndKeepsProbabilityOrdinatesOneWay()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var resources = EnsureApplicationResources();
+ var element = CreateElement("Hazard_Binding");
+ var control = new HazardPropertiesControl();
+
+ var exception = Record.Exception(() => control.Element = element);
+
+ Assert.Null(exception);
+ control.ApplyTemplate();
+ control.UpdateLayout();
+ var ordinatesControl = FindDescendant(control);
+ Assert.NotNull(ordinatesControl);
+
+ var binding = BindingOperations.GetBinding(ordinatesControl!, ProbabilityOrdinatesControl.ProbabilityOrdinatesProperty);
+ Assert.NotNull(binding);
+ Assert.Equal(BindingMode.OneWay, binding!.Mode);
+ });
+ }
+
+ [Fact]
+ public void ParentDistribution_SetterClonesInputAndUndoRedoRestoresSnapshots()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ var element = CreateElement("Hazard_DistributionUndo");
+ var originalType = element.ParentDistribution.Type;
+ var incomingDistribution = UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Normal);
+
+ element.ParentDistribution = incomingDistribution;
+
+ Assert.Equal(UnivariateDistributionType.Normal, element.ParentDistribution.Type);
+ Assert.NotSame(incomingDistribution, element.ParentDistribution);
+ var elementParameters = element.ParentDistribution.GetParameters.ToArray();
+ var mutatedIncomingParameters = incomingDistribution.GetParameters.ToArray();
+ mutatedIncomingParameters[0] += 10d;
+ incomingDistribution.SetParameters(mutatedIncomingParameters);
+ AssertParametersEqual(elementParameters, element.ParentDistribution.GetParameters.ToArray());
+
+ Assert.True(element.UndoManager.CanUndo);
+ element.UndoManager.Undo();
+ Assert.Equal(originalType, element.ParentDistribution.Type);
+ Assert.True(element.UndoManager.CanRedo);
+ element.UndoManager.Redo();
+ Assert.Equal(UnivariateDistributionType.Normal, element.ParentDistribution.Type);
+ });
+ }
+
+ [Fact]
+ public void SetDistributionParameters_InvalidatesEstimateAndUndoRedoRestoresSnapshots()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ var element = CreateElement("Hazard_ParameterUndo");
+ element.Estimate();
+ Assert.True(element.IsEstimated);
+ Assert.True(element.IsValid);
+
+ var originalParameters = element.ParentDistribution.GetParameters.ToArray();
+ var updatedParameters = originalParameters.ToArray();
+ updatedParameters[0] += 0.25d;
+
+ element.SetDistributionParameters(updatedParameters);
+
+ Assert.False(element.IsEstimated);
+ Assert.False(element.IsValid);
+ AssertParametersEqual(updatedParameters, element.ParentDistribution.GetParameters.ToArray());
+ Assert.True(element.UndoManager.CanUndo);
+
+ element.UndoManager.Undo();
+ AssertParametersEqual(originalParameters, element.ParentDistribution.GetParameters.ToArray());
+ Assert.True(element.UndoManager.CanRedo);
+
+ element.UndoManager.Redo();
+ AssertParametersEqual(updatedParameters, element.ParentDistribution.GetParameters.ToArray());
+ });
+ }
+
+ [Fact]
+ public void SamplingAndMinMax_HandleEmptyOrdinatesAndInvalidRequests()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ var element = CreateElement("Hazard_EmptyOrdinates");
+ element.ProbabilityOrdinates.Clear();
+
+ Assert.Null(element.SampleFunction());
+ Assert.Null(element.SampleFunction(-0.01d));
+ Assert.Null(element.SampleFunction(1.01d));
+ Assert.Null(element.SampleFunction(-1));
+ Assert.Null(element.SampleFunction(0));
+ Assert.True(double.IsNaN(element.MinHazard(meanOnly: true)));
+ Assert.True(double.IsNaN(element.MaxHazard(meanOnly: false)));
+ });
+ }
+
+ [Fact]
+ public void HazardControl_ClearsPlotDataWhenEstimatedResultsDoNotMatchOrdinates()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var resources = EnsureApplicationResources();
+ var element = CreateElement("Hazard_MismatchedResults");
+ element.Estimate();
+ Assert.True(element.IsEstimated);
+
+ var control = new HazardControl { Element = element };
+ control.UpdatePlot();
+ Assert.NotEmpty(control.ModeLinePoints);
+
+ element.Results.ModeCurve = element.Results.ModeCurve!.Take(1).ToArray();
+ var exception = Record.Exception(() =>
+ {
+ control.UpdatePlot();
+ control.BindFrequencyCurveTable();
+ });
+
+ Assert.Null(exception);
+ Assert.Empty(control.ModeLinePoints);
+ });
+ }
+
+ private static HazardElement CreateElement(string name)
+ {
+ var element = new HazardElement(name, new HazardElementCollection(DemoProject.GetInstance()));
+ element.IsUndoEnabled = false;
+ element.IsUncertain = false;
+ element.IsUndoEnabled = true;
+ element.UndoManager.Clear();
+ return element;
+ }
+
+ private static IDisposable EnsureApplicationResources()
+ {
+ if (Application.Current == null)
+ {
+ _ = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
+ }
+
+ var addedDictionaries = new List();
+ AddResourceDictionary("pack://application:,,,/FrameworkUI;component/Icons/IconDictionary.xaml", addedDictionaries);
+ AddResourceDictionary("pack://application:,,,/GenericControls;component/Themes/GenericControlsTheme.xaml", addedDictionaries);
+ AddResourceDictionary("pack://application:,,,/OxyPlotControls;component/Themes/OxyPlotControlsTheme.xaml", addedDictionaries);
+ AddResourceDictionary("pack://application:,,,/NumericControls;component/Themes/NumericControlsTheme.xaml", addedDictionaries);
+ AddResourceDictionary("pack://application:,,,/DatabaseControls;component/Themes/DatabaseControlsTheme.xaml", addedDictionaries);
+ return new ResourceScope(addedDictionaries);
+ }
+
+ private static void AddResourceDictionary(string source, IList addedDictionaries)
+ {
+ var uri = new Uri(source, UriKind.Absolute);
+ if (Application.Current!.Resources.MergedDictionaries.Any(x => x.Source == uri)) return;
+ var dictionary = new ResourceDictionary { Source = uri };
+ Application.Current.Resources.MergedDictionaries.Add(dictionary);
+ addedDictionaries.Add(dictionary);
+ }
+
+ private sealed class ResourceScope : IDisposable
+ {
+ private readonly IList dictionaries;
+
+ public ResourceScope(IList dictionaries)
+ {
+ this.dictionaries = dictionaries;
+ }
+
+ public void Dispose()
+ {
+ foreach (var dictionary in dictionaries)
+ {
+ Application.Current?.Resources.MergedDictionaries.Remove(dictionary);
+ }
+ }
+ }
+
+ private static T? FindDescendant(DependencyObject root)
+ where T : DependencyObject
+ {
+ if (root is T match) return match;
+
+ foreach (object child in LogicalTreeHelper.GetChildren(root))
+ {
+ if (child is DependencyObject dependencyChild)
+ {
+ var logicalMatch = FindDescendant(dependencyChild);
+ if (logicalMatch != null) return logicalMatch;
+ }
+ }
+
+ int visualChildren = 0;
+ try
+ {
+ visualChildren = VisualTreeHelper.GetChildrenCount(root);
+ }
+ catch (InvalidOperationException)
+ {
+ return null;
+ }
+
+ for (int i = 0; i < visualChildren; i++)
+ {
+ var visualMatch = FindDescendant(VisualTreeHelper.GetChild(root, i));
+ if (visualMatch != null) return visualMatch;
+ }
+
+ return null;
+ }
+
+ private static void AssertParametersEqual(double[] expected, double[] actual)
+ {
+ Assert.Equal(expected.Length, actual.Length);
+ for (int i = 0; i < expected.Length; i++)
+ {
+ Assert.Equal(expected[i], actual[i], precision: 12);
+ }
+ }
+}
+
+internal static class DispatcherTestHost
+{
+ private static readonly object Gate = new();
+ private static Dispatcher? dispatcher;
+ private static Thread? thread;
+
+ public static void Run(Action body)
+ {
+ EnsureRunning().Invoke(body);
+ }
+
+ private static Dispatcher EnsureRunning()
+ {
+ lock (Gate)
+ {
+ if (dispatcher != null)
+ {
+ return dispatcher;
+ }
+
+ using var ready = new ManualResetEventSlim(false);
+ Dispatcher? captured = null;
+
+ thread = new Thread(() =>
+ {
+ if (Application.Current == null)
+ {
+ _ = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
+ }
+
+ captured = Dispatcher.CurrentDispatcher;
+ ready.Set();
+ Dispatcher.Run();
+ })
+ {
+ IsBackground = true,
+ Name = "FrameworkUIDemoHazardTestsDispatcher",
+ };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ ready.Wait();
+ dispatcher = captured!;
+ return dispatcher;
+ }
+ }
+}
\ No newline at end of file
From b5937ae0823d1515db54e6cd63bf2ddf05c974e2 Mon Sep 17 00:00:00 2001
From: HadenSmith
Date: Tue, 21 Jul 2026 13:55:02 -0600
Subject: [PATCH 06/13] Improve demo project icon clarity
---
src/FrameworkUI.Demo/Model/DemoProject.cs | 16 +++++++++++---
.../FrameworkUIDemo/DemoProjectIconTests.cs | 21 +++++++++++++++++++
2 files changed, 34 insertions(+), 3 deletions(-)
create mode 100644 tests/FrameworkUI.Tests/FrameworkUIDemo/DemoProjectIconTests.cs
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index 89acec49..975715a9 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -171,9 +171,19 @@ public override string Description
{
try
{
- var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/FrameworkUIDemo.ico"));
- img.Freeze();
- return img;
+ var uri = new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/FrameworkUIDemo.ico");
+ var decoder = BitmapDecoder.Create(uri, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
+ BitmapFrame bestFrame = null;
+ foreach (var frame in decoder.Frames)
+ {
+ if (bestFrame == null || frame.PixelWidth * frame.PixelHeight > bestFrame.PixelWidth * bestFrame.PixelHeight)
+ {
+ bestFrame = frame;
+ }
+ }
+
+ bestFrame?.Freeze();
+ return bestFrame;
}
catch (Exception)
{
diff --git a/tests/FrameworkUI.Tests/FrameworkUIDemo/DemoProjectIconTests.cs b/tests/FrameworkUI.Tests/FrameworkUIDemo/DemoProjectIconTests.cs
new file mode 100644
index 00000000..2104da1b
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FrameworkUIDemo/DemoProjectIconTests.cs
@@ -0,0 +1,21 @@
+using System.Windows.Media.Imaging;
+using FrameworkUI.Demo;
+using Xunit;
+
+namespace FrameworkUI.Tests.FrameworkUIDemo;
+
+public class DemoProjectIconTests
+{
+ [Fact]
+ public void ProjectImage_LoadsLargestAvailableIconFrame()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ var image = Assert.IsAssignableFrom(DemoProject.GetInstance().ProjectImage);
+
+ Assert.True(image.PixelWidth >= 128, $"Expected a high-resolution project icon, but got {image.PixelWidth}x{image.PixelHeight}.");
+ Assert.Equal(image.PixelWidth, image.PixelHeight);
+ Assert.True(image.IsFrozen);
+ });
+ }
+}
\ No newline at end of file
From cd0f94c368380663ae845ea12fb823014d96abed Mon Sep 17 00:00:00 2001
From: HadenSmith
Date: Wed, 7 Oct 2026 12:59:26 -0600
Subject: [PATCH 07/13] Close SQLite connections after failed operations
---
.../SQLiteManagerLifetimeTests.cs | 342 ++++++++++++++++++
src/DatabaseManager/SQLiteManager.cs | 142 +++++---
2 files changed, 437 insertions(+), 47 deletions(-)
create mode 100644 src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs
diff --git a/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs b/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs
new file mode 100644
index 00000000..d4e69ff2
--- /dev/null
+++ b/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs
@@ -0,0 +1,342 @@
+using System.Data;
+using System.Data.SQLite;
+using Xunit;
+
+namespace DatabaseManager.Tests;
+
+/// Checks ownership and failure recovery of SQLite manager connections.
+public class SQLiteManagerLifetimeTests
+{
+ /// Checks successful helpers preserve the caller's connection ownership.
+ /// The helper to invoke.
+ /// Whether the caller owns an open connection.
+ [Theory]
+ [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)]
+ [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)]
+ public void HelpersPreserveInitialState(int helper, bool initiallyOpen)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ if (initiallyOpen) manager.Open();
+ Invoke(manager, helper);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ });
+ }
+
+ /// Checks partial acquisition failures close owned connections and preserve the original error.
+ /// The helper to invoke.
+ /// Whether close also reports an error.
+ [Theory]
+ [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)]
+ [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)]
+ public void PartialOpenFailureIsCleaned(int helper, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path) { FailOpen = true, FailClose = cleanupFails };
+ var error = Assert.Throws(() => Invoke(manager, helper));
+ Assert.Same(manager.OpenError, error);
+ Assert.Contains(nameof(ProbeManager.Open), error.StackTrace);
+ Assert.Equal(1, manager.CloseCalls);
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ AssertReleased(path);
+ manager.FailOpen = manager.FailClose = false;
+ Invoke(manager, helper);
+ });
+ }
+
+ /// Checks cleanup-only failure propagates after successful work.
+ /// The helper to invoke.
+ [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)]
+ public void CleanupOnlyFailurePropagates(int helper)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path) { FailClose = true };
+ Assert.Same(manager.CloseError, Assert.Throws(() => Invoke(manager, helper)));
+ AssertReleased(path);
+ });
+ }
+
+ /// Checks vacuum failure leaves the caller's active transaction usable.
+ [Fact]
+ public void VacuumDoesNotCloseCallerTransaction()
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ manager.Open();
+ using var transaction = manager.DbConnection.BeginTransaction();
+ Assert.Throws(() => manager.Vacuum());
+ Assert.True(manager.DataBaseOpen);
+ Assert.Equal(0, manager.CloseCalls);
+ using var command = new SQLiteCommand("CREATE TABLE retained (value INTEGER)", manager.DbConnection, transaction);
+ command.ExecuteNonQuery();
+ transaction.Commit();
+ Assert.Contains("retained", manager.GetTableNames());
+ });
+ }
+
+ /// Checks both constructors release provider resources when initialization throws.
+ /// Whether to use the password constructor.
+ /// Whether provider disposal reports a secondary error.
+ [Theory]
+ [InlineData(false, false)] [InlineData(true, false)]
+ [InlineData(false, true)] [InlineData(true, true)]
+ public void ConstructorFailureDisposesConnectionDirectly(bool passwordOverload, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ SQLiteConnection? captured = null;
+ var primary = new InvalidOperationException("initialization failure");
+ var cleanupObserved = false;
+ EventHandler cleanupFailure = (_, _) => { cleanupObserved = true; throw new IOException("provider cleanup failure"); };
+ Action fail = connection =>
+ {
+ captured = connection;
+ connection.Open();
+ if (cleanupFails) connection.Disposed += cleanupFailure;
+ throw primary;
+ };
+ var error = Assert.Throws(() =>
+ {
+ if (passwordOverload) _ = new FailingConstructorManager(path, null!, fail);
+ else _ = new FailingConstructorManager(path, fail);
+ });
+ Assert.Same(primary, error);
+ Assert.Contains(nameof(FailingConstructorManager.GetTableNames), error.StackTrace);
+ Assert.NotNull(captured);
+ try
+ {
+ if (cleanupFails)
+ {
+ Assert.True(cleanupObserved);
+ Assert.Equal(ConnectionState.Closed, captured.State);
+ }
+ else
+ {
+ Assert.Throws(() => captured.State);
+ Assert.Throws(() => captured.Open());
+ }
+ AssertReleased(path);
+ }
+ finally { captured.Disposed -= cleanupFailure; captured.Dispose(); }
+ });
+ }
+
+ /// Checks both constructors successfully initialize and leave the file released.
+ /// Whether to use the password constructor.
+ [Theory] [InlineData(false)] [InlineData(true)]
+ public void ConstructorsLeaveConnectionClosed(bool passwordOverload)
+ {
+ WithFile(path =>
+ {
+ using var manager = passwordOverload ? new SQLiteManager(path, null!, Builder()) : new SQLiteManager(path, Builder());
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ AssertReleased(path);
+ manager.Open();
+ using (var command = new SQLiteCommand("CREATE TABLE successful (value INTEGER); INSERT INTO successful VALUES (7);", manager.DbConnection))
+ command.ExecuteNonQuery();
+ Assert.Equal(7L, Scalar(manager, "SELECT value FROM successful"));
+ manager.Close();
+ Assert.Contains("successful", manager.GetTableNames());
+ });
+ }
+
+ /// Checks real invalid-file failures release owned acquisitions and recover after repair.
+ /// The helper to invoke.
+ [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)]
+ public void InvalidDatabaseFailureRecovers(int helper)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ File.WriteAllText(path, "This is not a SQLite database.");
+ Assert.Throws(() => Invoke(manager, helper));
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ Assert.Equal(1, manager.CloseCalls);
+ AssertReleased(path);
+ File.Delete(path);
+ SQLiteManager.CreateSqLiteFile(path);
+ Invoke(manager, helper);
+ Assert.False(manager.DataBaseOpen);
+ });
+ }
+
+ /// Checks a real exclusive lock failure preserves ownership and recovers once released.
+ /// Whether the manager's connection is caller-owned.
+ [Theory] [InlineData(false)] [InlineData(true)]
+ public void LockedVacuumRecovers(bool initiallyOpen)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ if (initiallyOpen) manager.Open();
+ var builder = Builder();
+ builder.DataSource = path;
+ using (var blocker = new SQLiteConnection(builder.ConnectionString))
+ {
+ blocker.Open();
+ using var begin = new SQLiteCommand("BEGIN EXCLUSIVE", blocker);
+ begin.ExecuteNonQuery();
+ try
+ {
+ Assert.Throws(() => manager.Vacuum());
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ if (!initiallyOpen) Assert.Equal(1, manager.CloseCalls);
+ }
+ finally
+ {
+ using var rollback = new SQLiteCommand("ROLLBACK", blocker);
+ rollback.ExecuteNonQuery();
+ }
+ }
+ manager.Vacuum();
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ });
+ }
+
+ /// Checks SQL failures restore owned connections and preserve borrowed connections.
+ /// The helper to invoke.
+ /// Whether the connection is caller-owned.
+ /// Whether owned close reports a secondary error.
+ [Theory]
+ [InlineData(0, false, false)] [InlineData(1, false, false)] [InlineData(2, false, false)]
+ [InlineData(0, true, false)] [InlineData(1, true, false)] [InlineData(2, true, false)]
+ [InlineData(0, false, true)] [InlineData(1, false, true)] [InlineData(2, false, true)]
+ public void SqlFailurePreservesOwnershipAndRecovers(int helper, bool initiallyOpen, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ manager.DbConnection.Authorize += (_, args) =>
+ {
+ if (manager.RejectSql) args.ReturnCode = SQLiteAuthorizerReturnCode.Deny;
+ };
+ if (initiallyOpen) manager.Open();
+ manager.RejectSql = true;
+ manager.FailClose = cleanupFails;
+ var error = Assert.Throws(() => Invoke(manager, helper));
+ Assert.Contains("authoriz", error.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ Assert.Equal(initiallyOpen ? 0 : 1, manager.CloseCalls);
+ manager.RejectSql = manager.FailClose = false;
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ Invoke(manager, helper);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ });
+ }
+
+ /// Creates nonpooled connections with bounded lock waiting.
+ /// The connection settings.
+ private static SQLiteConnectionStringBuilder Builder() => new() { Pooling = false, DefaultTimeout = 1, BusyTimeout = 1, JournalMode = SQLiteJournalModeEnum.Delete };
+
+ /// Runs a helper selected by its test index.
+ /// The manager.
+ /// The helper index.
+ private static void Invoke(SQLiteManager manager, int helper)
+ {
+ if (helper == 0) manager.GetTableNames();
+ else if (helper == 1) manager.Vacuum();
+ else manager.Optimize();
+ }
+
+ /// Reads a scalar from a caller-owned connection.
+ /// The manager.
+ /// The SQL query.
+ /// The scalar.
+ private static object Scalar(SQLiteManager manager, string sql)
+ {
+ using var command = new SQLiteCommand(sql, manager.DbConnection);
+ return command.ExecuteScalar();
+ }
+
+ /// Verifies file release before test cleanup without garbage collection or pool clearing.
+ /// The database path.
+ private static void AssertReleased(string path)
+ {
+ using var exclusive = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ }
+
+ /// Provides an isolated temporary database and deterministic final cleanup.
+ /// The test body.
+ private static void WithFile(Action test)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"sqlite-lifetime-{Guid.NewGuid():N}.db");
+ try { test(path); }
+ finally
+ {
+ foreach (var file in new[] { path, path + "-wal", path + "-shm", path + "-journal" })
+ if (File.Exists(file)) File.Delete(file);
+ }
+ }
+
+ /// Injects errors after provider acquisition and release.
+ private sealed class ProbeManager : SQLiteManager
+ {
+ /// Creates a manager with nonpooled settings.
+ /// The database path.
+ public ProbeManager(string path) : base(path, Builder()) { CloseCalls = 0; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool RejectSql { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool FailOpen { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool FailClose { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public int CloseCalls { get; private set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public InvalidOperationException OpenError { get; } = new("partial open failure");
+ /// Provides the injected failure or recorded lifecycle state.
+ public InvalidOperationException CloseError { get; } = new("close failure");
+ /// Opens the provider before injecting a failure.
+ public override void Open() { base.Open(); if (FailOpen) throw OpenError; }
+ /// Closes the provider before injecting a failure.
+ public override void Close() { CloseCalls++; base.Close(); if (FailClose) throw CloseError; }
+ }
+
+ /// Injects initialization failure without shared static state.
+ private sealed class FailingConstructorManager : SQLiteManager
+ {
+ // The constructor argument is unavailable during base initialization; AsyncLocal isolates the setup callback.
+ private static readonly AsyncLocal?> Failure = new();
+ /// Sets the callback before base initialization.
+ /// The database path.
+ /// The failure callback.
+ /// The database path.
+ private static string Prepare(string path, Action fail) { Failure.Value = fail; return path; }
+ /// Invokes the ordinary constructor.
+ /// The database path.
+ /// The failure callback.
+ public FailingConstructorManager(string path, Action fail) : base(Prepare(path, fail), Builder()) { }
+ /// Invokes the password constructor.
+ /// The database path.
+ /// The password.
+ /// The failure callback.
+ public FailingConstructorManager(string path, string password, Action fail) : base(Prepare(path, fail), password, Builder()) { }
+ /// Throws during constructor initialization after provider acquisition.
+ /// Never returns.
+ public override string[] GetTableNames()
+ {
+ var callback = Failure.Value!;
+ Failure.Value = null;
+ callback(DbConnection);
+ return [];
+ }
+ /// Rejects unsafe virtual disposal during base construction.
+ /// Whether disposal is explicit.
+ protected override void Dispose(bool disposing) => throw new InvalidOperationException("virtual disposal during construction");
+ }
+}
diff --git a/src/DatabaseManager/SQLiteManager.cs b/src/DatabaseManager/SQLiteManager.cs
index a58bc6de..3c3da6ef 100644
--- a/src/DatabaseManager/SQLiteManager.cs
+++ b/src/DatabaseManager/SQLiteManager.cs
@@ -3,6 +3,7 @@
using System.Data;
using System.Data.SQLite;
using System.Globalization;
+using System.Diagnostics;
using System.Linq;
using System.Threading;
@@ -44,8 +45,16 @@ public class SQLiteManager : DatabaseManager, IDisposable
public SQLiteManager(string dataBaseFile, SQLiteConnectionStringBuilder connectionBuilder = null)
{
_dataBasePath = dataBaseFile;
- SetDatabaseConnection(dataBaseFile, connectionBuilder);
- _tableNames = GetTableNames();
+ try
+ {
+ SetDatabaseConnection(dataBaseFile, connectionBuilder);
+ _tableNames = GetTableNames();
+ }
+ catch
+ {
+ DisposeFailedInitialization();
+ throw;
+ }
}
///
@@ -57,8 +66,30 @@ public SQLiteManager(string dataBaseFile, SQLiteConnectionStringBuilder connecti
public SQLiteManager(string dataBaseFile, string databasePassword, SQLiteConnectionStringBuilder connectionBuilder = null)
{
_dataBasePath = dataBaseFile;
- SetDatabaseConnection(dataBaseFile, databasePassword, connectionBuilder);
- _tableNames = GetTableNames();
+ try
+ {
+ SetDatabaseConnection(dataBaseFile, databasePassword, connectionBuilder);
+ _tableNames = GetTableNames();
+ }
+ catch
+ {
+ DisposeFailedInitialization();
+ throw;
+ }
+ }
+
+ /// Releases the owned provider after failed initialization without dispatching virtual disposal.
+ /// A cleanup failure is diagnostic only so the original initialization exception retains its stack.
+ private void DisposeFailedInitialization()
+ {
+ try
+ {
+ _dbConnection?.Dispose();
+ }
+ catch (Exception cleanupError)
+ {
+ Debug.WriteLine(cleanupError);
+ }
}
#endregion
@@ -240,20 +271,14 @@ public static void CreateSqLiteFile(string databaseFile, string databasePassword
///
public void Vacuum()
{
- bool reOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ WithConnectionOwnership(() =>
{
- Open();
- }
- // vacuum sqlite file
- using (var command = new SQLiteCommand("vacuum", _dbConnection))
- {
- command.ExecuteNonQuery();
- }
- if (reOpen == false)
- {
- Close();
- }
+ using (var command = new SQLiteCommand("vacuum", _dbConnection))
+ {
+ command.ExecuteNonQuery();
+ }
+ return true;
+ });
}
///
@@ -263,25 +288,55 @@ public void Vacuum()
///
public void Optimize()
{
- bool reOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ WithConnectionOwnership(() =>
+ {
+ using (var command = new SQLiteCommand("PRAGMA optimize", _dbConnection))
+ {
+ command.ExecuteNonQuery();
+ }
+ return true;
+ });
+ }
+
+
+ #endregion
+
+ /// Runs an operation while restoring ownership of an initially closed connection.
+ /// The operation result type.
+ /// The operation to run after opening the connection if needed.
+ /// The operation result.
+ /// Acquisition is protected because a failed open can acquire provider resources. Borrowed connections remain open. A secondary close failure is logged while the original exception propagates; a cleanup-only failure propagates.
+ private TResult WithConnectionOwnership(Func operation)
+ {
+ bool wasOpen = DataBaseOpen;
+ Exception operationError = null;
+ try
{
- Open();
+ if (!wasOpen)
+ Open();
+ return operation();
}
- // optimize sqlite file
- using (var command = new SQLiteCommand("PRAGMA optimize", _dbConnection))
+ catch (Exception error)
{
- command.ExecuteNonQuery();
+ operationError = error;
+ throw;
}
- if (reOpen == false)
- {
- Close();
+ finally
+ {
+ if (!wasOpen)
+ {
+ try
+ {
+ Close();
+ }
+ catch (Exception cleanupError) when (operationError != null)
+ {
+ Debug.WriteLine(cleanupError);
+ }
+ }
}
}
-
- #endregion
-
#region Database Table Management
///
@@ -401,29 +456,22 @@ public void RenameTable(string oldTableName, string newTableName)
///
public override string[] GetTableNames()
{
- bool wasOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ var result = WithConnectionOwnership(() =>
{
- Open();
- }
- //
- var result = new List();
- using (var command = new SQLiteCommand("SELECT name FROM sqlite_master WHERE type='table'", _dbConnection))
- {
- using (var reader = command.ExecuteReader())
+ var names = new List();
+ using (var command = new SQLiteCommand("SELECT name FROM sqlite_master WHERE type='table'", _dbConnection))
{
- if (reader.HasRows)
+ using (var reader = command.ExecuteReader())
{
- while (reader.Read())
- result.Add(Convert.ToString(reader[0]));
+ if (reader.HasRows)
+ {
+ while (reader.Read())
+ names.Add(Convert.ToString(reader[0]));
+ }
}
}
- }
- //
- if (wasOpen == false)
- {
- Close();
- }
+ return names;
+ });
_tableNames = result.ToArray();
return result.ToArray();
}
From 5f153c96146b6747413ce775b59bb4ae7be74f3f Mon Sep 17 00:00:00 2001
From: HadenSmith
Date: Wed, 7 Oct 2026 12:59:47 -0600
Subject: [PATCH 08/13] Keep compaction active until database work completes
---
.../Main Window/MainWindow.xaml.cs | 20 +-
.../File Size Management/FileSizeManager.cs | 317 +++++++++----
.../CompactionFailureScenarios.cs | 133 ++++++
.../FileSizeManagerLifecycleTests.cs | 434 ++++++++++++++++++
.../MainWindowCompactionTests.cs | 204 ++++++++
5 files changed, 1003 insertions(+), 105 deletions(-)
create mode 100644 tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs
create mode 100644 tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs
create mode 100644 tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs
diff --git a/src/FrameworkUI/Main Window/MainWindow.xaml.cs b/src/FrameworkUI/Main Window/MainWindow.xaml.cs
index 66e4afef..db566374 100644
--- a/src/FrameworkUI/Main Window/MainWindow.xaml.cs
+++ b/src/FrameworkUI/Main Window/MainWindow.xaml.cs
@@ -1777,7 +1777,7 @@ private void ExitCommandBinding_Executed(object sender, ExecutedRoutedEventArgs
/// The cancel event data.
private void MainWindow_Closing(object sender, CancelEventArgs e)
{
- if (ShellPublicVariables.SimulationInProgress == true)
+ if (ShellPublicVariables.SimulationInProgress || ShellPublicVariables.CompactionInProgress)
{
e.Cancel = true;
return;
@@ -1795,7 +1795,21 @@ private void MainWindow_Closing(object sender, CancelEventArgs e)
if (UserSettings.CompressProjectFileOnClose == true && ProjectNode?.Project is not null)
{
// Compact and Optimize
- FileSizeManager.CompactAndOptimizeFile(ProjectNode.Project);
+ try
+ {
+ FileSizeManager.CompactAndOptimizeFile(ProjectNode.Project);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex.ToString());
+ GenericControls.MessageBox.Show("The project file could not be compacted and optimized. " + ex.Message,
+ "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ if (ShellPublicVariables.CompactionInProgress)
+ {
+ e.Cancel = true;
+ return;
+ }
+ }
}
// Close the current project
@@ -2425,7 +2439,7 @@ private void CompactProjectFile_Click(object sender, RoutedEventArgs e)
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
- GenericControls.MessageBox.Show("There was an unexpected error when trying to compact and optimize the project file.", "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ GenericControls.MessageBox.Show("The project file could not be compacted and optimized. " + ex.Message, "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
diff --git a/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs b/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
index bab0c1b3..a8ee6d5d 100644
--- a/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
+++ b/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
@@ -1,7 +1,10 @@
-using System.ComponentModel;
+using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Windows;
using System.Windows.Threading;
using FrameworkInterfaces;
@@ -52,6 +55,11 @@ public class FileSizeManager
private static DispatcherTimer? _timer;
private static FrameworkUI.CompactProgressControl? _progressControl;
private static BackgroundWorker? _backgroundWorker;
+ private static int _operationActive;
+ private static bool _workerStarted;
+ private static bool _workerCompleted;
+ private static ExceptionDispatchInfo? _workerError;
+ private static DispatcherFrame? _recoveryFrame;
#endregion
@@ -73,70 +81,215 @@ public class FileSizeManager
#region Public Methods
///
- /// Compact and optimize the project file.
+ /// Compacts and optimizes the project file, returning only after the worker has completed.
///
/// The project to compact and optimize.
+ /// The project is null.
+ /// Another compaction is active, or its completion cannot be observed safely.
+ /// Worker errors are rethrown on the caller after owned resources have been released.
public static void CompactAndOptimizeFile(IProject project)
{
- // Set the project file
- _project = project;
- // Get the 'before' file size text. Guard against a missing file (new / unsaved
- // project, file moved between open and compact) - FileInfo.Length throws
- // FileNotFoundException on an absent path, on the UI thread.
- if (!string.IsNullOrEmpty(_project.FullFileName) && File.Exists(_project.FullFileName))
+ if (Interlocked.CompareExchange(ref _operationActive, 1, 0) != 0)
+ throw new InvalidOperationException("Project compaction is already in progress.");
+
+ ExceptionDispatchInfo? failure = null;
+ string? message = null;
+ try
{
- _fileSizeBefore = GetFileSizeText(_project.FullFileName);
+ _project = project ?? throw new ArgumentNullException(nameof(project));
+ ShellPublicVariables.CompactionInProgress = true;
+ _workerStarted = false;
+ _workerCompleted = false;
+ _workerError = null;
+ _fileSizeBefore = !string.IsNullOrEmpty(project.FullFileName) && File.Exists(project.FullFileName)
+ ? GetFileSizeText(project.FullFileName) : string.Empty;
+
+ _progressControl = new CompactProgressControl();
+ _progressControl.ProgressText.Text = "Analyzing Project File...";
+ _progressControl.ProgressBar.IsIndeterminate = true;
+ _progressControl.ProgressBar.Minimum = 0d;
+ _progressControl.ProgressBar.Maximum = 100d;
+ _progressControl.ProgressBar.Value = 0d;
+ _progressControl.Closing += ProgressControl_Closing;
+ _progressControl.Loaded += ProgressControl_Loaded;
+
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(0.1d) };
+ _timer.Tick += Timer_Tick;
+ _backgroundWorker = new BackgroundWorker { WorkerReportsProgress = true };
+ _backgroundWorker.DoWork += BackgroundWorker_Dowork;
+ _backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
+ _backgroundWorker.RunWorkerCompleted += BackgroundWorker_WorkerComplete;
+
+ try
+ {
+ _progressControl.ShowDialog();
+ }
+ catch (Exception ex)
+ {
+ failure = ExceptionDispatchInfo.Capture(ex);
+ }
+
+ // Hide(), dispatcher shutdown, or a dialog exception must not release a live writer.
+ if (_workerStarted && !_workerCompleted)
+ WaitForWorkerCompletion();
+
+ if (failure == null && _workerError == null)
+ {
+ if (!_workerCompleted)
+ throw new InvalidOperationException("Project compaction did not complete.");
+ _fileSizeAfter = GetFileSizeText(project.FullFileName);
+ message = _fileSizeBefore == _fileSizeAfter
+ ? "The file was optimized. However, there was no unused space to compact in '" + project.Name + "', so the file size remains unchanged at " + _fileSizeAfter + "."
+ : "The file was compacted and optimized. The project '" + project.Name + "' was compacted from " + _fileSizeBefore + " to " + _fileSizeAfter + ".";
+ }
}
- else
+ catch (Exception ex)
{
- _fileSizeBefore = string.Empty;
+ PreserveFailure(ref failure, ex, "operation");
+ }
+ finally
+ {
+ if (_workerError != null) PreserveFailure(ref failure, _workerError.SourceException, "worker");
+ // If recovery itself aborts, retain ownership and the public guard. Callers must
+ // not save or close a project while its completion remains unobserved.
+ if (!_workerStarted || _workerCompleted)
+ {
+ try { CleanupOperation(); }
+ catch (Exception ex)
+ {
+ PreserveFailure(ref failure, ex, "cleanup");
+ }
+ }
}
- // Begin compaction
- ShellPublicVariables.CompactionInProgress = true;
+ failure?.Throw();
+ if (message != null) ReportProgress?.Invoke(message);
+ }
- // Clean up existing timer if any
- if (_timer != null)
+ /// Starts the prepared worker once the modal progress window is loaded.
+ /// The progress window.
+ /// The loaded event.
+ private static void ProgressControl_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement progressControl) progressControl.Loaded -= ProgressControl_Loaded;
+ if (_workerStarted || _workerCompleted || _backgroundWorker == null) return;
+ try
+ {
+ _timer?.Start();
+ _workerStarted = true;
+ _backgroundWorker.RunWorkerAsync(_project);
+ }
+ catch (Exception ex)
{
- _timer.Stop();
- _timer.Tick -= Timer_Tick;
+ _workerStarted = false;
+ _workerError = ExceptionDispatchInfo.Capture(ex);
+ _progressControl?.Close();
}
+ }
- // Start the timer
- _timer = new DispatcherTimer() { Interval = TimeSpan.FromSeconds(0.1d) };
- _timer.Tick += Timer_Tick;
- _timer.IsEnabled = true;
- _timer.Start();
+ /// Preserves modal protection when the progress dialog returns before its worker.
+ /// The dispatcher stops before completion is observed.
+ private static void WaitForWorkerCompletion()
+ {
+ var dispatcher = _progressControl!.Dispatcher;
+ var disabledWindows = new List();
+ try
+ {
+ if (Application.Current != null)
+ {
+ foreach (Window window in Application.Current.Windows)
+ {
+ if (window.Dispatcher == dispatcher && window.IsEnabled)
+ {
+ disabledWindows.Add(window);
+ window.SetCurrentValue(UIElement.IsEnabledProperty, false);
+ }
+ }
+ }
+ _recoveryFrame = new DispatcherFrame();
+ if (!dispatcher.HasShutdownStarted && !dispatcher.HasShutdownFinished && !_workerCompleted)
+ Dispatcher.PushFrame(_recoveryFrame);
+ if (!_workerCompleted)
+ throw new InvalidOperationException("Project compaction is still running; the project cannot be saved or closed.");
+ }
+ finally
+ {
+ _recoveryFrame = null;
+ if (_workerCompleted)
+ {
+ ExceptionDispatchInfo? restoreFailure = null;
+ foreach (Window window in disabledWindows)
+ AttemptCleanup(() => window.SetCurrentValue(UIElement.IsEnabledProperty, true), ref restoreFailure);
+ restoreFailure?.Throw();
+ }
+ }
+ }
- // Clean up existing background worker if any
- if (_backgroundWorker != null)
+ /// Releases the completed operation's handlers, timer, worker, window, and guard.
+ /// Every cleanup step is attempted; the first cleanup error is propagated afterward.
+ private static void CleanupOperation()
+ {
+ ExceptionDispatchInfo? failure = null;
+ try
{
- _backgroundWorker.DoWork -= BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete;
- _backgroundWorker.Dispose();
+ var timer = _timer;
+ if (timer != null)
+ {
+ AttemptCleanup(timer.Stop, ref failure);
+ AttemptCleanup(() => timer.Tick -= Timer_Tick, ref failure);
+ }
+ var worker = _backgroundWorker;
+ if (worker != null)
+ {
+ AttemptCleanup(() => worker.DoWork -= BackgroundWorker_Dowork, ref failure);
+ AttemptCleanup(() => worker.ProgressChanged -= BackgroundWorker_ProgressChanged, ref failure);
+ AttemptCleanup(() => worker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete, ref failure);
+ AttemptCleanup(worker.Dispose, ref failure);
+ }
+ var progressControl = _progressControl;
+ if (progressControl != null)
+ {
+ AttemptCleanup(() => progressControl.Loaded -= ProgressControl_Loaded, ref failure);
+ AttemptCleanup(() => progressControl.Closing -= ProgressControl_Closing, ref failure);
+ AttemptCleanup(progressControl.Close, ref failure);
+ }
}
+ finally
+ {
+ _timer = null;
+ _backgroundWorker = null;
+ _progressControl = null;
+ _project = null;
+ _fileSizeBefore = null;
+ _fileSizeAfter = null;
+ _workerError = null;
+ _workerStarted = false;
+ _workerCompleted = false;
+ ShellPublicVariables.CompactionInProgress = false;
+ Interlocked.Exchange(ref _operationActive, 0);
+ }
+ failure?.Throw();
+ }
- // Create compact background worker
- _backgroundWorker = new BackgroundWorker();
- _backgroundWorker.WorkerSupportsCancellation = true;
- _backgroundWorker.WorkerReportsProgress = true;
- _backgroundWorker.DoWork += BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted += BackgroundWorker_WorkerComplete;
- _backgroundWorker.RunWorkerAsync();
-
- // Open the progress bar control
- _progressControl = new FrameworkUI.CompactProgressControl();
- _progressControl.ProgressText.Text = "Analyzing Project File...";
- _progressControl.ProgressBar.IsIndeterminate = true;
- _progressControl.ProgressBar.Minimum = 0d;
- _progressControl.ProgressBar.Maximum = 100d;
- _progressControl.ProgressBar.Value = 0d;
- _progressControl.Closing += ProgressControl_Closing;
- _progressControl.ShowDialog();
+ /// Attempts one cleanup action without preventing subsequent cleanup.
+ /// The resource cleanup or window restoration action.
+ /// The first failure, if one has already occurred.
+ private static void AttemptCleanup(Action cleanup, ref ExceptionDispatchInfo? failure)
+ {
+ try { cleanup(); }
+ catch (Exception ex) { PreserveFailure(ref failure, ex, "cleanup"); }
}
+ /// Preserves the first failure and records secondary diagnostic failures.
+ /// The first failure delivered to the caller.
+ /// The newly observed failure.
+ /// The operation producing this diagnostic.
+ private static void PreserveFailure(ref ExceptionDispatchInfo? failure, Exception error, string operation)
+ {
+ if (failure == null) failure = ExceptionDispatchInfo.Capture(error);
+ else if (!ReferenceEquals(failure.SourceException, error))
+ Debug.WriteLine($"FileSizeManager secondary {operation} failure: {error}");
+ }
///
/// Background worker for performing file compaction.
///
@@ -144,13 +297,13 @@ public static void CompactAndOptimizeFile(IProject project)
/// The event arguments.
private static void BackgroundWorker_Dowork(object? sender, DoWorkEventArgs e)
{
- if (sender is not BackgroundWorker worker || _project == null) return;
+ if (sender is not BackgroundWorker worker || e.Argument is not IProject project) return;
// Update progress bar to compacting
worker.ReportProgress(0);
- _project.Compact();
+ project.Compact();
// Update progress bar to optimizing
worker.ReportProgress(100);
- _project.Optimize();
+ project.Optimize();
}
///
@@ -185,66 +338,26 @@ private static void BackgroundWorker_ProgressChanged(object? sender, ProgressCha
/// The event arguments.
private static void BackgroundWorker_WorkerComplete(object? sender, RunWorkerCompletedEventArgs e)
{
- // Stop and clean up timer
- if (_timer != null)
- {
- _timer.Stop();
- _timer.IsEnabled = false;
- _timer.Tick -= Timer_Tick;
- }
-
- // Stop and clean up worker
- if (_backgroundWorker != null)
- {
- _backgroundWorker.CancelAsync();
- _backgroundWorker.DoWork -= BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete;
- _backgroundWorker.Dispose();
- _backgroundWorker = null;
- }
-
- if (_progressControl != null)
- {
- _progressControl.Closing -= ProgressControl_Closing;
- _progressControl.Close();
- _progressControl = null;
- }
- if (_timer != null) { _timer.Stop(); _timer.Tick -= Timer_Tick; _timer = null; }
- ShellPublicVariables.CompactionInProgress = false;
-
- if (_project == null) return;
-
- // Get new file size
- _fileSizeAfter = GetFileSizeText(_project.FullFileName);
-
- // Report final compression
- string message;
- if (_fileSizeBefore == _fileSizeAfter)
- {
- message = "The file was optimized. However, there was no unused space to compact in '" + _project.Name + "', so the file size remains unchanged at " + _fileSizeAfter + ".";
- }
- else
+ if (e.Error != null) _workerError = ExceptionDispatchInfo.Capture(e.Error);
+ else if (e.Cancelled) _workerError = ExceptionDispatchInfo.Capture(new OperationCanceledException("Project compaction was canceled."));
+ _workerCompleted = true;
+ if (_recoveryFrame != null) _recoveryFrame.Continue = false;
+ try { _progressControl?.Close(); }
+ catch (Exception ex)
{
- message = "The file was compacted and optimized. The project '" + _project.Name + "' was compacted from " + _fileSizeBefore + " to " + _fileSizeAfter + ".";
+ _workerError ??= ExceptionDispatchInfo.Capture(ex);
+ Debug.WriteLine($"FileSizeManager progress window close failed: {ex}");
+ _progressControl?.Hide();
}
-
- ReportProgress?.Invoke(message);
}
- ///
- /// Handles the progress control window closing event.
- /// Cancels the background worker when the user closes the window.
- ///
- /// The source of the event.
- /// The event arguments.
+ /// Vetoes closing while a non-cancelable compaction worker remains active.
+ /// The progress window.
+ /// The closing event.
private static void ProgressControl_Closing(object? sender, CancelEventArgs e)
{
- _backgroundWorker?.CancelAsync();
- if (_timer != null) { _timer.Stop(); _timer.Tick -= Timer_Tick; _timer = null; }
- ShellPublicVariables.CompactionInProgress = false;
+ if (_workerStarted && !_workerCompleted) e.Cancel = true;
}
-
///
/// Timer used to update the progress bar.
///
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs b/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs
new file mode 100644
index 00000000..2fa52b19
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs
@@ -0,0 +1,133 @@
+using System.ComponentModel;
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using System.Windows.Threading;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Injects actual dispatcher and disposal failures in isolated compaction test processes.
+internal static class CompactionFailureScenarios
+{
+ /// Exercises the selected error through modal recovery or owned resource cleanup.
+ /// The failure scenario selected by the parent test.
+ public static void Run(string testCase)
+ {
+ if (testCase == "cleanup-failure") { AssertCleanupContinues(); return; }
+ if (testCase == "worker-cleanup-failure") { AssertWorkerErrorSurvivesCleanup(); return; }
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var primary = new IOException("The modal or recovery operation failed first.");
+ var secondary = new IOException("The worker failure must remain secondary.");
+ var window = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ window.Show();
+ var secondWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ secondWindow.Show();
+ DependencyPropertyChangedEventHandler restoreFailure = (_, e) =>
+ {
+ if ((bool)e.NewValue) throw primary;
+ };
+ if (testCase == "recovery-worker-failure") window.IsEnabledChanged += restoreFailure;
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ if (testCase == "modal-worker-failure")
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(() => throw primary));
+ else
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(release.Set));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("The recovery frame did not release its worker.");
+ throw secondary;
+ };
+ try
+ {
+ Assert.Same(primary, scenario.Run());
+ Assert.True(secondWindow.IsEnabled, "Later window states must be restored even when an earlier restoration raises an error.");
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ release.Set();
+ window.IsEnabledChanged -= restoreFailure;
+ window.Close();
+ secondWindow.Close();
+ }
+ }
+
+ /// Verifies that a completed worker error survives a later owned-resource disposal error.
+ private static void AssertWorkerErrorSurvivesCleanup()
+ {
+ using var scenario = new CompactionScenario();
+ var primary = new IOException("The worker failed before cleanup.");
+ var secondary = new IOException("Cleanup must not replace the worker failure.");
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ var worker = Assert.IsType(CompactionScenario.Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) =>
+ {
+ // Substitute only after the real callback has completed its work.
+ SetField("_backgroundWorker", new ThrowingDisposeWorker(secondary));
+ worker.Dispose();
+ };
+ });
+ throw primary;
+ };
+ Assert.Same(primary, scenario.Run());
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ }
+ /// Verifies that disposal failure cannot skip closing later owned resources.
+ private static void AssertCleanupContinues()
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Disposing the completed worker failed.");
+ var timer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) };
+ var dialog = new CompactProgressControl();
+ var worker = new ThrowingDisposeWorker(failure);
+ timer.Start();
+ dialog.Show();
+ SetField("_timer", timer);
+ SetField("_progressControl", dialog);
+ SetField("_backgroundWorker", worker);
+ SetField("_project", scenario.Project);
+ SetField("_operationActive", 1);
+ ShellPublicVariables.CompactionInProgress = true;
+ try
+ {
+ var thrown = Assert.Throws(() =>
+ typeof(FileSizeManager).GetMethod("CleanupOperation", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, null));
+ Assert.Same(failure, thrown.InnerException);
+ Assert.False(timer.IsEnabled);
+ Assert.False(dialog.IsVisible, "The progress window must close even when worker disposal fails.");
+ scenario.AssertClean();
+ }
+ finally { timer.Stop(); dialog.Close(); }
+ }
+
+ /// Sets an owned resource for a focused cleanup fault without exposing production hooks.
+ /// The existing private field.
+ /// The test-owned resource or state.
+ private static void SetField(string name, object value) => typeof(FileSizeManager).GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, value);
+
+ /// Raises a disposal failure after normal BackgroundWorker cleanup.
+ private sealed class ThrowingDisposeWorker : BackgroundWorker
+ {
+ private readonly Exception failure;
+ /// Stores the exact error instance to raise during disposal.
+ /// The injected cleanup failure.
+ public ThrowingDisposeWorker(Exception failure) => this.failure = failure;
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing) throw failure;
+ }
+ }
+}
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs b/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs
new file mode 100644
index 00000000..10b334d0
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs
@@ -0,0 +1,434 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Threading;
+using FrameworkInterfaces;
+using FrameworkUI.Tests.FrameworkUIDemo;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Exercises compaction through its real modal window and background worker.
+public class FileSizeManagerLifecycleTests
+{
+ /// Closing either phase must not release the caller or the compaction guard.
+ /// Whether to attempt closing during optimization.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void CloseDuringWork_IsVetoedUntilCompletion(bool duringOptimize)
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ bool remainedVisible = false;
+ bool remainedGuarded = false;
+ Action attemptClose = () => scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ var dialog = CompactionScenario.Dialog();
+ dialog.Close();
+ remainedVisible = dialog.IsVisible;
+ remainedGuarded = ShellPublicVariables.CompactionInProgress;
+ });
+ if (duringOptimize) scenario.Project.OptimizeAction = attemptClose;
+ else scenario.Project.CompactAction = attemptClose;
+
+ var error = scenario.Run();
+
+ Assert.Null(error);
+ Assert.True(remainedVisible, "Closing the progress window must be vetoed while its worker is running.");
+ Assert.True(remainedGuarded);
+ Assert.False(scenario.ReturnedBeforeCompletion);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ Assert.Single(scenario.Reports);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Worker failures reach the synchronous caller unchanged and never report success.
+ /// Whether optimization should fail after compaction succeeds.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void WorkerFailure_PreservesExceptionAndAllowsReuse(bool duringOptimize)
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Injected compaction failure.");
+ Action fail = () => throw failure;
+ if (duringOptimize) scenario.Project.OptimizeAction = fail;
+ else scenario.Project.CompactAction = fail;
+
+ var error = scenario.Run();
+
+ Assert.Same(failure, error);
+ Assert.Contains(nameof(CompactionProject), error!.StackTrace);
+ Assert.Equal(duringOptimize ? new[] { "Compact", "Optimize" } : new[] { "Compact" }, scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ scenario.Project.CompactAction = null;
+ scenario.Project.OptimizeAction = null;
+ Assert.Null(scenario.Run());
+ Assert.Single(scenario.Reports);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Reentry must reject the second project before touching the first operation.
+ [Fact]
+ public void Reentry_LeavesFirstProjectAndDialogUntouched()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var second = new CompactionProject(scenario.Project.FullFileName);
+ Exception? rejected = null;
+ bool sameDialog = false;
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var dialog = CompactionScenario.Dialog();
+ scenario.ObserveCompletion();
+ rejected = Record.Exception(() => FileSizeManager.CompactAndOptimizeFile(second));
+ sameDialog = ReferenceEquals(dialog, CompactionScenario.Dialog());
+ });
+
+ Assert.Null(scenario.Run());
+ var rejection = Assert.IsType(rejected);
+ Assert.Equal("Project compaction is already in progress.", rejection.Message);
+ Assert.True(sameDialog);
+ Assert.Empty(second.Calls);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ scenario.AssertClean();
+ });
+ }
+
+ /// An immediate worker may complete only after its dialog has been prepared and shown.
+ [Fact]
+ public void ImmediateCompletion_StartsOnceAndReportsAfterCleanup()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ bool dialogWasVisible = false;
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var dialog = CompactionScenario.Dialog();
+ dialogWasVisible = dialog.IsVisible;
+ // A duplicate Loaded notification must not start a second worker.
+ dialog.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent));
+ });
+
+ Assert.Null(scenario.Run());
+ Assert.True(dialogWasVisible);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ Assert.Single(scenario.Reports);
+ Assert.True(scenario.ReportsObservedCleanState);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Failure before showing a dialog must release the guard and permit a later operation.
+ [Fact]
+ public void StartupFailure_CleansStateAndAllowsReuse()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Injected startup failure.");
+ scenario.Project.PathFailure = failure;
+
+ Assert.Same(failure, scenario.Run());
+ Assert.Empty(scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ scenario.Project.PathFailure = null;
+ Assert.Null(scenario.Run());
+ scenario.AssertClean();
+ });
+ }
+
+ /// Hiding the modal dialog must retain protection until actual worker completion.
+ [Fact]
+ public void HideDuringWork_RecoversModalProtectionAndRestoresWindowStates()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var enabledWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ var disabledWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false, IsEnabled = false };
+ enabledWindow.Show();
+ disabledWindow.Show();
+ bool protectedDuringRecovery = false;
+ bool windowsDisabled = false;
+ bool returnedDuringRecovery = false;
+ try
+ {
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() =>
+ {
+ protectedDuringRecovery = ShellPublicVariables.CompactionInProgress;
+ windowsDisabled = !enabledWindow.IsEnabled && !disabledWindow.IsEnabled;
+ returnedDuringRecovery = scenario.Returned;
+ release.Set();
+ }));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("The recovery dispatcher did not process its probe.");
+ };
+
+ Assert.Null(scenario.Run());
+ Assert.True(protectedDuringRecovery);
+ Assert.True(windowsDisabled);
+ Assert.False(returnedDuringRecovery);
+ Assert.False(scenario.ReturnedBeforeCompletion);
+ Assert.True(enabledWindow.IsEnabled);
+ Assert.False(disabledWindow.IsEnabled);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ release.Set();
+ enabledWindow.Close();
+ disabledWindow.Close();
+ }
+ });
+ }
+
+ /// Aborted recovery retains the live writer and rejects another operation until observed completion.
+ [Fact]
+ public void AbortedRecovery_RetainsGuardAndResourcesWhileWorkerIsActive()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var completion = new DispatcherFrame();
+ bool completed = false;
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ var worker = Assert.IsType(CompactionScenario.Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) => { completed = true; completion.Continue = false; };
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() =>
+ Assert.IsType(CompactionScenario.Field("_recoveryFrame")).Continue = false));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Recovery-abort fixture was not released.");
+ };
+ try
+ {
+ var failure = Assert.Throws(() => FileSizeManager.CompactAndOptimizeFile(scenario.Project));
+ Assert.Contains("cannot be saved or closed", failure.Message);
+ Assert.True(ShellPublicVariables.CompactionInProgress);
+ Assert.NotNull(CompactionScenario.Field("_backgroundWorker"));
+ Assert.NotNull(CompactionScenario.Field("_timer"));
+ Assert.Same(scenario.Project, CompactionScenario.Field("_project"));
+ Assert.False(completed);
+ Assert.Empty(scenario.Reports);
+ var rejected = Assert.Throws(() => FileSizeManager.CompactAndOptimizeFile(scenario.Project));
+ Assert.Equal("Project compaction is already in progress.", rejected.Message);
+ }
+ finally
+ {
+ release.Set();
+ var watchdog = new DispatcherTimer { Interval = TimeSpan.FromSeconds(25) };
+ watchdog.Tick += (_, _) => completion.Continue = false;
+ watchdog.Start();
+ try { if (!completed) Dispatcher.PushFrame(completion); }
+ finally { watchdog.Stop(); }
+ Assert.True(completed);
+ // The production caller intentionally retains the guard after an aborted frame.
+ // The isolated fixture releases it only after observing the actual callback.
+ typeof(FileSizeManager).GetMethod("CleanupOperation", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, null);
+ }
+ scenario.AssertClean();
+ });
+ }
+
+ /// Success observers run after cleanup and may start a new independent operation.
+ [Fact]
+ public void SuccessObserver_CanStartNextOperation()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var second = new CompactionProject(scenario.Project.FullFileName);
+ bool entered = false;
+ FileSizeManager.ReportProgressEventHandler observer = _ =>
+ {
+ if (entered) return;
+ entered = true;
+ FileSizeManager.CompactAndOptimizeFile(second);
+ };
+ FileSizeManager.ReportProgress += observer;
+ try
+ {
+ Assert.Null(scenario.Run());
+ Assert.Equal(new[] { "Compact", "Optimize" }, second.Calls);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ FileSizeManager.ReportProgress -= observer;
+ }
+ });
+ }
+}
+
+/// Owns a temporary file and observes a real compaction invocation on the shared dispatcher.
+internal sealed class CompactionScenario : IDisposable
+{
+ private bool completionObserved;
+ private bool completionSubscribed;
+ private DispatcherFrame? completionFrame;
+ private readonly string path = Path.Combine(Path.GetTempPath(), $"framework-compaction-{Guid.NewGuid():N}.sqlite");
+
+ /// Creates a file-backed fake project and subscribes to final reporting.
+ public CompactionScenario()
+ {
+ File.WriteAllText(path, "Compaction lifecycle fixture");
+ Project = new CompactionProject(path);
+ FileSizeManager.ReportProgress += OnReport;
+ }
+
+ /// Gets the UI dispatcher on which the operation runs.
+ public Dispatcher Dispatcher { get; } = System.Windows.Threading.Dispatcher.CurrentDispatcher;
+ /// Gets the controlled project.
+ public CompactionProject Project { get; }
+ /// Gets the final reports.
+ public List Reports { get; } = new();
+ /// Gets whether the public method returned before the completion callback.
+ public bool ReturnedBeforeCompletion { get; private set; }
+ /// Gets whether the invocation has returned.
+ public bool Returned { get; private set; }
+ /// Gets whether all reporting observed released resources.
+ public bool ReportsObservedCleanState { get; private set; } = true;
+
+ /// Invokes compaction and drains any prematurely detached worker before assertions.
+ /// The exception delivered to the synchronous caller.
+ public Exception? Run()
+ {
+ Returned = false;
+ var error = Record.Exception(() => FileSizeManager.CompactAndOptimizeFile(Project));
+ Returned = true;
+ ReturnedBeforeCompletion = completionSubscribed && !completionObserved;
+ if (ReturnedBeforeCompletion)
+ {
+ completionFrame = new DispatcherFrame();
+ var watchdog = new DispatcherTimer { Interval = TimeSpan.FromSeconds(25) };
+ watchdog.Tick += (_, _) => completionFrame.Continue = false;
+ watchdog.Start();
+ try { Dispatcher.PushFrame(completionFrame); }
+ finally { watchdog.Stop(); }
+ Assert.True(completionObserved, "The detached worker failed to complete.");
+ }
+ return error;
+ }
+
+ /// Attaches a test observer to the currently active real worker.
+ public void ObserveCompletion()
+ {
+ if (completionSubscribed) return;
+ completionSubscribed = true;
+ var worker = Assert.IsType(Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) =>
+ {
+ completionObserved = true;
+ if (completionFrame != null) completionFrame.Continue = false;
+ };
+ }
+
+ /// Gets the currently visible compaction dialog.
+ /// The operation's progress window.
+ public static CompactProgressControl Dialog() => Application.Current.Windows.OfType().Single();
+
+ /// Asserts that a completed operation released all owned state.
+ public void AssertClean()
+ {
+ Assert.False(ShellPublicVariables.CompactionInProgress);
+ Assert.Null(Field("_backgroundWorker"));
+ Assert.Null(Field("_timer"));
+ Assert.Null(Field("_progressControl"));
+ Assert.Null(Field("_project"));
+ }
+
+ /// Reads owned resource fields without introducing production test seams.
+ /// The field name.
+ /// The current field value.
+ public static object? Field(string name) => typeof(FileSizeManager).GetField(name, BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null);
+
+ /// Records whether success is emitted only after operation cleanup.
+ /// The final success message.
+ private void OnReport(string message)
+ {
+ Reports.Add(message);
+ ReportsObservedCleanState &= !ShellPublicVariables.CompactionInProgress && Field("_project") == null && Field("_backgroundWorker") == null && Field("_timer") == null && Field("_progressControl") == null;
+ }
+
+ /// Unsubscribes reporting and deletes the temporary file.
+ public void Dispose()
+ {
+ FileSizeManager.ReportProgress -= OnReport;
+ File.Delete(path);
+ }
+}
+
+/// Provides controlled Compact and Optimize operations without real database work.
+internal sealed class CompactionProject : ProjectBase, IProject
+{
+ /// Creates a project with a real temporary file.
+ /// The owned fixture path.
+ public CompactionProject(string path)
+ {
+ FullFileName = path;
+ IsDirty = false;
+ ElementCollections = new ReadOnlyCollection(new List());
+ }
+ /// Sets a clean model with a pending layout save for shell close tests.
+ public void RequireLayoutSave() { IsDirty = false; LayoutDirty = true; }
+ /// Gets or sets the compaction operation.
+ public Action? CompactAction { get; set; }
+ /// Gets or sets the optimization operation.
+ public Action? OptimizeAction { get; set; }
+ /// Gets or sets the startup failure raised when reading the project path.
+ public Exception? PathFailure { get; set; }
+ /// Gets or sets a failure raised by the final save.
+ public Exception? SaveFailure { get; set; }
+ /// Gets the ordered operations requested by production code.
+ public List Calls { get; } = new();
+ ///
+ public override string Name { get; set; } = "Compaction fixture";
+ ///
+ public override string Description { get; set; } = string.Empty;
+ ///
+ public override string SoftwareVersion => "1.0";
+ ///
+ public override ImageSource ProjectImage => null!;
+ ///
+ string IProject.FullFileName { get => PathFailure == null ? FullFileName : throw PathFailure; set => FullFileName = value; }
+ ///
+ public override bool IsValid() => true;
+ ///
+ public override void CreateNew(string newFullFileName) => FullFileName = newFullFileName;
+ ///
+ public override void Open() => throw new NotSupportedException();
+ ///
+ public override void Save() { Calls.Add("Save"); if (SaveFailure != null) throw SaveFailure; }
+ ///
+ public override void Close() => Calls.Add("Close");
+ ///
+ public override void Compact() { Calls.Add("Compact"); CompactAction?.Invoke(); }
+ ///
+ public override void Optimize() { Calls.Add("Optimize"); OptimizeAction?.Invoke(); }
+}
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs b/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs
new file mode 100644
index 00000000..627ab50b
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs
@@ -0,0 +1,204 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using FrameworkInterfaces;
+using FrameworkUI.Tests.FrameworkUIDemo;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Exercises shell compaction callers in isolated processes because closing shuts down WPF.
+public class MainWindowCompactionTests
+{
+ private const string ChildCaseVariable = "FRAMEWORK_COMPACTION_CHILD_CASE";
+
+ /// Preserves final save ordering and errors while reporting the actual compaction failure.
+ /// The isolated close or manual-command scenario.
+ /// The asynchronous child-process verification.
+ [Theory]
+ [InlineData("success")]
+ [InlineData("compact-failure")]
+ [InlineData("optimize-failure")]
+ [InlineData("save-failure")]
+ [InlineData("manual-failure")]
+ [InlineData("active-guard")]
+ [InlineData("modal-worker-failure")]
+ [InlineData("recovery-worker-failure")]
+ [InlineData("cleanup-failure")]
+ [InlineData("worker-cleanup-failure")]
+ public async Task ShellCompaction_PreservesSaveAndErrorContracts(string testCase)
+ {
+ var childCase = Environment.GetEnvironmentVariable(ChildCaseVariable);
+ if (childCase != null)
+ {
+ if (childCase == testCase) DispatcherTestHost.Run(() => RunChild(testCase));
+ return;
+ }
+
+ var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
+ start.ArgumentList.Add("vstest");
+ start.ArgumentList.Add(typeof(MainWindowCompactionTests).Assembly.Location);
+ start.ArgumentList.Add("--TestCaseFilter:FullyQualifiedName~MainWindowCompactionTests.ShellCompaction_PreservesSaveAndErrorContracts");
+ start.Environment[ChildCaseVariable] = testCase;
+ using var child = Process.Start(start)!;
+ Task output = child.StandardOutput.ReadToEndAsync();
+ Task error = child.StandardError.ReadToEndAsync();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90));
+ try { await child.WaitForExitAsync(deadline.Token); }
+ catch (OperationCanceledException)
+ {
+ child.Kill(entireProcessTree: true);
+ throw new TimeoutException($"The isolated compaction scenario '{testCase}' did not finish.");
+ }
+ Assert.True(child.ExitCode == 0, $"{testCase}: {await output}\n{await error}");
+ }
+
+ /// Runs one real main-window scenario with isolated settings and a controlled project.
+ /// The scenario to execute.
+ private static void RunChild(string testCase)
+ {
+ if (testCase is "modal-worker-failure" or "recovery-worker-failure" or "cleanup-failure" or "worker-cleanup-failure")
+ {
+ CompactionFailureScenarios.Run(testCase);
+ return;
+ }
+ string folder = Path.Combine(Path.GetTempPath(), $"framework-shell-compaction-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(folder);
+ try
+ {
+ foreach (var field in typeof(ShellPublicVariables).GetFields(BindingFlags.Public | BindingFlags.Static))
+ {
+ if (field.IsInitOnly || field.FieldType != typeof(string) || !field.Name.EndsWith("Path", StringComparison.Ordinal)) continue;
+ field.SetValue(null, field.Name.EndsWith("FolderPath", StringComparison.Ordinal) ? folder + Path.DirectorySeparatorChar : Path.Combine(folder, field.Name + ".xml"));
+ }
+ ThemeManager.SetTheme(ThemeColor.Light);
+ Application.Current.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/GenericControls;component/Themes/GenericControlsTheme.xaml") });
+ Application.Current.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/FrameworkUI;component/Icons/IconDictionary.xaml") });
+ using var scenario = new CompactionScenario();
+ var window = new MainWindow { ProjectNode = new CompactionController(scenario.Project), ShowInTaskbar = false };
+ UserSettings.SaveWindowLayout = false;
+ UserSettings.CreateAutoRecoverBackup = false;
+ UserSettings.CompressProjectFileOnClose = true;
+ window.Show();
+ // Shutdown clears application resources; queued undo command queries are outside this lifecycle test.
+ window.CommandBindings.Clear();
+ scenario.Project.RequireLayoutSave();
+ var injected = new IOException($"Actual {testCase} error.");
+ string? displayedError = null;
+ bool saveWasBlockedDuringWork = false;
+ Action fail = () =>
+ {
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => displayedError = RespondToMessage("OK")));
+ throw injected;
+ };
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ CompactionScenario.Dialog().Close();
+ saveWasBlockedDuringWork = !scenario.Project.Calls.Contains("Save") && !scenario.Project.Calls.Contains("Close") && ShellPublicVariables.CompactionInProgress;
+ });
+ if (testCase is "compact-failure" or "manual-failure") scenario.Project.CompactAction = fail;
+ if (testCase == "optimize-failure") scenario.Project.OptimizeAction = fail;
+ if (testCase == "save-failure") scenario.Project.SaveFailure = injected;
+
+ if (testCase == "manual-failure")
+ {
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => RespondToMessage("Yes")));
+ Invoke(window, "CompactProjectFile_Click", window, new RoutedEventArgs());
+ Assert.Contains(injected.Message, displayedError);
+ Assert.Equal(new[] { "Compact" }, scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ return;
+ }
+ if (testCase == "active-guard")
+ {
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var closing = new CancelEventArgs();
+ Invoke(window, "MainWindow_Closing", window, closing);
+ Assert.True(closing.Cancel);
+ Assert.DoesNotContain("Save", scenario.Project.Calls);
+ Assert.DoesNotContain("Close", scenario.Project.Calls);
+ });
+ Assert.Null(scenario.Run());
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ return;
+ }
+
+ Exception? failure = Record.Exception(window.Close);
+ if (testCase == "save-failure")
+ {
+ Assert.Same(injected, failure);
+ Assert.Equal(new[] { "Compact", "Optimize", "Save" }, scenario.Project.Calls);
+ }
+ else
+ {
+ Assert.Null(failure);
+ Assert.Equal(testCase == "compact-failure" ? new[] { "Compact", "Save", "Close" } : new[] { "Compact", "Optimize", "Save", "Close" }, scenario.Project.Calls);
+ }
+ if (testCase is "compact-failure" or "optimize-failure")
+ {
+ Assert.Contains(injected.Message, displayedError);
+ Assert.Empty(scenario.Reports);
+ }
+ else
+ {
+ Assert.True(saveWasBlockedDuringWork);
+ Assert.Single(scenario.Reports);
+ }
+ scenario.AssertClean();
+ }
+ finally { Directory.Delete(folder, recursive: true); }
+ }
+
+ /// Reads and responds to the actual themed modal message without user interaction.
+ /// The response button text.
+ /// The message shown to the user.
+ private static string RespondToMessage(string button)
+ {
+ var dialog = Application.Current.Windows.OfType().Single();
+ string message = ((TextBlock)dialog.FindName("MessageText")).Text;
+ var panel = (StackPanel)dialog.FindName("ButtonPanel");
+ panel.Children.OfType