From 8f4be8fb91052b65fe03457b9615a75df2b17de6 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 08:37:53 +0100 Subject: [PATCH 1/9] Add workflow state monitoring infrastructure --- .../Diagnostics/WorkflowElementCounter.cs | 83 +++++++++++++++++++ .../Diagnostics/WorkflowElementStatus.cs | 11 +++ Bonsai.Editor/Diagnostics/WorkflowMeter.cs | 60 ++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs create mode 100644 Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs create mode 100644 Bonsai.Editor/Diagnostics/WorkflowMeter.cs diff --git a/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs b/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs new file mode 100644 index 00000000..741315cb --- /dev/null +++ b/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using Bonsai.Expressions; + +namespace Bonsai.Editor.Diagnostics +{ + internal class WorkflowElementCounter : IDisposable + { + readonly IDisposable subscription; + private long subscribeCount; + private long onNextCount; + private long onCompletedCount; + private long onErrorCount; + private long disposeCount; + private long lastNotificationCount; + + public WorkflowElementCounter(InspectBuilder inspectBuilder) + { + InspectBuilder = inspectBuilder ?? throw new ArgumentNullException(nameof(inspectBuilder)); + subscription = InspectBuilder.Output.SelectMany(source => + { + Interlocked.Increment(ref subscribeCount); + return source + .Do(value => Interlocked.Increment(ref onNextCount), + error => Interlocked.Increment(ref onErrorCount), + () => Interlocked.Increment(ref onCompletedCount)) + .IgnoreElements() + .Finally(() => Interlocked.Increment(ref disposeCount)); + }).Subscribe(); + } + + public InspectBuilder InspectBuilder { get; } + + public long SubscribeCount => subscribeCount; + + public long OnNextCount => onNextCount; + + public long OnErrorCount => onErrorCount; + + public long OnCompletedCount => onCompletedCount; + + public long DisposeCount => disposeCount; + + public long TotalCount => + Interlocked.Read(ref subscribeCount) + + Interlocked.Read(ref onNextCount) + + Interlocked.Read(ref onErrorCount) + + Interlocked.Read(ref onCompletedCount) + + Interlocked.Read(ref disposeCount); + + public WorkflowElementStatus GetStatus() + { + var notificationCount = Interlocked.Read(ref onNextCount); + var activeSubscriptions = Interlocked.Read(ref subscribeCount) - Interlocked.Read(ref disposeCount); + if (activeSubscriptions > 0) + { + if (notificationCount > lastNotificationCount) + { + lastNotificationCount = notificationCount; + return WorkflowElementStatus.Notifying; + } + + return WorkflowElementStatus.Active; + } + else + { + lastNotificationCount = notificationCount; + var errorCount = Interlocked.Read(ref onErrorCount); + var completedCount = Interlocked.Read(ref onCompletedCount); + if (errorCount > 0) return WorkflowElementStatus.Error; + else if (completedCount > 0) return WorkflowElementStatus.Completed; + return WorkflowElementStatus.Ready; + } + } + + public void Dispose() + { + subscription.Dispose(); + } + } +} diff --git a/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs b/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs new file mode 100644 index 00000000..bf24f929 --- /dev/null +++ b/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs @@ -0,0 +1,11 @@ +namespace Bonsai.Editor.Diagnostics +{ + internal enum WorkflowElementStatus + { + Ready, + Active, + Notifying, + Completed, + Error, + } +} diff --git a/Bonsai.Editor/Diagnostics/WorkflowMeter.cs b/Bonsai.Editor/Diagnostics/WorkflowMeter.cs new file mode 100644 index 00000000..660ea63a --- /dev/null +++ b/Bonsai.Editor/Diagnostics/WorkflowMeter.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bonsai.Dag; +using Bonsai.Expressions; + +namespace Bonsai.Editor.Diagnostics +{ + internal class WorkflowMeter : IDisposable + { + readonly Dictionary counters; + + public WorkflowMeter(ExpressionBuilderGraph workflow) + { + counters = GetElements(workflow).ToDictionary( + inspectBuilder => (ExpressionBuilder)inspectBuilder, + inspectBuilder => new WorkflowElementCounter(inspectBuilder)); + } + + public IReadOnlyDictionary Counters => counters; + + static IEnumerable GetElements(ExpressionBuilderGraph workflow) + { + var stack = new Stack>>(); + stack.Push(workflow.GetEnumerator()); + + while (stack.Count > 0) + { + var nodeEnumerator = stack.Peek(); + while (true) + { + if (!nodeEnumerator.MoveNext()) + { + stack.Pop(); + break; + } + + var inspectBuilder = (InspectBuilder)nodeEnumerator.Current.Value; + yield return inspectBuilder; + + if (inspectBuilder.Builder is IWorkflowExpressionBuilder workflowBuilder && + workflowBuilder.Workflow != null) + { + stack.Push(workflowBuilder.Workflow.GetEnumerator()); + break; + } + } + } + } + + public void Dispose() + { + foreach (var counter in counters.Values) + { + counter.Dispose(); + } + counters.Clear(); + } + } +} From d03dc81009e79d17484cfc6fcca2e94a6f4b2e64 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 08:50:16 +0100 Subject: [PATCH 2/9] Simplify code and rename connector to port --- Bonsai.Editor/GraphView/GraphViewControl.cs | 145 +++++++++----------- 1 file changed, 68 insertions(+), 77 deletions(-) diff --git a/Bonsai.Editor/GraphView/GraphViewControl.cs b/Bonsai.Editor/GraphView/GraphViewControl.cs index 4e76f206..af5218a5 100644 --- a/Bonsai.Editor/GraphView/GraphViewControl.cs +++ b/Bonsai.Editor/GraphView/GraphViewControl.cs @@ -20,7 +20,7 @@ partial class GraphViewControl : UserControl, IGraphView const float DefaultPenWidth = 2; const float DefaultNodeSize = 30; const float DefaultNodeAirspace = 80; - const float DefaultConnectorSize = 6; + const float DefaultPortSize = 6; const float DefaultLabelTextOffset = 5; static readonly Color CursorLight = Color.White; static readonly Color CursorDark = Color.Black; @@ -34,36 +34,36 @@ partial class GraphViewControl : UserControl, IGraphView static readonly Color RubberBandPenColor = Color.FromArgb(51, 153, 255); static readonly Color RubberBandBrushColor = Color.FromArgb(128, 170, 204, 238); static readonly Color HotBrushColor = Color.FromArgb(128, 229, 243, 251); - static readonly StringFormat TextFormat = new StringFormat(StringFormatFlags.NoWrap); - static readonly StringFormat CenteredTextFormat = new StringFormat(StringFormat.GenericTypographic) + static readonly StringFormat TextFormat = new(StringFormatFlags.NoWrap); + static readonly StringFormat CenteredTextFormat = new(StringFormat.GenericTypographic) { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; - static readonly StringFormat VectorTextFormat = new StringFormat(StringFormat.GenericTypographic) + static readonly StringFormat VectorTextFormat = new(StringFormat.GenericTypographic) { FormatFlags = StringFormatFlags.NoWrap, Trimming = StringTrimming.Character }; - static readonly object EventItemDrag = new object(); - static readonly object EventNodeMouseClick = new object(); - static readonly object EventNodeMouseDoubleClick = new object(); - static readonly object EventNodeMouseEnter = new object(); - static readonly object EventNodeMouseLeave = new object(); - static readonly object EventNodeMouseHover = new object(); - static readonly object EventSelectedNodeChanged = new object(); + static readonly object EventItemDrag = new(); + static readonly object EventNodeMouseClick = new(); + static readonly object EventNodeMouseDoubleClick = new(); + static readonly object EventNodeMouseEnter = new(); + static readonly object EventNodeMouseLeave = new(); + static readonly object EventNodeMouseHover = new(); + static readonly object EventSelectedNodeChanged = new(); float PenWidth; float NodeAirspace; float NodeSize; float HalfSize; - float ConnectorSize; + float PortSize; float LabelTextOffset; SizeF VectorTextOffset; SizeF EntryOffset; SizeF ExitOffset; - SizeF ConnectorOffset; + SizeF InputPortOffset; Pen SolidPen; Pen DashPen; Font DefaultIconFont; @@ -75,9 +75,9 @@ partial class GraphViewControl : UserControl, IGraphView RectangleF previousRectangle; Point? previousScrollOffset; GraphNode[] selectionBeforeDrag; - readonly LayoutNodeCollection layoutNodes = new LayoutNodeCollection(); - readonly HashSet selectedNodes = new HashSet(); - readonly SvgRendererState iconRendererState = new SvgRendererState(); + readonly LayoutNodeCollection layoutNodes = new(); + readonly HashSet selectedNodes = new(); + readonly SvgRendererState iconRendererState = new(); IReadOnlyList nodes; GraphNode pivot; GraphNode hot; @@ -401,12 +401,12 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) NodeAirspace = DefaultNodeAirspace * drawScale; NodeSize = DefaultNodeSize * drawScale; HalfSize = NodeSize / 2; - ConnectorSize = DefaultConnectorSize * drawScale; + PortSize = DefaultPortSize * drawScale; LabelTextOffset = DefaultLabelTextOffset * drawScale; VectorTextOffset = new SizeF(0, 1.375f * drawScale); - EntryOffset = new SizeF(-2 * DefaultPenWidth * drawScale, DefaultNodeSize * drawScale / 2); - ExitOffset = new SizeF(NodeSize + 2 * DefaultPenWidth * drawScale, DefaultNodeSize * drawScale / 2); - ConnectorOffset = new SizeF(-ConnectorSize / 2, EntryOffset.Height - ConnectorSize / 2); + EntryOffset = new SizeF(-2 * PenWidth, HalfSize); + ExitOffset = new SizeF(NodeSize + 2 * PenWidth, HalfSize); + InputPortOffset = new SizeF(-PortSize / 2, EntryOffset.Height - PortSize / 2); SolidPen = new Pen(NodeEdgeColor, drawScale); DashPen = new Pen(NodeEdgeColor, drawScale) { DashPattern = new[] { 4f, 2f } }; DefaultIconFont = new Font(Font, FontStyle.Bold); @@ -426,11 +426,9 @@ Rectangle GetBoundingRectangle(GraphNode node) var labelRectangle = nodeLayout.LabelRectangle; if (nodeText != nodeLayout.Text) { - using (var graphics = CreateVectorGraphics()) - { - nodeLayout.SetNodeLabel(nodeText, Font, graphics); - labelRectangle = RectangleF.Union(labelRectangle, nodeLayout.LabelRectangle); - } + using var graphics = CreateVectorGraphics(); + nodeLayout.SetNodeLabel(nodeText, Font, graphics); + labelRectangle = RectangleF.Union(labelRectangle, nodeLayout.LabelRectangle); } labelRectangle.Offset(offset); @@ -594,11 +592,11 @@ void ProcessRubberBand(Rectangle? rect) if (rect.HasValue) { var selection = new HashSet(GetRubberBandSelection(rect.Value)); - if (Control.ModifierKeys.HasFlag(Keys.Control)) + if (ModifierKeys.HasFlag(Keys.Control)) { selection.SymmetricExceptWith(selectionBeforeDrag); } - else if (Control.ModifierKeys.HasFlag(Keys.Shift)) + else if (ModifierKeys.HasFlag(Keys.Shift)) { selection.UnionWith(selectionBeforeDrag); } @@ -645,7 +643,7 @@ Point GetScrollablePoint(Point point, Point scrollOrigin) return point; } - Rectangle GetNormalizedRectangle(Point p1, Point p2) + static Rectangle GetNormalizedRectangle(Point p1, Point p2) { return new Rectangle( Math.Min(p1.X, p2.X), @@ -654,19 +652,19 @@ Rectangle GetNormalizedRectangle(Point p1, Point p2) Math.Abs(p2.Y - p1.Y)); } - float SquaredDistance(ref PointF point, ref PointF center) + static float SquaredDistance(ref PointF point, ref PointF center) { var xdiff = point.X - center.X; var ydiff = point.Y - center.Y; return xdiff * xdiff + ydiff * ydiff; } - bool CircleIntersect(PointF center, float radius, PointF point) + static bool CircleIntersect(PointF center, float radius, PointF point) { return SquaredDistance(ref point, ref center) <= radius * radius; } - bool CircleIntersect(PointF center, float radius, RectangleF rect) + static bool CircleIntersect(PointF center, float radius, RectangleF rect) { float closestX = Math.Max(rect.Left, Math.Min(center.X, rect.Right)); float closestY = Math.Max(rect.Top, Math.Min(center.Y, rect.Bottom)); @@ -758,7 +756,7 @@ GraphNode ResolveDummyPredecessor(GraphNode source) return source; } - GraphNode ResolveDummySuccessor(GraphNode source) + static GraphNode ResolveDummySuccessor(GraphNode source) { while (source.Value == null) { @@ -768,7 +766,7 @@ GraphNode ResolveDummySuccessor(GraphNode source) return source; } - GraphNode GetDefaultSuccessor(GraphNode node) + static GraphNode GetDefaultSuccessor(GraphNode node) { return (from successor in node.Successors orderby successor.Node.LayerIndex ascending @@ -935,7 +933,7 @@ private static IEnumerable ConcatEmpty(IEnumerable fi if (!any) foreach (var xs in second) yield return xs; } - private IEnumerable GetSelectionRange(GraphNode from, GraphNode to) + static private IEnumerable GetSelectionRange(GraphNode from, GraphNode to) { return ConcatEmpty( ConcatEmpty(GetAllPaths(from, to), GetAllPaths(to, from)), @@ -1134,12 +1132,12 @@ private void DrawNode( if (hot) graphics.FillEllipse(iconRendererState.FillStyle(HotBrushColor), nodeRectangle); if (layout.Node.ArgumentCount < layout.Node.ArgumentRange.UpperBound) { - var connectorRectangle = layout.ConnectorRectangle; + var inputPortRectangle = layout.InputPortRectangle; var argumentRequired = layout.Node.ArgumentCount < layout.Node.ArgumentRange.LowerBound; - var connectorBrush = argumentRequired ? Brushes.Black : Brushes.White; - connectorRectangle.Offset(offset.Width, offset.Height); - graphics.DrawEllipse(iconRendererState.StrokeStyle(NodeEdgeColor, PenWidth), connectorRectangle); - graphics.FillEllipse(connectorBrush, connectorRectangle); + var inputPortBrush = argumentRequired ? Brushes.Black : Brushes.White; + inputPortRectangle.Offset(offset.Width, offset.Height); + graphics.DrawEllipse(iconRendererState.StrokeStyle(NodeEdgeColor, PenWidth), inputPortRectangle); + graphics.FillEllipse(inputPortBrush, inputPortRectangle); } } @@ -1174,36 +1172,31 @@ public void DrawGraphics(IGraphics graphics) graphics.SmoothingMode = SmoothingMode.AntiAlias; graphics.Clear(Color.White); - using (var measureGraphics = CreateVectorGraphics()) + var font = Font; + using var measureGraphics = CreateVectorGraphics(); + using var fill = new SolidBrush(Color.White); + using var stroke = new Pen(NodeEdgeColor, PenWidth); + DrawEdges(graphics, Size.Empty); + foreach (var layout in layoutNodes) { - var font = Font; - using (var fill = new SolidBrush(Color.White)) - using (var stroke = new Pen(NodeEdgeColor, PenWidth)) + if (layout.Node.Value != null) { - DrawEdges(graphics, Size.Empty); - foreach (var layout in layoutNodes) + DrawNode(graphics, layout, Size.Empty, null, fill, stroke, Color.White, vectorFont: font); + var labelRect = layout.LabelRectangle; + foreach (var line in layout.Label.Split(Environment.NewLine.ToArray(), + StringSplitOptions.RemoveEmptyEntries)) { - if (layout.Node.Value != null) - { - DrawNode(graphics, layout, Size.Empty, null, fill, stroke, Color.White, vectorFont: font); - var labelRect = layout.LabelRectangle; - foreach (var line in layout.Label.Split(Environment.NewLine.ToArray(), - StringSplitOptions.RemoveEmptyEntries)) - { - int charactersFitted, linesFilled; - var size = measureGraphics.MeasureString( - line, font, - labelRect.Size, - VectorTextFormat, - out charactersFitted, out linesFilled); - var lineLabel = line.Length > charactersFitted ? line.Substring(0, charactersFitted) : line; - graphics.DrawString(lineLabel, font, Brushes.Black, labelRect); - labelRect.Y += size.Height; - } - } - else DrawDummyNode(graphics, layout, Size.Empty); + var size = measureGraphics.MeasureString( + line, font, + labelRect.Size, + VectorTextFormat, + out int charactersFitted, out _); + var lineLabel = line.Length > charactersFitted ? line.Substring(0, charactersFitted) : line; + graphics.DrawString(lineLabel, font, Brushes.Black, labelRect); + labelRect.Y += size.Height; } } + else DrawDummyNode(graphics, layout, Size.Empty); } } @@ -1256,12 +1249,10 @@ private void canvas_Paint(object sender, PaintEventArgs e) if (rubberBand.Width > 0 && rubberBand.Height > 0) { - using (var rubberBandPen = new Pen(RubberBandPenColor)) - using (var rubberBandBrush = new SolidBrush(RubberBandBrushColor)) - { - e.Graphics.FillRectangle(rubberBandBrush, rubberBand); - e.Graphics.DrawRectangle(rubberBandPen, rubberBand.X, rubberBand.Y, rubberBand.Width, rubberBand.Height); - } + using var rubberBandPen = new Pen(RubberBandPenColor); + using var rubberBandBrush = new SolidBrush(RubberBandBrushColor); + e.Graphics.FillRectangle(rubberBandBrush, rubberBand); + e.Graphics.DrawRectangle(rubberBandPen, rubberBand.X, rubberBand.Y, rubberBand.Width, rubberBand.Height); } } @@ -1393,9 +1384,9 @@ public PointF ExitPoint get { return PointF.Add(Location, View.ExitOffset); } } - public PointF ConnectorLocation + public PointF InputPortLocation { - get { return PointF.Add(Location, View.ConnectorOffset); } + get { return PointF.Add(Location, View.InputPortOffset); } } public RectangleF BoundingRectangle @@ -1403,18 +1394,18 @@ public RectangleF BoundingRectangle get { return new RectangleF( - ConnectorLocation.X - View.PenWidth, Location.Y - View.PenWidth, - View.ConnectorSize / 2 + View.NodeSize + 2 * View.PenWidth, View.NodeSize + 2 * View.PenWidth); + InputPortLocation.X - View.PenWidth, Location.Y - View.PenWidth, + View.PortSize / 2 + View.NodeSize + 2 * View.PenWidth, View.NodeSize + 2 * View.PenWidth); } } - public RectangleF ConnectorRectangle + public RectangleF InputPortRectangle { get { return new RectangleF( - ConnectorLocation, - new SizeF(View.ConnectorSize, View.ConnectorSize)); + InputPortLocation, + new SizeF(View.PortSize, View.PortSize)); } } From a2d0d41158271e1b144c08ed5d1a70c66eed0eb1 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 08:56:20 +0100 Subject: [PATCH 3/9] Add workflow state watch to debug mode --- Bonsai.Editor/EditorForm.cs | 9 ++++ Bonsai.Editor/GraphModel/GraphNode.cs | 5 ++ Bonsai.Editor/GraphView/GraphViewControl.cs | 45 ++++++++++++++++ Bonsai.Editor/GraphView/WorkflowGraphView.cs | 38 ++++++++++++++ Bonsai.Editor/WorkflowWatch.cs | 54 ++++++++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 Bonsai.Editor/WorkflowWatch.cs diff --git a/Bonsai.Editor/EditorForm.cs b/Bonsai.Editor/EditorForm.cs index 48f0799a..2f99b24a 100644 --- a/Bonsai.Editor/EditorForm.cs +++ b/Bonsai.Editor/EditorForm.cs @@ -72,6 +72,7 @@ public partial class EditorForm : Form readonly List workflowElements; readonly List workflowExtensions; readonly WorkflowRuntimeExceptionCache exceptionCache; + readonly WorkflowWatch workflowWatch; readonly string definitionsPath; AttributeCollection browsableAttributes; DirectoryInfo extensionsPath; @@ -147,6 +148,7 @@ public EditorForm( } serviceProvider = provider; + workflowWatch = new WorkflowWatch(); treeCache = new List(); editorSite = new EditorSite(this); hotKeys = new HotKeyMessageFilter(); @@ -1196,6 +1198,7 @@ IDisposable ShutdownSequence() running = null; building = false; + workflowWatch.Stop(); editorControl.UpdateVisualizerLayout(); UpdateTitle(); })); @@ -1223,6 +1226,7 @@ void StartWorkflow(bool debug) var runtimeWorkflow = workflowBuilder.Workflow.BuildObservable(); Invoke((Action)(() => { + if (debug) workflowWatch.Start(workflowBuilder.Workflow); statusTextLabel.Text = Resources.RunningStatus; statusImageLabel.Image = statusRunningImage; editorSite.OnWorkflowStarted(EventArgs.Empty); @@ -2531,6 +2535,11 @@ public object GetService(Type serviceType) return siteForm.iconRenderer; } + if (serviceType == typeof(WorkflowWatch)) + { + return siteForm.workflowWatch; + } + if (serviceType == typeof(DialogTypeVisualizer)) { var selectedView = siteForm.selectionModel.SelectedView; diff --git a/Bonsai.Editor/GraphModel/GraphNode.cs b/Bonsai.Editor/GraphModel/GraphNode.cs index f4e246e2..949c26a7 100644 --- a/Bonsai.Editor/GraphModel/GraphNode.cs +++ b/Bonsai.Editor/GraphModel/GraphNode.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Drawing; using System.Drawing.Drawing2D; +using Bonsai.Editor.Diagnostics; using Bonsai.Expressions; namespace Bonsai.Editor.GraphModel @@ -156,6 +157,10 @@ public bool Highlight } } + public WorkflowElementStatus? Status { get; set; } + + public int NotifyingCounter { get; set; } + /// /// Returns a string that represents the value of this instance. diff --git a/Bonsai.Editor/GraphView/GraphViewControl.cs b/Bonsai.Editor/GraphView/GraphViewControl.cs index af5218a5..702a0ff1 100644 --- a/Bonsai.Editor/GraphView/GraphViewControl.cs +++ b/Bonsai.Editor/GraphView/GraphViewControl.cs @@ -64,6 +64,7 @@ partial class GraphViewControl : UserControl, IGraphView SizeF EntryOffset; SizeF ExitOffset; SizeF InputPortOffset; + SizeF OutputPortOffset; Pen SolidPen; Pen DashPen; Font DefaultIconFont; @@ -407,6 +408,7 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) EntryOffset = new SizeF(-2 * PenWidth, HalfSize); ExitOffset = new SizeF(NodeSize + 2 * PenWidth, HalfSize); InputPortOffset = new SizeF(-PortSize / 2, EntryOffset.Height - PortSize / 2); + OutputPortOffset = new SizeF(NodeSize - PortSize / 2, ExitOffset.Height - PortSize / 2); SolidPen = new Pen(NodeEdgeColor, drawScale); DashPen = new Pen(NodeEdgeColor, drawScale) { DashPattern = new[] { 4f, 2f } }; DefaultIconFont = new Font(Font, FontStyle.Bold); @@ -1139,6 +1141,34 @@ private void DrawNode( graphics.DrawEllipse(iconRendererState.StrokeStyle(NodeEdgeColor, PenWidth), inputPortRectangle); graphics.FillEllipse(inputPortBrush, inputPortRectangle); } + + if (layout.Node.Status != null) + { + var nodeStatus = layout.Node.Status.GetValueOrDefault(); + var outputPortRectangle = layout.OutputPortRectangle; + var outputPortBrush = nodeStatus switch + { + Diagnostics.WorkflowElementStatus.Completed => Brushes.Green, + Diagnostics.WorkflowElementStatus.Error => Brushes.Red, + _ => Brushes.White + }; + outputPortRectangle.Offset(offset.Width, offset.Height); + + var active = nodeStatus == Diagnostics.WorkflowElementStatus.Active || + nodeStatus == Diagnostics.WorkflowElementStatus.Notifying; + var terminated = nodeStatus == Diagnostics.WorkflowElementStatus.Completed || + nodeStatus == Diagnostics.WorkflowElementStatus.Error; + var edgeColor = active || terminated ? CursorColor : NodeEdgeColor; + + graphics.DrawEllipse(iconRendererState.StrokeStyle(edgeColor, PenWidth), outputPortRectangle); + graphics.FillEllipse(outputPortBrush, outputPortRectangle); + if (active && layout.Node.NotifyingCounter != 0) + { + var notify1 = new PointF(outputPortRectangle.X + PortSize / 2, outputPortRectangle.Top + PenWidth); + var notify2 = new PointF(outputPortRectangle.X + PortSize / 2, outputPortRectangle.Bottom - PenWidth); + graphics.DrawLine(iconRendererState.StrokeStyle(CursorDark, PenWidth), notify1, notify2); + } + } } private void DrawDummyNode(IGraphics graphics, LayoutNode layout, Size offset) @@ -1389,6 +1419,11 @@ public PointF InputPortLocation get { return PointF.Add(Location, View.InputPortOffset); } } + public PointF OutputPortLocation + { + get { return PointF.Add(Location, View.OutputPortOffset); } + } + public RectangleF BoundingRectangle { get @@ -1409,6 +1444,16 @@ public RectangleF InputPortRectangle } } + public RectangleF OutputPortRectangle + { + get + { + return new RectangleF( + OutputPortLocation, + new SizeF(View.PortSize, View.PortSize)); + } + } + public void SetNodeLabel(string text, Font font, Graphics graphics) { Text = text; diff --git a/Bonsai.Editor/GraphView/WorkflowGraphView.cs b/Bonsai.Editor/GraphView/WorkflowGraphView.cs index 539aacea..8dea220c 100644 --- a/Bonsai.Editor/GraphView/WorkflowGraphView.cs +++ b/Bonsai.Editor/GraphView/WorkflowGraphView.cs @@ -16,6 +16,7 @@ using Bonsai.Design; using Bonsai.Editor.GraphModel; using System.Xml; +using Bonsai.Editor.Diagnostics; namespace Bonsai.Editor.GraphView { @@ -49,6 +50,7 @@ partial class WorkflowGraphView : UserControl readonly IServiceProvider serviceProvider; readonly IUIService uiService; readonly ThemeRenderer themeRenderer; + readonly WorkflowWatch workflowWatch; readonly IDefinitionProvider definitionProvider; Dictionary visualizerMapping; VisualizerLayout visualizerLayout; @@ -81,7 +83,9 @@ public WorkflowGraphView(IServiceProvider provider, WorkflowEditorControl owner, typeVisualizerMap = (TypeVisualizerMap)provider.GetService(typeof(TypeVisualizerMap)); editorState = (IWorkflowEditorState)provider.GetService(typeof(IWorkflowEditorState)); workflowEditorMapping = new Dictionary(); + workflowWatch = (WorkflowWatch)provider.GetService(typeof(WorkflowWatch)); + workflowWatch.Update += WorkflowWatch_Tick; graphView.HandleDestroyed += graphView_HandleDestroyed; editorState.WorkflowStarted += editorService_WorkflowStarted; themeRenderer.ThemeChanged += themeRenderer_ThemeChanged; @@ -89,6 +93,39 @@ public WorkflowGraphView(IServiceProvider provider, WorkflowEditorControl owner, InitializeViewBindings(); } + private void WorkflowWatch_Tick(object sender, EventArgs e) + { + var layers = graphView.Nodes; + if (layers != null) + { + for (int i = 0; i < layers.Count; i++) + { + foreach (var node in layers[i]) + { + if (workflowWatch.Counters?.TryGetValue(node.Value, out var counter) is true) + { + node.Status = counter.GetStatus(); + if (node.Status == WorkflowElementStatus.Notifying) + { + node.NotifyingCounter = (node.NotifyingCounter + 1) % 2; + } + else if (node.Status != WorkflowElementStatus.Active) + { + node.NotifyingCounter = 0; + } + } + else + { + node.Status = null; + node.NotifyingCounter = 0; + } + } + } + } + + graphView.Invalidate(); + } + internal WorkflowEditor Editor { get; } internal WorkflowEditorLauncher Launcher { get; set; } @@ -1268,6 +1305,7 @@ private void graphView_HandleDestroyed(object sender, EventArgs e) { editorState.WorkflowStarted -= editorService_WorkflowStarted; themeRenderer.ThemeChanged -= themeRenderer_ThemeChanged; + workflowWatch.Update -= WorkflowWatch_Tick; } private void editorService_WorkflowStarted(object sender, EventArgs e) diff --git a/Bonsai.Editor/WorkflowWatch.cs b/Bonsai.Editor/WorkflowWatch.cs new file mode 100644 index 00000000..1b38d8bf --- /dev/null +++ b/Bonsai.Editor/WorkflowWatch.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using Bonsai.Editor.Diagnostics; +using Bonsai.Expressions; + +namespace Bonsai.Editor +{ + internal class WorkflowWatch + { + const int WatchPeriod = 100; + readonly Timer watchTimer = new() { Interval = WatchPeriod }; + WorkflowMeter workflowMeter; + + public WorkflowWatch() + { + watchTimer.Tick += (_, e) => OnUpdate(e); + } + + public event EventHandler Update; + + private void OnUpdate(EventArgs e) + { + Update?.Invoke(this, e); + } + + public void Start(ExpressionBuilderGraph workflow) + { + if (workflowMeter is not null) + { + throw new InvalidOperationException( + $"{nameof(Stop)} must be called before starting a new watch."); + } + + workflowMeter = new WorkflowMeter(workflow); + OnUpdate(EventArgs.Empty); + watchTimer.Start(); + } + + public IReadOnlyDictionary Counters => + workflowMeter?.Counters; + + public void Stop() + { + watchTimer.Stop(); + if (workflowMeter is not null) + { + workflowMeter.Dispose(); + workflowMeter = null; + } + OnUpdate(EventArgs.Empty); + } + } +} From f36647bdf144bf919f6532729134813380c87d22 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 10:42:08 +0100 Subject: [PATCH 4/9] Avoid creating counters on elements with no output --- Bonsai.Editor/Diagnostics/WorkflowMeter.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Bonsai.Editor/Diagnostics/WorkflowMeter.cs b/Bonsai.Editor/Diagnostics/WorkflowMeter.cs index 660ea63a..d7dee5fb 100644 --- a/Bonsai.Editor/Diagnostics/WorkflowMeter.cs +++ b/Bonsai.Editor/Diagnostics/WorkflowMeter.cs @@ -36,13 +36,16 @@ static IEnumerable GetElements(ExpressionBuilderGraph workflow) } var inspectBuilder = (InspectBuilder)nodeEnumerator.Current.Value; - yield return inspectBuilder; - - if (inspectBuilder.Builder is IWorkflowExpressionBuilder workflowBuilder && - workflowBuilder.Workflow != null) + if (inspectBuilder.Output != null) { - stack.Push(workflowBuilder.Workflow.GetEnumerator()); - break; + yield return inspectBuilder; + + if (inspectBuilder.Builder is IWorkflowExpressionBuilder workflowBuilder && + workflowBuilder.Workflow != null) + { + stack.Push(workflowBuilder.Workflow.GetEnumerator()); + break; + } } } } From f08ee6c3b7089d67dc782a549c72296f88ccb18f Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 10:44:23 +0100 Subject: [PATCH 5/9] Move watch port on top of node branch point --- Bonsai.Editor/GraphView/GraphViewControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bonsai.Editor/GraphView/GraphViewControl.cs b/Bonsai.Editor/GraphView/GraphViewControl.cs index 702a0ff1..77d08852 100644 --- a/Bonsai.Editor/GraphView/GraphViewControl.cs +++ b/Bonsai.Editor/GraphView/GraphViewControl.cs @@ -408,7 +408,7 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) EntryOffset = new SizeF(-2 * PenWidth, HalfSize); ExitOffset = new SizeF(NodeSize + 2 * PenWidth, HalfSize); InputPortOffset = new SizeF(-PortSize / 2, EntryOffset.Height - PortSize / 2); - OutputPortOffset = new SizeF(NodeSize - PortSize / 2, ExitOffset.Height - PortSize / 2); + OutputPortOffset = new SizeF(ExitOffset.Width - PortSize / 2, ExitOffset.Height - PortSize / 2); SolidPen = new Pen(NodeEdgeColor, drawScale); DashPen = new Pen(NodeEdgeColor, drawScale) { DashPattern = new[] { 4f, 2f } }; DefaultIconFont = new Font(Font, FontStyle.Bold); From 3b316dce30bd74ec611506f3d6747561281a8399 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 13:56:22 +0100 Subject: [PATCH 6/9] Replace blinker with spinner for notifications --- Bonsai.Editor/GraphModel/GraphNode.cs | 3 +- Bonsai.Editor/GraphView/GraphViewControl.cs | 57 +++++++++++++++----- Bonsai.Editor/GraphView/WorkflowGraphView.cs | 6 +-- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/Bonsai.Editor/GraphModel/GraphNode.cs b/Bonsai.Editor/GraphModel/GraphNode.cs index 949c26a7..7f572153 100644 --- a/Bonsai.Editor/GraphModel/GraphNode.cs +++ b/Bonsai.Editor/GraphModel/GraphNode.cs @@ -159,8 +159,7 @@ public bool Highlight public WorkflowElementStatus? Status { get; set; } - public int NotifyingCounter { get; set; } - + public int NotifyingCounter { get; set; } = -1; /// /// Returns a string that represents the value of this instance. diff --git a/Bonsai.Editor/GraphView/GraphViewControl.cs b/Bonsai.Editor/GraphView/GraphViewControl.cs index 77d08852..8a717d48 100644 --- a/Bonsai.Editor/GraphView/GraphViewControl.cs +++ b/Bonsai.Editor/GraphView/GraphViewControl.cs @@ -22,6 +22,7 @@ partial class GraphViewControl : UserControl, IGraphView const float DefaultNodeAirspace = 80; const float DefaultPortSize = 6; const float DefaultLabelTextOffset = 5; + static readonly float SpinnerRotation = (float)Math.Cos(Math.PI / 4); static readonly Color CursorLight = Color.White; static readonly Color CursorDark = Color.Black; static readonly Color NodeEdgeColor = Color.DarkGray; @@ -57,14 +58,17 @@ partial class GraphViewControl : UserControl, IGraphView float PenWidth; float NodeAirspace; float NodeSize; - float HalfSize; float PortSize; + float HalfNodeSize; + float HalfPortSize; + float HalfPenWidth; float LabelTextOffset; SizeF VectorTextOffset; SizeF EntryOffset; SizeF ExitOffset; SizeF InputPortOffset; SizeF OutputPortOffset; + SpinnerOffset[] SpinnerOffsets; Pen SolidPen; Pen DashPen; Font DefaultIconFont; @@ -401,14 +405,28 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) PenWidth = DefaultPenWidth * drawScale; NodeAirspace = DefaultNodeAirspace * drawScale; NodeSize = DefaultNodeSize * drawScale; - HalfSize = NodeSize / 2; PortSize = DefaultPortSize * drawScale; + HalfNodeSize = NodeSize / 2; + HalfPortSize = PortSize / 2; + HalfPenWidth = PenWidth / 2; LabelTextOffset = DefaultLabelTextOffset * drawScale; VectorTextOffset = new SizeF(0, 1.375f * drawScale); - EntryOffset = new SizeF(-2 * PenWidth, HalfSize); - ExitOffset = new SizeF(NodeSize + 2 * PenWidth, HalfSize); - InputPortOffset = new SizeF(-PortSize / 2, EntryOffset.Height - PortSize / 2); - OutputPortOffset = new SizeF(ExitOffset.Width - PortSize / 2, ExitOffset.Height - PortSize / 2); + EntryOffset = new SizeF(-2 * PenWidth, HalfNodeSize); + ExitOffset = new SizeF(NodeSize + 2 * PenWidth, HalfNodeSize); + InputPortOffset = new SizeF(-HalfPortSize, EntryOffset.Height - HalfPortSize); + OutputPortOffset = new SizeF(ExitOffset.Width - HalfPortSize, ExitOffset.Height - HalfPortSize); + + var spinnerTilt = (HalfPortSize - HalfPenWidth) * SpinnerRotation; + SpinnerOffsets = new SpinnerOffset[] + { + new(x1: HalfPortSize, y1: HalfPenWidth, x2: HalfPortSize, y2: PortSize - HalfPenWidth), + new(x1: HalfPortSize + spinnerTilt, y1: HalfPortSize - spinnerTilt, + x2: HalfPortSize - spinnerTilt, y2: HalfPortSize + spinnerTilt), + new(x1: HalfPenWidth, y1: HalfPortSize, x2: PortSize - HalfPenWidth, y2: HalfPortSize), + new(x1: HalfPortSize + spinnerTilt, y1: HalfPortSize + spinnerTilt, + x2: HalfPortSize - spinnerTilt, y2: HalfPortSize - spinnerTilt) + }; + SolidPen = new Pen(NodeEdgeColor, drawScale); DashPen = new Pen(NodeEdgeColor, drawScale) { DashPattern = new[] { 4f, 2f } }; DefaultIconFont = new Font(Font, FontStyle.Bold); @@ -578,7 +596,7 @@ IEnumerable GetRubberBandSelection(Rectangle rect) { if (layout.Node.Value != null) { - var selected = CircleIntersect(layout.Center, HalfSize, selectionRect); + var selected = CircleIntersect(layout.Center, HalfNodeSize, selectionRect); if (selected) { yield return layout.Node; @@ -719,7 +737,7 @@ public GraphNode GetNodeAt(Point point) { if (layout.Node.Value == null) continue; - if (CircleIntersect(layout.Center, HalfSize, point)) + if (CircleIntersect(layout.Center, HalfNodeSize, point)) { return layout.Node; } @@ -1162,10 +1180,11 @@ private void DrawNode( graphics.DrawEllipse(iconRendererState.StrokeStyle(edgeColor, PenWidth), outputPortRectangle); graphics.FillEllipse(outputPortBrush, outputPortRectangle); - if (active && layout.Node.NotifyingCounter != 0) + if (active && layout.Node.NotifyingCounter >= 0) { - var notify1 = new PointF(outputPortRectangle.X + PortSize / 2, outputPortRectangle.Top + PenWidth); - var notify2 = new PointF(outputPortRectangle.X + PortSize / 2, outputPortRectangle.Bottom - PenWidth); + var spinnerIndex = layout.Node.NotifyingCounter % SpinnerOffsets.Length; + var notify1 = outputPortRectangle.Location + SpinnerOffsets[spinnerIndex].Offset1; + var notify2 = outputPortRectangle.Location + SpinnerOffsets[spinnerIndex].Offset2; graphics.DrawLine(iconRendererState.StrokeStyle(CursorDark, PenWidth), notify1, notify2); } } @@ -1401,7 +1420,7 @@ public LayoutNode(GraphViewControl view, GraphNode node, PointF location) public PointF Center { - get { return PointF.Add(Location, new SizeF(View.HalfSize, View.HalfSize)); } + get { return PointF.Add(Location, new SizeF(View.HalfNodeSize, View.HalfNodeSize)); } } public PointF EntryPoint @@ -1430,7 +1449,7 @@ public RectangleF BoundingRectangle { return new RectangleF( InputPortLocation.X - View.PenWidth, Location.Y - View.PenWidth, - View.PortSize / 2 + View.NodeSize + 2 * View.PenWidth, View.NodeSize + 2 * View.PenWidth); + View.HalfPortSize + View.NodeSize + 2 * View.PenWidth, View.NodeSize + 2 * View.PenWidth); } } @@ -1474,5 +1493,17 @@ public void SetNodeLabel(string text, Font font, Graphics graphics) LabelRectangle = new RectangleF(labelLocation, labelSize); } } + + struct SpinnerOffset + { + public SpinnerOffset(float x1, float y1, float x2, float y2) + { + Offset1 = new SizeF(x1, y1); + Offset2 = new SizeF(x2, y2); + } + + public SizeF Offset1; + public SizeF Offset2; + } } } diff --git a/Bonsai.Editor/GraphView/WorkflowGraphView.cs b/Bonsai.Editor/GraphView/WorkflowGraphView.cs index 8dea220c..c3cf723e 100644 --- a/Bonsai.Editor/GraphView/WorkflowGraphView.cs +++ b/Bonsai.Editor/GraphView/WorkflowGraphView.cs @@ -107,17 +107,17 @@ private void WorkflowWatch_Tick(object sender, EventArgs e) node.Status = counter.GetStatus(); if (node.Status == WorkflowElementStatus.Notifying) { - node.NotifyingCounter = (node.NotifyingCounter + 1) % 2; + node.NotifyingCounter++; } else if (node.Status != WorkflowElementStatus.Active) { - node.NotifyingCounter = 0; + node.NotifyingCounter = -1; } } else { node.Status = null; - node.NotifyingCounter = 0; + node.NotifyingCounter = -1; } } } From b6a2eaf04c5177df7f58dd74600f0950315a3a38 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Thu, 15 Aug 2024 19:59:47 +0100 Subject: [PATCH 7/9] Avoid querying watch counters for dummy nodes --- Bonsai.Editor/GraphView/WorkflowGraphView.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bonsai.Editor/GraphView/WorkflowGraphView.cs b/Bonsai.Editor/GraphView/WorkflowGraphView.cs index c3cf723e..09c92d51 100644 --- a/Bonsai.Editor/GraphView/WorkflowGraphView.cs +++ b/Bonsai.Editor/GraphView/WorkflowGraphView.cs @@ -102,6 +102,9 @@ private void WorkflowWatch_Tick(object sender, EventArgs e) { foreach (var node in layers[i]) { + if (node.Value is null) + continue; + if (workflowWatch.Counters?.TryGetValue(node.Value, out var counter) is true) { node.Status = counter.GetStatus(); From 341631755e52384b72197a0454511df7e6f78487 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 3 Sep 2024 17:43:06 +0100 Subject: [PATCH 8/9] Add dedicated watch stream to track cancellations --- Bonsai.Core/Expressions/InspectBuilder.cs | 77 ++++- Bonsai.Core/Expressions/InspectSubject.cs | 296 ++++++++++++++++++ Bonsai.Core/Expressions/WatchNotification.cs | 33 ++ .../Diagnostics/NotificationCounter.cs | 28 ++ .../Diagnostics/WorkflowElementCounter.cs | 82 +++-- .../Diagnostics/WorkflowElementStatus.cs | 1 + Bonsai.Editor/GraphView/GraphViewControl.cs | 6 +- 7 files changed, 465 insertions(+), 58 deletions(-) create mode 100644 Bonsai.Core/Expressions/InspectSubject.cs create mode 100644 Bonsai.Core/Expressions/WatchNotification.cs create mode 100644 Bonsai.Editor/Diagnostics/NotificationCounter.cs diff --git a/Bonsai.Core/Expressions/InspectBuilder.cs b/Bonsai.Core/Expressions/InspectBuilder.cs index 460fe7db..c4ca6eed 100644 --- a/Bonsai.Core/Expressions/InspectBuilder.cs +++ b/Bonsai.Core/Expressions/InspectBuilder.cs @@ -11,7 +11,7 @@ namespace Bonsai.Expressions { /// - /// Represents an expression builder that replays the latest notification from all the + /// Represents an expression builder that monitors the notifications from all the /// subscriptions made to its decorated builder. /// public sealed class InspectBuilder : ExpressionBuilder, INamedElement @@ -23,7 +23,7 @@ public sealed class InspectBuilder : ExpressionBuilder, INamedElement /// specified expression builder. /// /// - /// The expression builder whose notifications will be replayed by this inspector. + /// The expression builder whose notifications will be monitored by this inspector. /// public InspectBuilder(ExpressionBuilder builder) : base(builder, decorator: false) @@ -101,6 +101,12 @@ internal IReadOnlyList VisualizerMappings /// public IObservable ErrorEx { get; private set; } + /// + /// Gets an observable sequence that multicasts watch notifications from all + /// the subscriptions made to the output of the decorated expression builder. + /// + public IObservable> Watch { get; private set; } + /// /// Gets the range of input arguments that the decorated expression builder accepts. /// @@ -142,6 +148,7 @@ public override Expression Build(IEnumerable arguments) if (VisualizerElement != null) { Output = VisualizerElement.Output; + Watch = VisualizerElement.Watch; ErrorEx = Observable.Empty(); VisualizerElement = BuildVisualizerElement(VisualizerElement, VisualizerMappings); return source; @@ -162,6 +169,7 @@ public override Expression Build(IEnumerable arguments) else { Output = Observable.Empty>(); + Watch = Observable.Empty>(); ErrorEx = Observable.Empty(); if (VisualizerElement != null) { @@ -241,17 +249,15 @@ methodCall.Arguments[0] is MethodCallExpression lazy && return null; } - ReplaySubject> CreateInspectorSubject() + ReplaySubject> CreateInspectorSubject() { - var subject = new ReplaySubject>(1); - Output = subject.Select(ys => ys.Select(xs => (object)xs)); + var subject = new ReplaySubject>(1); + Output = subject.Select(ys => ys.Output); #pragma warning disable CS0612 // Type or member is obsolete - Error = subject.Merge().IgnoreElements().Select(xs => Unit.Default); + Error = subject.SelectMany(ys => ys.Error); #pragma warning restore CS0612 // Type or member is obsolete - ErrorEx = subject.SelectMany(xs => xs - .IgnoreElements() - .Select(x => default(Exception)) - .Catch(ex => Observable.Return(ex))); + ErrorEx = subject.SelectMany(xs => xs.ErrorEx); + Watch = subject.Select(xs => xs.Watch); return subject; } @@ -260,13 +266,13 @@ IObservable Process(IObservable source) return source; } - IObservable Process(IObservable source, ReplaySubject> subject) + IObservable Process(IObservable source, ReplaySubject> subject) { return Observable.Create(observer => { - var sourceInspector = new Subject(); + var sourceInspector = new Inspector(); subject.OnNext(sourceInspector); - var subscription = source.Do(sourceInspector).SubscribeSafe(observer); + var subscription = source.Do(sourceInspector.Subject).SubscribeSafe(observer); return Disposable.Create(() => { try { subscription.Dispose(); } @@ -275,11 +281,54 @@ IObservable Process(IObservable source, ReplaySubject { throw new WorkflowRuntimeException(ex.Message, this, ex); } - finally { sourceInspector.OnCompleted(); } + finally + { + if (!sourceInspector.Subject.HasTerminated) + { + sourceInspector.IsCanceled = true; + sourceInspector.Subject.OnCompleted(); + } + } }); }); } + class Inspector + { + public InspectSubject Subject { get; } = new(); + + public bool IsCanceled { get; internal set; } + + public IObservable Output => Subject.Select(value => (object)value); + + public IObservable Error => Subject.IgnoreElements().Select(xs => Unit.Default); + + public IObservable ErrorEx => Subject + .IgnoreElements() + .Select(x => default(Exception)) + .Catch(ex => Observable.Return(ex)); + + public IObservable Watch => + Observable.Create(observer => + { + observer.OnNext(WatchNotification.Subscribe); + var notificationObserver = Observer.Create( + value => observer.OnNext(WatchNotification.OnNext), + error => + { + observer.OnNext(WatchNotification.OnError); + observer.OnNext(WatchNotification.Unsubscribe); + }, + () => + { + if (!IsCanceled) + observer.OnNext(WatchNotification.OnCompleted); + observer.OnNext(WatchNotification.Unsubscribe); + }); + return Subject.SubscribeSafe(notificationObserver); + }); + } + class VisualizerMappingList { readonly SortedList localMappings = new SortedList(); diff --git a/Bonsai.Core/Expressions/InspectSubject.cs b/Bonsai.Core/Expressions/InspectSubject.cs new file mode 100644 index 00000000..6609e7a1 --- /dev/null +++ b/Bonsai.Core/Expressions/InspectSubject.cs @@ -0,0 +1,296 @@ +// Adapted from https://github.com/dotnet/reactive +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Disposables; +using System.Reactive.Subjects; +using System.Threading; + +namespace Bonsai.Expressions +{ + internal sealed class InspectSubject : ISubject + { + private SubjectDisposable[] _observers; + private Exception _exception; +#pragma warning disable CA1825,IDE0300 // (Avoid zero-length array allocations. Use collection expressions) The identity of these arrays matters, so we can't use the shared Array.Empty() instance either explicitly, or indirectly via a collection expression + private static readonly SubjectDisposable[] Terminated = new SubjectDisposable[0]; + private static readonly SubjectDisposable[] Disposed = new SubjectDisposable[0]; +#pragma warning restore CA1825,IDE0300 + + #region Constructors + + public InspectSubject() + { + _observers = Array.Empty(); + } + + #endregion + + #region Properties + + /// + /// Indicates whether the subject has observers subscribed to it. + /// + public bool HasObservers => Volatile.Read(ref _observers).Length != 0; + + /// + /// Indicates whether the subject has been disposed. + /// + public bool IsDisposed => Volatile.Read(ref _observers) == Disposed; + + /// + /// Indicates whether the sequence has terminated. + /// + public bool HasTerminated => Volatile.Read(ref _observers) == Terminated; + + #endregion + + #region Methods + + #region IObserver implementation + + private static void ThrowDisposed() => throw new ObjectDisposedException(string.Empty); + + /// + /// Notifies all subscribed observers about the end of the sequence. + /// + public void OnCompleted() + { + for (; ; ) + { + var observers = Volatile.Read(ref _observers); + + if (observers == Disposed) + { + _exception = null; + ThrowDisposed(); + break; + } + + if (observers == Terminated) + { + break; + } + + if (Interlocked.CompareExchange(ref _observers, Terminated, observers) == observers) + { + foreach (var observer in observers) + { + observer.Observer?.OnCompleted(); + } + + break; + } + } + } + + /// + /// Notifies all subscribed observers about the specified exception. + /// + /// The exception to send to all currently subscribed observers. + /// is null. + public void OnError(Exception error) + { + if (error == null) + { + throw new ArgumentNullException(nameof(error)); + } + + for (; ; ) + { + var observers = Volatile.Read(ref _observers); + + if (observers == Disposed) + { + _exception = null; + ThrowDisposed(); + break; + } + + if (observers == Terminated) + { + break; + } + + _exception = error; + + if (Interlocked.CompareExchange(ref _observers, Terminated, observers) == observers) + { + foreach (var observer in observers) + { + observer.Observer?.OnError(error); + } + + break; + } + } + } + + /// + /// Notifies all subscribed observers about the arrival of the specified element in the sequence. + /// + /// The value to send to all currently subscribed observers. + public void OnNext(T value) + { + var observers = Volatile.Read(ref _observers); + + if (observers == Disposed) + { + _exception = null; + ThrowDisposed(); + return; + } + + foreach (var observer in observers) + { + observer.Observer?.OnNext(value); + } + } + + #endregion + + #region IObservable implementation + + /// + /// Subscribes an observer to the subject. + /// + /// Observer to subscribe to the subject. + /// Disposable object that can be used to unsubscribe the observer from the subject. + /// is null. + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var disposable = default(SubjectDisposable); + for (; ; ) + { + var observers = Volatile.Read(ref _observers); + + if (observers == Disposed) + { + _exception = null; + ThrowDisposed(); + + break; + } + + if (observers == Terminated) + { + var ex = _exception; + + if (ex != null) + { + observer.OnError(ex); + } + else + { + observer.OnCompleted(); + } + + break; + } + + disposable ??= new SubjectDisposable(this, observer); + + var n = observers.Length; + var b = new SubjectDisposable[n + 1]; + + Array.Copy(observers, 0, b, 0, n); + + b[n] = disposable; + + if (Interlocked.CompareExchange(ref _observers, b, observers) == observers) + { + return disposable; + } + } + + return Disposable.Empty; + } + + private void Unsubscribe(SubjectDisposable observer) + { + for (; ; ) + { + var a = Volatile.Read(ref _observers); + var n = a.Length; + + if (n == 0) + { + break; + } + + var j = Array.IndexOf(a, observer); + + if (j < 0) + { + break; + } + + SubjectDisposable[] b; + + if (n == 1) + { + b = Array.Empty(); + } + else + { + b = new SubjectDisposable[n - 1]; + + Array.Copy(a, 0, b, 0, j); + Array.Copy(a, j + 1, b, j, n - j - 1); + } + + if (Interlocked.CompareExchange(ref _observers, b, a) == a) + { + break; + } + } + } + + private sealed class SubjectDisposable : IDisposable + { + private InspectSubject _subject; + private volatile IObserver _observer; + + public SubjectDisposable(InspectSubject subject, IObserver observer) + { + _subject = subject; + _observer = observer; + } + + public IObserver Observer => _observer; + + public void Dispose() + { + var observer = Interlocked.Exchange(ref _observer, null); + if (observer == null) + { + return; + } + + _subject.Unsubscribe(this); + _subject = null!; + } + } + + #endregion + + #region IDisposable implementation + + public void Dispose() + { + Interlocked.Exchange(ref _observers, Disposed); + _exception = null; + } + + #endregion + + #endregion + } +} diff --git a/Bonsai.Core/Expressions/WatchNotification.cs b/Bonsai.Core/Expressions/WatchNotification.cs new file mode 100644 index 00000000..349e3884 --- /dev/null +++ b/Bonsai.Core/Expressions/WatchNotification.cs @@ -0,0 +1,33 @@ +namespace Bonsai.Expressions +{ + /// + /// Indicates the type of an inspector watch notification. + /// + public enum WatchNotification + { + /// + /// Indicates the sequence was subscribed to. + /// + Subscribe, + + /// + /// Indicates the sequence has emitted a value. + /// + OnNext, + + /// + /// Indicates the sequence has terminated with an error. + /// + OnError, + + /// + /// Indicates the sequence has terminated successfully. + /// + OnCompleted, + + /// + /// Indicates the subscription to the sequence has been disposed. + /// + Unsubscribe + } +} diff --git a/Bonsai.Editor/Diagnostics/NotificationCounter.cs b/Bonsai.Editor/Diagnostics/NotificationCounter.cs new file mode 100644 index 00000000..779dc28a --- /dev/null +++ b/Bonsai.Editor/Diagnostics/NotificationCounter.cs @@ -0,0 +1,28 @@ +using System.Threading; + +namespace Bonsai.Editor.Diagnostics +{ + internal struct NotificationCounter + { + private long count; + private long lastCount; + + public void Increment() + { + Interlocked.Increment(ref count); + } + + public long Read() + { + return Interlocked.Read(ref count); + } + + public long ReadDelta(out long count) + { + count = Read(); + var delta = count - lastCount; + lastCount = count; + return delta; + } + } +} diff --git a/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs b/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs index 741315cb..bade40af 100644 --- a/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs +++ b/Bonsai.Editor/Diagnostics/WorkflowElementCounter.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Reactive.Linq; -using System.Threading; using Bonsai.Expressions; namespace Bonsai.Editor.Diagnostics @@ -9,70 +8,69 @@ namespace Bonsai.Editor.Diagnostics internal class WorkflowElementCounter : IDisposable { readonly IDisposable subscription; - private long subscribeCount; - private long onNextCount; - private long onCompletedCount; - private long onErrorCount; - private long disposeCount; - private long lastNotificationCount; + private NotificationCounter subscribeCounter; + private NotificationCounter onNextCounter; + private NotificationCounter onCompletedCounter; + private NotificationCounter onErrorCounter; + private NotificationCounter unsubscribeCounter; + private WorkflowElementStatus lastInactiveStatus; public WorkflowElementCounter(InspectBuilder inspectBuilder) { InspectBuilder = inspectBuilder ?? throw new ArgumentNullException(nameof(inspectBuilder)); - subscription = InspectBuilder.Output.SelectMany(source => + subscription = InspectBuilder.Watch.SelectMany(source => source.Do(notification => { - Interlocked.Increment(ref subscribeCount); - return source - .Do(value => Interlocked.Increment(ref onNextCount), - error => Interlocked.Increment(ref onErrorCount), - () => Interlocked.Increment(ref onCompletedCount)) - .IgnoreElements() - .Finally(() => Interlocked.Increment(ref disposeCount)); - }).Subscribe(); + switch (notification) + { + case WatchNotification.Subscribe: subscribeCounter.Increment(); break; + case WatchNotification.OnNext: onNextCounter.Increment(); break; + case WatchNotification.OnError: onErrorCounter.Increment(); break; + case WatchNotification.OnCompleted: onCompletedCounter.Increment(); break; + case WatchNotification.Unsubscribe: unsubscribeCounter.Increment(); break; + } + }).IgnoreElements()).Subscribe(); } public InspectBuilder InspectBuilder { get; } - public long SubscribeCount => subscribeCount; + public long SubscribeCount => subscribeCounter.Read(); - public long OnNextCount => onNextCount; + public long OnNextCount => onNextCounter.Read(); - public long OnErrorCount => onErrorCount; + public long OnErrorCount => onErrorCounter.Read(); - public long OnCompletedCount => onCompletedCount; + public long OnCompletedCount => onCompletedCounter.Read(); - public long DisposeCount => disposeCount; + public long UnsubscribeCount => unsubscribeCounter.Read(); public long TotalCount => - Interlocked.Read(ref subscribeCount) + - Interlocked.Read(ref onNextCount) + - Interlocked.Read(ref onErrorCount) + - Interlocked.Read(ref onCompletedCount) + - Interlocked.Read(ref disposeCount); + SubscribeCount + + OnNextCount + + OnErrorCount + + OnCompletedCount + + UnsubscribeCount; public WorkflowElementStatus GetStatus() { - var notificationCount = Interlocked.Read(ref onNextCount); - var activeSubscriptions = Interlocked.Read(ref subscribeCount) - Interlocked.Read(ref disposeCount); + var notificationDelta = onNextCounter.ReadDelta(out long notificationCount); + var unsubscribeDelta = unsubscribeCounter.ReadDelta(out long unsubscribeCount); + var activeSubscriptions = SubscribeCount - unsubscribeCount; if (activeSubscriptions > 0) { - if (notificationCount > lastNotificationCount) - { - lastNotificationCount = notificationCount; - return WorkflowElementStatus.Notifying; - } - - return WorkflowElementStatus.Active; + return notificationDelta > 0 + ? WorkflowElementStatus.Notifying + : WorkflowElementStatus.Active; } - else + else if (unsubscribeDelta > 0) { - lastNotificationCount = notificationCount; - var errorCount = Interlocked.Read(ref onErrorCount); - var completedCount = Interlocked.Read(ref onCompletedCount); - if (errorCount > 0) return WorkflowElementStatus.Error; - else if (completedCount > 0) return WorkflowElementStatus.Completed; - return WorkflowElementStatus.Ready; + var errorDelta = onErrorCounter.ReadDelta(out long _); + var completedDelta = onCompletedCounter.ReadDelta(out long _); + if (errorDelta > 0) lastInactiveStatus = WorkflowElementStatus.Error; + else if (completedDelta > 0) lastInactiveStatus = WorkflowElementStatus.Completed; + else lastInactiveStatus = WorkflowElementStatus.Canceled; } + + return lastInactiveStatus; } public void Dispose() diff --git a/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs b/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs index bf24f929..4cd065bc 100644 --- a/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs +++ b/Bonsai.Editor/Diagnostics/WorkflowElementStatus.cs @@ -7,5 +7,6 @@ internal enum WorkflowElementStatus Notifying, Completed, Error, + Canceled } } diff --git a/Bonsai.Editor/GraphView/GraphViewControl.cs b/Bonsai.Editor/GraphView/GraphViewControl.cs index 8a717d48..ac599ca5 100644 --- a/Bonsai.Editor/GraphView/GraphViewControl.cs +++ b/Bonsai.Editor/GraphView/GraphViewControl.cs @@ -1166,8 +1166,9 @@ private void DrawNode( var outputPortRectangle = layout.OutputPortRectangle; var outputPortBrush = nodeStatus switch { - Diagnostics.WorkflowElementStatus.Completed => Brushes.Green, + Diagnostics.WorkflowElementStatus.Completed => Brushes.LimeGreen, Diagnostics.WorkflowElementStatus.Error => Brushes.Red, + Diagnostics.WorkflowElementStatus.Canceled => Brushes.Orange, _ => Brushes.White }; outputPortRectangle.Offset(offset.Width, offset.Height); @@ -1175,7 +1176,8 @@ private void DrawNode( var active = nodeStatus == Diagnostics.WorkflowElementStatus.Active || nodeStatus == Diagnostics.WorkflowElementStatus.Notifying; var terminated = nodeStatus == Diagnostics.WorkflowElementStatus.Completed || - nodeStatus == Diagnostics.WorkflowElementStatus.Error; + nodeStatus == Diagnostics.WorkflowElementStatus.Error || + nodeStatus == Diagnostics.WorkflowElementStatus.Canceled; var edgeColor = active || terminated ? CursorColor : NodeEdgeColor; graphics.DrawEllipse(iconRendererState.StrokeStyle(edgeColor, PenWidth), outputPortRectangle); From d407291b2fa43704640658f667e88d4d70b1972c Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 3 Sep 2024 18:01:10 +0100 Subject: [PATCH 9/9] Allow enabling and disabling workflow watch --- Bonsai.Editor/EditorForm.Designer.cs | 24 ++++++++++++++++++- Bonsai.Editor/EditorForm.cs | 14 ++++++++++- Bonsai.Editor/GraphView/WorkflowGraphView.cs | 12 +++++----- .../Properties/Resources.Designer.cs | 10 ++++++++ Bonsai.Editor/Properties/Resources.resx | 10 ++++++++ Bonsai.Editor/WorkflowWatch.cs | 14 ++++++++++- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/Bonsai.Editor/EditorForm.Designer.cs b/Bonsai.Editor/EditorForm.Designer.cs index 3c99e6e7..fec3e642 100644 --- a/Bonsai.Editor/EditorForm.Designer.cs +++ b/Bonsai.Editor/EditorForm.Designer.cs @@ -72,6 +72,8 @@ private void InitializeComponent() this.startWithoutDebuggingToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.stopToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.restartToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator11 = new System.Windows.Forms.ToolStripSeparator(); + this.watchToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.packageManagerToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.galleryToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -470,7 +472,9 @@ private void InitializeComponent() this.startToolStripMenuItem, this.startWithoutDebuggingToolStripMenuItem, this.stopToolStripMenuItem, - this.restartToolStripMenuItem}); + this.restartToolStripMenuItem, + this.toolStripSeparator11, + this.watchToolStripMenuItem}); this.workflowToolStripMenuItem.Name = "workflowToolStripMenuItem"; this.workflowToolStripMenuItem.Size = new System.Drawing.Size(70, 20); this.workflowToolStripMenuItem.Text = "&Workflow"; @@ -518,6 +522,22 @@ private void InitializeComponent() this.restartToolStripMenuItem.Visible = false; this.restartToolStripMenuItem.Click += new System.EventHandler(this.restartToolStripMenuItem_Click); // + // toolStripSeparator11 + // + this.toolStripSeparator11.Name = "toolStripSeparator11"; + this.toolStripSeparator11.Size = new System.Drawing.Size(249, 6); + // + // watchToolStripMenuItem + // + this.watchToolStripMenuItem.Checked = true; + this.watchToolStripMenuItem.CheckOnClick = true; + this.watchToolStripMenuItem.CheckState = System.Windows.Forms.CheckState.Checked; + this.watchToolStripMenuItem.Image = global::Bonsai.Editor.Properties.Resources.WatchMenuImage; + this.watchToolStripMenuItem.Name = "watchToolStripMenuItem"; + this.watchToolStripMenuItem.Size = new System.Drawing.Size(252, 22); + this.watchToolStripMenuItem.Text = "&Watch"; + this.watchToolStripMenuItem.Click += new System.EventHandler(this.watchToolStripMenuItem_Click); + // // toolsToolStripMenuItem // this.toolsToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { @@ -1500,6 +1520,8 @@ private void InitializeComponent() private System.Windows.Forms.ContextMenuStrip statusContextMenuStrip; private System.Windows.Forms.ToolStripMenuItem statusCopyToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem toolboxDocsToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator11; + private System.Windows.Forms.ToolStripMenuItem watchToolStripMenuItem; } } diff --git a/Bonsai.Editor/EditorForm.cs b/Bonsai.Editor/EditorForm.cs index 2f99b24a..645f9504 100644 --- a/Bonsai.Editor/EditorForm.cs +++ b/Bonsai.Editor/EditorForm.cs @@ -1187,6 +1187,7 @@ IDisposable ShutdownSequence() groupToolStripMenuItem.Enabled = true; cutToolStripMenuItem.Enabled = true; pasteToolStripMenuItem.Enabled = true; + watchToolStripMenuItem.Enabled = true; startToolStripSplitButton.Enabled = startToolStripMenuItem.Enabled = startWithoutDebuggingToolStripMenuItem.Enabled = true; stopToolStripButton.Visible = stopToolStripMenuItem.Visible = stopToolStripButton.Enabled = stopToolStripMenuItem.Enabled = false; restartToolStripButton.Visible = restartToolStripMenuItem.Visible = restartToolStripButton.Enabled = restartToolStripMenuItem.Enabled = false; @@ -1226,7 +1227,8 @@ void StartWorkflow(bool debug) var runtimeWorkflow = workflowBuilder.Workflow.BuildObservable(); Invoke((Action)(() => { - if (debug) workflowWatch.Start(workflowBuilder.Workflow); + if (watchToolStripMenuItem.Checked) + workflowWatch.Start(workflowBuilder.Workflow); statusTextLabel.Text = Resources.RunningStatus; statusImageLabel.Image = statusRunningImage; editorSite.OnWorkflowStarted(EventArgs.Empty); @@ -1259,6 +1261,7 @@ void StartWorkflow(bool debug) groupToolStripMenuItem.Enabled = false; cutToolStripMenuItem.Enabled = false; pasteToolStripMenuItem.Enabled = false; + watchToolStripMenuItem.Enabled = false; startToolStripSplitButton.Enabled = startToolStripMenuItem.Enabled = startWithoutDebuggingToolStripMenuItem.Enabled = false; stopToolStripButton.Visible = stopToolStripMenuItem.Visible = stopToolStripButton.Enabled = stopToolStripMenuItem.Enabled = true; restartToolStripButton.Visible = restartToolStripMenuItem.Visible = restartToolStripButton.Enabled = restartToolStripMenuItem.Enabled = true; @@ -2214,6 +2217,15 @@ private void disableToolStripMenuItem_Click(object sender, EventArgs e) #endregion + #region Watch + + private void watchToolStripMenuItem_Click(object sender, EventArgs e) + { + workflowWatch.Enabled = watchToolStripMenuItem.Checked; + } + + #endregion + #region Undo/Redo private void commandExecutor_StatusChanged(object sender, EventArgs e) diff --git a/Bonsai.Editor/GraphView/WorkflowGraphView.cs b/Bonsai.Editor/GraphView/WorkflowGraphView.cs index 09c92d51..b6ac60c2 100644 --- a/Bonsai.Editor/GraphView/WorkflowGraphView.cs +++ b/Bonsai.Editor/GraphView/WorkflowGraphView.cs @@ -105,7 +105,12 @@ private void WorkflowWatch_Tick(object sender, EventArgs e) if (node.Value is null) continue; - if (workflowWatch.Counters?.TryGetValue(node.Value, out var counter) is true) + if (!workflowWatch.Enabled) + { + node.Status = null; + node.NotifyingCounter = -1; + } + else if (workflowWatch.Counters?.TryGetValue(node.Value, out var counter) is true) { node.Status = counter.GetStatus(); if (node.Status == WorkflowElementStatus.Notifying) @@ -117,11 +122,6 @@ private void WorkflowWatch_Tick(object sender, EventArgs e) node.NotifyingCounter = -1; } } - else - { - node.Status = null; - node.NotifyingCounter = -1; - } } } } diff --git a/Bonsai.Editor/Properties/Resources.Designer.cs b/Bonsai.Editor/Properties/Resources.Designer.cs index 295eb181..7ff5b62c 100644 --- a/Bonsai.Editor/Properties/Resources.Designer.cs +++ b/Bonsai.Editor/Properties/Resources.Designer.cs @@ -602,6 +602,16 @@ internal static string VisualizerLayoutOnNullWorkflow_Error { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap WatchMenuImage { + get { + object obj = ResourceManager.GetObject("WatchMenuImage", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized string similar to Externalized properties of this workflow can be configured below.. /// diff --git a/Bonsai.Editor/Properties/Resources.resx b/Bonsai.Editor/Properties/Resources.resx index d5b31a13..3bd3856c 100644 --- a/Bonsai.Editor/Properties/Resources.resx +++ b/Bonsai.Editor/Properties/Resources.resx @@ -341,4 +341,14 @@ NOTE: You will have to restart Bonsai for any changes to take effect. Help + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7CAAAOwgEVKEqAAAAAv0lE + QVQ4T2MYRkBRUTEJiPeDMFQIBcDkQOqgQgggIyMjpKCgsElKSkoYKoQV4FQnLy+fBcJQLl6AoZZY22EA + Qz0ptsMAih6gaY+AAfMfC34AkgfRaOJgDNIHNgDI+SsnJyeJjpEV45D/BzYAaNILoHMMkCWB/ACg+FWo + BRdAfGR5ZWVlI6D4c5gB24A4HlkBkL8aqKkAakAOiI8sD5RLBIptgRmQB3ImMgaKvQKFNkgeqEEQxMei + Jg9swFAHDAwAKi9WPOw18+QAAAAASUVORK5CYII= + + \ No newline at end of file diff --git a/Bonsai.Editor/WorkflowWatch.cs b/Bonsai.Editor/WorkflowWatch.cs index 1b38d8bf..03dccae8 100644 --- a/Bonsai.Editor/WorkflowWatch.cs +++ b/Bonsai.Editor/WorkflowWatch.cs @@ -11,10 +11,22 @@ internal class WorkflowWatch const int WatchPeriod = 100; readonly Timer watchTimer = new() { Interval = WatchPeriod }; WorkflowMeter workflowMeter; + bool enabled; public WorkflowWatch() { watchTimer.Tick += (_, e) => OnUpdate(e); + enabled = true; + } + + public bool Enabled + { + get => enabled; + set + { + enabled = value; + OnUpdate(EventArgs.Empty); + } } public event EventHandler Update; @@ -45,10 +57,10 @@ public void Stop() watchTimer.Stop(); if (workflowMeter is not null) { + OnUpdate(EventArgs.Empty); workflowMeter.Dispose(); workflowMeter = null; } - OnUpdate(EventArgs.Empty); } } }