Search code examples
oxyplot

OxyPlot: Keep tracker open when left button released


I'm using OxyPlot 2014.1.546 with C# and WPF.

My plot has a custom tracker that appears when the user clicks a point. I'd like to include buttons for performing actions related to the clicked point. Adding them to the tracker template is straightforward enough; the problem is, by default, the tracker disappears as soon as the user releases the mouse button, which means it's impossible to actually click them.

Is there any way to tell OxyPlot to keep the tracker open until the user clicks outside of it?


Solution

  • The short answer is that OxyPlot doesn't appear to support this behavior directly. After spending some time digging through the decompiled source, I came up with the following solution, which appears to work. The basic idea was to derive my own StayOpenTrackerManipulator from OxyPlot's built-in TrackerManipulator and instantiate it in response to a click. My manipulator overrides the virtual Completed() function, which the framework calls when the mouse button is released, and defers the call to the base-class Completed(), which closes the tracker, until the next time the mouse is clicked (or until the plot is modified, or until the mouse leaves it). Since I'm using C# and WPF, I wrapped everything up in an attached behavior that can be used from XAML like so:

    <PlotView behaviors:ShowTrackerAndLeaveOpenBehavior.BindToMouseDown="Left" />

    but it would be simple enough to pull the guts out and reuse them in a different manner if needed. Here's the source:

    /// <summary>
    /// Normal OxyPlot behavior is to show the tracker when the bound mouse button is pressed,
    /// and hide it again when the button is released. With this behavior set, the tracker will stay open
    /// until the user clicks the plot outside it (or the plot is modified).
    /// </summary>
    public static class ShowTrackerAndLeaveOpenBehavior
    {
        public static readonly DependencyProperty BindToMouseDownProperty = DependencyProperty.RegisterAttached(
            "BindToMouseDown", typeof(OxyMouseButton), typeof(ShowTrackerAndLeaveOpenBehavior),
            new PropertyMetadata(default(OxyMouseButton), OnBindToMouseButtonChanged));
    
        [AttachedPropertyBrowsableForType(typeof(IPlotView))]
        public static void SetBindToMouseDown(DependencyObject element, OxyMouseButton value) =>
            element.SetValue(BindToMouseDownProperty, value);
    
        [AttachedPropertyBrowsableForType(typeof(IPlotView))]
        public static OxyMouseButton GetBindToMouseDown(DependencyObject element) =>
            (OxyMouseButton) element.GetValue(BindToMouseDownProperty);
    
        private static void OnBindToMouseButtonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (!(d is IPlotView plot))
                throw new InvalidOperationException($"Can only be applied to {nameof(IPlotView)}");
    
            if (plot.ActualModel == null)
                throw new InvalidOperationException("Plot has no model");
    
            var controller = plot.ActualController;
            if (controller == null)
                throw new InvalidOperationException("Plot has no controller");
    
            if (e.OldValue is OxyMouseButton oldButton && oldButton != OxyMouseButton.None)
                controller.UnbindMouseDown(oldButton);
    
            var newButton = GetBindToMouseDown(d);
            if (newButton == OxyMouseButton.None)
                return;
    
            controller.UnbindMouseDown(newButton);
            controller.BindMouseDown(newButton, new DelegatePlotCommand<OxyMouseDownEventArgs>(
                AddStayOpenTrackerManipulator));
        }
    
        private static void AddStayOpenTrackerManipulator(IPlotView view, IController controller,
            OxyMouseDownEventArgs e)
        {
            controller.AddMouseManipulator(view, new StayOpenTrackerManipulator(view), e);
        }
    
        private class StayOpenTrackerManipulator : TrackerManipulator
        {
            private readonly PlotModel _plotModel;
            private bool _isTrackerOpen;
    
            public StayOpenTrackerManipulator(IPlotView plot)
                : base(plot)
            {
                _plotModel = plot?.ActualModel ?? throw new ArgumentException("Plot has no model", nameof(plot));
    
                Snap = true;
                PointsOnly = false;
            }
    
            public override void Started(OxyMouseEventArgs e)
            {
                _plotModel.TrackerChanged += HandleTrackerChanged;
                base.Started(e);
            }
    
            public override void Completed(OxyMouseEventArgs e)
            {
                if (!_isTrackerOpen)
                {
                    ReallyCompleted(e);
                }
                else
                {
                    // Completed() is called as soon as the mouse button is released.
                    // We won't call the base Completed() here since that would hide the tracker.
                    // Instead, defer the call until one of the hooked events occurs.
                    // The caller will still remove us from the list of active manipulators as soon as we return,
                    // but that's good; otherwise the tracker would continue to move around as the mouse does.
                    new DeferredCompletedCall(_plotModel, () => ReallyCompleted(e)).HookUp();
                }
            }
    
            private void ReallyCompleted(OxyMouseEventArgs e)
            {
                base.Completed(e);
    
                // Must unhook or this object will live as long as the model (instead of as long as the manipulation)
                _plotModel.TrackerChanged -= HandleTrackerChanged;
            }
    
            private void HandleTrackerChanged(object sender, TrackerEventArgs e) =>
                _isTrackerOpen = e.HitResult != null;
    
            /// <summary>
            /// Monitors events that should trigger manipulator completion and calls an injected function when they fire
            /// </summary>
            private class DeferredCompletedCall
            {
                private readonly PlotModel _plotModel;
                private readonly Action _completed;
    
                public DeferredCompletedCall(PlotModel plotModel, Action completed)
                {
                    _plotModel = plotModel ?? throw new ArgumentNullException(nameof(plotModel));
                    _completed = completed ?? throw new ArgumentNullException(nameof(completed));
                }
    
                /// <summary>
                /// Start monitoring events. Their observer lists will keep us alive until <see cref="Unhook"/> is called.
                /// </summary>
                public void HookUp()
                {
                    Unhook();
    
                    _plotModel.MouseDown += HandleMouseDown;
                    _plotModel.Updated += HandleUpdated;
                    _plotModel.MouseLeave += HandleMouseLeave;
                }
    
                /// <summary>
                /// Stop watching events. If they were the only things keeping us alive, we'll turn into garbage.
                /// </summary>
                private void Unhook()
                {
                    _plotModel.MouseDown -= HandleMouseDown;
                    _plotModel.Updated -= HandleUpdated;
                    _plotModel.MouseLeave -= HandleMouseLeave;
                }
    
                private void CallCompletedAndUnhookEvents()
                {
                    _completed();
                    Unhook();
                }
    
                private void HandleUpdated(object sender, EventArgs e) => CallCompletedAndUnhookEvents();
    
                private void HandleMouseLeave(object sender, OxyMouseEventArgs e) => CallCompletedAndUnhookEvents();
    
                private void HandleMouseDown(object sender, OxyMouseDownEventArgs e)
                {
                    CallCompletedAndUnhookEvents();
    
                    // Since we're not setting e.Handled to true here, this click will have its regular effect in
                    // addition to closing the tracker; e.g. it could open the tracker again at the new position.
                    // Modify this code if that's not what you want.
                }
            }
        }
    }