Skip to content

Implemented swap mouse Left Pan <-> Middle zoom#1335

Closed
EFeru wants to merge 2 commits intoScottPlot:masterfrom
EFeru:master
Closed

Implemented swap mouse Left Pan <-> Middle zoom#1335
EFeru wants to merge 2 commits intoScottPlot:masterfrom
EFeru:master

Conversation

@EFeru
Copy link
Contributor

@EFeru EFeru commented Oct 7, 2021

The behavior can be activated by setting Configuration.SwapDragLeftMiddle = true;

New Contributors:
Please review CONTRIBUTING.md

Hacktoberfest Participants:
Check-out the Hacktoberfest 2021 page

Purpose:

  • This change allows to swap left drag pan to middle drag pan and middle drag zoom to left drag zoom by Configuration.SwapDragLeftMiddle = true; Default behavior is false, so the original key bindings are not affected
  • Related issue Is it possible to change key binds #352
  • I implemented this swap, because for users of Matlab (like me), it feels more natural with swap enabled to match Matlab's plot default behavior

Isssue to be solved:
For some reason when using Draggable lines, the zoom also triggers, which is not wanted of course. @swharden maybe you can help me to fix that. I tried to debug, and the createMouseMovedToZoomRectangle is not triggering but I still get a zoom.

mouseMoveEvent = EventFactory.CreateMouseMovedToZoomRectangle(input.X, input.Y);

You can use the Cookbook to test, just set SwapDragLeftMiddle = true in Configuration.cs

EFeru added 2 commits October 7, 2021 20:12
The behavior can be activated by setting Configuration.SwapDragMiddleLeft = true;
@bclehmann
Copy link
Member

bclehmann commented Oct 7, 2021

I think if we are going to support remapping keys/mouse buttons we should allow full (or close to it) remapping instead of just one swap. Otherwise, we open ourselves up to maintaining every configuration option in one of several booleans.

Perhaps if we do that we could add a convenience function like UseMatlabBindings on top of it.

@EFeru
Copy link
Contributor Author

EFeru commented Oct 7, 2021

Thank you @bclehmann for your feedback. The thing is there are more tools besides Matlab using middle mouse as pan, e.g., Autocad, even Windows Photos. So, what I try to say is that at least middle mouse pan is very common... I am open to suggestions for a more appropriate/generic name for this setting.

Edit: Maybe UseMiddleClickAsPan as the setting name?

@bclehmann
Copy link
Member

I wasn't intending to comment on the naming, although UseMiddleClickPan or something similar sounds fine to me.

I was suggesting that the implementation be more generic, supporting arbitrary bindings and then middle-click panning could be built on top of that. A good way to support changing key bindings would prevent an explosion of complexity if we decide to support 5 different keymaps.

@swharden
Copy link
Member

swharden commented Oct 8, 2021

Hi @EFeru, thanks for opening this PR! I agree there should be a way that users can customize what buttons do, so I'm invested in reaching a solution here. This has come up before (#352 and #1222) and has been on the triage list for a while (#1028).

The remaining discussion will focus on how to best implement this feature. I recognize this PR gets the job done to meet @EFeru's needs, but it would be great to have a solution that is a little deeper and extensible for alternative bindings.

Best Solution: Refactor the Backend

I agree with @bclehmann that a bool flag isn't the best long-term solution here. I also really like their suggestion of having functions like UseMatlabBindings() to quickly reconfigure mouse behavior to match a known paradigm.

I think this goal can be totally achieved just by editing Control/Backend.cs, but the refactoring would be very extensive. All those variables and function names assume a fixed default behavior (e.g., left click drag is pan), so lots of the logic would have to be reworked.

I'm thinking I may be the best person to do this since the rework would be pretty extensive, and I'd probably save it for a weekend.

/* This file describes the ScottPlot back-end control module.
*
* Goals for this module:
* - Interact with the Plot object so controls don't have to.
* - Wrap/abstract mouse interaction logic so controls don't have to implement it.
* - Use events to tell controls when to update the image or change the mouse cursor.
* - Render calls are non-blocking so GUI/controls aren't slowed by render requests.
* - Delayed high quality renders are possible after mouse interaction stops.
*
* Default Controls:
*
* - Left-click-drag: pan
* - Right-click-drag: zoom
* - Middle-click-drag: zoom region
* - ALT+Left-click-drag: zoom region
* - Scroll wheel: zoom to cursor
*
* - Right-click: show menu
* - Middle-click: auto-axis
* - Double-click: show benchmark
*
* - CTRL+Left-click-drag to pan horizontally
* - SHIFT+Left-click-drag to pan vertically
* - CTRL+Right-click-drag to zoom horizontally
* - SHIFT+Right-click-drag to zoom vertically
* - CTRL+SHIFT+Right-click-drag to zoom evenly
* - SHIFT+click-drag draggables for fixed-size dragging
*
* Configurable options:
*
* - left-click-drag pan
* - right-click-drag zoom
* - lock vertical or horizontal axis
* - middle-click auto-axis margin
* - double-click benchmark toggle
* - scrollwheel zoom
* - quality (anti-aliasing on/off) for various actions
* - delayed high quality render after low-quality interactive renders
*
*/
using ScottPlot.Control.EventProcess;
using ScottPlot.Plottable;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace ScottPlot.Control
{
/// <summary>
/// The control back end module contains all the logic required to manage a mouse-interactive
/// plot to display in a user control. However, this module contains no control-specific dependencies.
/// User controls can instantiate this object, pass mouse and resize event information in, and have
/// renders triggered using events.
/// </summary>
public class ControlBackEnd
{
/// <summary>
/// This event is invoked when an existing Bitmap is redrawn.
/// e.g., after rendering following a click-drag-pan mouse event.
/// </summary>
public event EventHandler BitmapUpdated = delegate { };
/// <summary>
/// This event is invoked after a new Bitmap was created.
/// e.g., after resizing the control, requiring a new Bitmap of a different size
/// </summary>
public event EventHandler BitmapChanged = delegate { };
/// <summary>
/// This event is invoked when the cursor is supposed to change.
/// Cursor changes may be required when hovering over draggable plottable objects.
/// </summary>
public event EventHandler CursorChanged = delegate { };
/// <summary>
/// This event is invoked when the axis limts change.
/// This is typically the result of a pan or zoom operation.
/// </summary>
public event EventHandler AxesChanged = delegate { };
/// <summary>
/// This event is invoked when the user right-clicks the control with the mouse.
/// It is typically used to deploy a context menu.
/// </summary>
public event EventHandler RightClicked = delegate { };
/// <summary>
/// This event is invoked after the mouse moves while dragging a draggable plottable.
/// </summary>
public event EventHandler PlottableDragged = delegate { };
/// <summary>
/// This event is invoked after the mouse moves while dragging a draggable plottable.
/// </summary>
public event EventHandler PlottableDropped = delegate { };
/// <summary>
/// The control configuration object stores advanced customization and behavior settings
/// for mouse-interactive plots.
/// </summary>
public readonly Configuration Configuration = new();
/// <summary>
/// True if the middle mouse button is pressed
/// </summary>
private bool IsMiddleDown;
/// <summary>
/// True if the right mouse button is pressed
/// </summary>
private bool IsRightDown;
/// <summary>
/// True if the left mouse button is pressed
/// </summary>
private bool IsLeftDown;
/// <summary>
/// Current position of the mouse in pixels
/// </summary>
private float MouseLocationX;
/// <summary>
/// Current position of the mouse in pixels
/// </summary>
private float MouseLocationY;
/// <summary>
/// Holds the plottable actively being dragged with the mouse.
/// Contains null if no plottable is being dragged.
/// </summary>
private IDraggable PlottableBeingDragged = null;
/// <summary>
/// True when a zoom rectangle is being drawn and the mouse button is still down
/// </summary>
private bool IsZoomingRectangle;
/// <summary>
/// True if a zoom rectangle is being actively drawn using ALT + left click
/// </summary>
private bool IsZoomingWithAlt;
/// <summary>
/// The plot underlying this control.
/// </summary>
public Plot Plot { get; private set; }
/// <summary>
/// The settings object underlying the plot.
/// </summary>
private Settings Settings;
/// <summary>
/// The latest render is stored in this bitmap.
/// New renders may be performed on this existing bitmap.
/// When a new bitmap is created, this bitmap will be stored in OldBitmaps and eventually disposed.
/// </summary>
private System.Drawing.Bitmap Bmp;
/// <summary>
/// Bitmaps that are created are stored here so they can be kept track of and
/// disposed properly when new bitmaps are created.
/// </summary>
private readonly Queue<System.Drawing.Bitmap> OldBitmaps = new();
/// <summary>
/// Store last render limits so new renders can know whether the axis limits
/// have changed and decide whether to invoke the AxesChanged event or not.
/// </summary>
private AxisLimits LimitsOnLastRender = new();
/// <summary>
/// Unique identifier of the plottables list that was last rendered.
/// This value is used to determine if the plottables list was modified (requiring a re-render).
/// </summary>
private int PlottablesIdentifierAtLastRender = 0;
/// <summary>
/// This is set to True while the render loop is running.
/// This prevents multiple renders from occurring at the same time.
/// </summary>
private bool currentlyRendering = false;
/// <summary>
/// The style of cursor the control should display
/// </summary>
public Cursor Cursor { get; private set; } = Cursor.Arrow;
/// <summary>
/// The events processor invokes renders in response to custom events
/// </summary>
private readonly EventsProcessor EventsProcessor;
/// <summary>
/// The event factor creates event objects to be handled by the event processor
/// </summary>
private UIEventFactory EventFactory;
/// <summary>
/// Number of times the current bitmap has been rendered on.
/// </summary>
private int BitmapRenderCount = 0;
/// <summary>
/// Total number of renders performed.
/// Note that at least one render occurs before the first request to measure the layout and calculate data area.
/// This means the first render increments this number twice.
/// </summary>
public int RenderCount { get; private set; } = 0;
/// <summary>
/// Tracks the total distance the mouse was click-dragged (rectangular pixel units)
/// </summary>
private float MouseDownTravelDistance;
/// <summary>
/// True if the mouse was dragged (with a button down) long enough to quality as a drag instead of a click
/// </summary>
private bool MouseDownDragged => MouseDownTravelDistance > Configuration.IgnoreMouseDragDistance;
/// <summary>
/// Indicates whether Render() has been explicitly called by the user.
/// Renders requested by resize events do not count.
/// </summary>
public bool WasManuallyRendered;
/// <summary>
/// Variable name for the user control tied to this backend.
/// </summary>
public readonly string ControlName;
/// <summary>
/// Create a back-end for a user control
/// </summary>
/// <param name="width">initial bitmap size (pixels)</param>
/// <param name="height">initial bitmap size (pixels)</param>
/// <param name="name">variable name of the user control using this backend</param>
public ControlBackEnd(float width, float height, string name = "UnamedControl")
{
EventFactory = new UIEventFactory(Configuration, Settings, Plot);
EventsProcessor = new EventsProcessor(
renderAction: (lowQuality) => Render(lowQuality),
renderDelay: (int)Configuration.ScrollWheelZoomHighQualityDelay);
ControlName = name;
Reset(width, height);
}
/// <summary>
/// The host control may instantiate the back-end and start sending it events
/// before it has fully connected its event handlers. To prevent processing events before
/// the host is control is ready, the processor will be stopped until is called by the host control.
/// </summary>
public void StartProcessingEvents() => EventsProcessor.Enable = true;
/// <summary>
/// Reset the back-end by creating an entirely new plot of the given dimensions
/// </summary>
public void Reset(float width, float height) => Reset(width, height, new Plot());
/// <summary>
/// Reset the back-end by replacing the existing plot with one that has already been created
/// </summary>
public void Reset(float width, float height, Plot newPlot)
{
Plot = newPlot;
Settings = Plot.GetSettings(false);
EventFactory = new UIEventFactory(Configuration, Settings, Plot);
WasManuallyRendered = false;
Resize(width, height, useDelayedRendering: false);
}
/// <summary>
/// Return a copy of the list of draggable plottables
/// </summary>
private IDraggable[] GetDraggables() =>
Settings.Plottables.Where(x => x is IDraggable).Select(x => (IDraggable)x).ToArray();
/// <summary>
/// Return the draggable plottable under the mouse cursor (or null if there isn't one)
/// </summary>
private IDraggable GetDraggableUnderMouse(double pixelX, double pixelY, int snapDistancePixels = 5)
{
double snapWidth = Settings.XAxis.Dims.UnitsPerPx * snapDistancePixels;
double snapHeight = Settings.YAxis.Dims.UnitsPerPx * snapDistancePixels;
foreach (IDraggable draggable in GetDraggables())
if (draggable.IsUnderMouse(Plot.GetCoordinateX((float)pixelX), Plot.GetCoordinateY((float)pixelY), snapWidth, snapHeight))
if (draggable.DragEnabled)
return draggable;
return null;
}
/// <summary>
/// Return a multi-line string describing the default mouse interactions.
/// This can be useful for displaying a help message in a user control.
/// </summary>
public static string GetHelpMessage()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("Left-click-drag: pan");
sb.AppendLine("Right-click-drag: zoom");
sb.AppendLine("Middle-click-drag: zoom region");
sb.AppendLine("ALT+Left-click-drag: zoom region");
sb.AppendLine("Scroll wheel: zoom to cursor");
sb.AppendLine("");
sb.AppendLine("Right-click: show menu");
sb.AppendLine("Middle-click: auto-axis");
sb.AppendLine("Double-click: show benchmark");
sb.AppendLine("");
sb.AppendLine("CTRL+Left-click-drag to pan horizontally");
sb.AppendLine("SHIFT+Left-click-drag to pan vertically");
sb.AppendLine("CTRL+Right-click-drag to zoom horizontally");
sb.AppendLine("SHIFT+Right-click-drag to zoom vertically");
sb.AppendLine("CTRL+SHIFT+Right-click-drag to zoom evenly");
sb.AppendLine("SHIFT+click-drag draggables for fixed-size dragging");
return sb.ToString();
}
/// <summary>
/// Return the most recently rendered Bitmap.
/// This method also disposes old Bitmaps if they exist.
/// </summary>
public System.Drawing.Bitmap GetLatestBitmap()
{
while (OldBitmaps.Count > 3)
OldBitmaps.Dequeue()?.Dispose();
return Bmp;
}
/// <summary>
/// Render onto the existing Bitmap.
/// Quality describes whether anti-aliasing will be used.
/// </summary>
public void Render(bool lowQuality = false, bool skipIfCurrentlyRendering = false)
{
if (Bmp is null)
return;
if (currentlyRendering && skipIfCurrentlyRendering)
return;
currentlyRendering = true;
if (Configuration.Quality == QualityMode.High)
lowQuality = false;
else if (Configuration.Quality == QualityMode.Low)
lowQuality = true;
Plot.Render(Bmp, lowQuality);
BitmapRenderCount += 1;
RenderCount += 1;
PlottablesIdentifierAtLastRender = Settings.PlottablesIdentifier;
if (WasManuallyRendered == false &&
Settings.Plottables.Count > 0 &&
Configuration.WarnIfRenderNotCalledManually &&
Debugger.IsAttached)
{
string message = $"ScottPlot {Plot.Version} WARNING:\n" +
$"{ControlName}.Refresh() must be called\n" +
$"after modifying the plot or its data.";
Debug.WriteLine(message.Replace("\n", " "));
AddErrorMessage(Bmp, message);
}
AxisLimits newLimits = Plot.GetAxisLimits();
if (!newLimits.Equals(LimitsOnLastRender) && Configuration.AxesChangedEventEnabled)
AxesChanged(null, EventArgs.Empty);
LimitsOnLastRender = newLimits;
if (BitmapRenderCount == 1)
{
// a new bitmap was rendered on for the first time
BitmapChanged(this, EventArgs.Empty);
}
else
{
// an existing bitmap was re-rendered onto
BitmapUpdated(null, EventArgs.Empty);
}
currentlyRendering = false;
}
/// <summary>
/// Add error text on top of the rendered plot
/// </summary>
private static void AddErrorMessage(System.Drawing.Bitmap bmp, string message)
{
System.Drawing.Color foreColor = System.Drawing.Color.Red;
System.Drawing.Color backColor = System.Drawing.Color.Yellow;
System.Drawing.Color shadowColor = System.Drawing.Color.FromArgb(50, System.Drawing.Color.Black);
int padding = 10;
int shadowOffset = 7;
using var gfx = System.Drawing.Graphics.FromImage(bmp);
gfx.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
using System.Drawing.StringFormat sf = Drawing.GDI.StringFormat(HorizontalAlignment.Center, VerticalAlignment.Middle);
using System.Drawing.Font font = new(System.Drawing.FontFamily.GenericSansSerif, 16, System.Drawing.FontStyle.Bold);
System.Drawing.SizeF messageSize = gfx.MeasureString(message, font);
System.Drawing.RectangleF messageRect = new(
x: bmp.Width / 2 - messageSize.Width / 2 - padding,
y: bmp.Height / 2 - messageSize.Height / 2 - padding,
width: messageSize.Width + padding * 2,
height: messageSize.Height + padding * 2);
System.Drawing.RectangleF shadowRect = new(
x: messageRect.X + shadowOffset,
y: messageRect.Y + shadowOffset,
width: messageRect.Width,
height: messageRect.Height);
using System.Drawing.SolidBrush foreBrush = new(foreColor);
using System.Drawing.Pen forePen = new(foreColor, width: 5);
using System.Drawing.SolidBrush backBrush = new(backColor);
using System.Drawing.SolidBrush shadowBrush = new(shadowColor);
if (messageSize.Width > bmp.Width || messageSize.Height > bmp.Height)
{
System.Drawing.RectangleF plotRect = new(0, 0, bmp.Width, bmp.Height);
sf.Alignment = System.Drawing.StringAlignment.Near;
sf.LineAlignment = System.Drawing.StringAlignment.Near;
message = message.Replace("\n", " ");
using System.Drawing.Font fontSmall = new(System.Drawing.FontFamily.GenericSansSerif, 12, System.Drawing.FontStyle.Bold);
gfx.Clear(backColor);
gfx.DrawString(message, fontSmall, foreBrush, plotRect, sf);
}
else
{
gfx.FillRectangle(shadowBrush, shadowRect);
gfx.FillRectangle(backBrush, messageRect);
gfx.DrawRectangle(forePen, System.Drawing.Rectangle.Round(messageRect));
gfx.DrawString(message, font, foreBrush, messageRect, sf);
}
}
/// <summary>
/// Request a render using the render queue.
/// This method does not block the calling thread.
/// </summary>
public void RenderRequest(RenderType renderType)
{
switch (renderType)
{
case RenderType.LowQuality:
ProcessEvent(EventFactory.CreateManualLowQualityRender());
return;
case RenderType.HighQuality:
ProcessEvent(EventFactory.CreateManualHighQualityRender());
return;
case RenderType.HighQualityDelayed:
ProcessEvent(EventFactory.CreateManualDelayedHighQualityRender());
return;
case RenderType.LowQualityThenHighQuality:
ProcessEvent(EventFactory.CreateManualLowQualityRender());
ProcessEvent(EventFactory.CreateManualHighQualityRender());
return;
case RenderType.LowQualityThenHighQualityDelayed:
ProcessEvent(EventFactory.CreateManualDelayedHighQualityRender());
return;
case RenderType.ProcessMouseEventsOnly:
return;
default:
throw new InvalidOperationException($"unsupported render type {renderType}");
}
}
/// <summary>
/// Check if the number of plottibles has changed and if so request a render.
/// This is typically called by a continuously running timer in the user control.
/// </summary>
[Obsolete("Automatic render timer has been removed. Call Render() manually.", true)]
public void RenderIfPlottableListChanged()
{
if (Configuration.RenderIfPlottableListChanges == false)
return;
if (Bmp is null)
return;
if (Settings.PlottablesIdentifier != PlottablesIdentifierAtLastRender)
Render();
}
/// <summary>
/// Resize the control (creates a new Bitmap and requests a render)
/// </summary>
/// <param name="width">new width (pixels)</param>
/// <param name="height">new height (pixels)</param>
/// <param name="useDelayedRendering">Render using the queue (best for mouse events), otherwise render immediately.</param>
public void Resize(float width, float height, bool useDelayedRendering)
{
// don't render if the requested size cannot produce a valid bitmap
if (width < 1 || height < 1)
return;
// don't render if the request is so early that the processor hasn't started
if (EventsProcessor is null)
return;
// Disposing a Bitmap the GUI is displaying will cause an exception.
// Keep track of old bitmaps so they can be disposed of later.
OldBitmaps.Enqueue(Bmp);
Bmp = new System.Drawing.Bitmap((int)width, (int)height);
BitmapRenderCount = 0;
if (useDelayedRendering)
RenderRequest(RenderType.HighQualityDelayed);
else
Render();
}
/// <summary>
/// Indicate a mouse button has just been pressed
/// </summary>
public void MouseDown(InputState input)
{
if (!Settings.AllAxesHaveBeenSet)
Plot.SetAxisLimits(Plot.GetAxisLimits());
IsMiddleDown = input.MiddleWasJustPressed;
IsRightDown = input.RightWasJustPressed;
IsLeftDown = input.LeftWasJustPressed;
PlottableBeingDragged = GetDraggableUnderMouse(input.X, input.Y);
Settings.MouseDown(input.X, input.Y);
MouseDownTravelDistance = 0;
}
/// <summary>
/// Return the mouse position on the plot (in coordinate space) for the latest X and Y coordinates
/// </summary>
public (double x, double y) GetMouseCoordinates()
{
(double x, double y) = Plot.GetCoordinate(MouseLocationX, MouseLocationY);
return (double.IsNaN(x) ? 0 : x, double.IsNaN(y) ? 0 : y);
}
/// <summary>
/// Return the mouse position (in pixel space) for the last observed mouse position
/// </summary>
public (float x, float y) GetMousePixel() => (MouseLocationX, MouseLocationY);
/// <summary>
/// Indicate the mouse has moved to a new position
/// </summary>
public void MouseMove(InputState input)
{
bool altWasLifted = IsZoomingWithAlt && !input.AltDown;
bool middleButtonLifted = IsZoomingRectangle && !input.MiddleWasJustPressed;
if (IsZoomingRectangle && (altWasLifted || middleButtonLifted))
Settings.ZoomRectangle.Clear();
IsZoomingWithAlt = IsLeftDown && input.AltDown;
bool isMiddleClickDragZooming = IsMiddleDown && !middleButtonLifted;
bool isZooming = IsZoomingWithAlt || isMiddleClickDragZooming;
IsZoomingRectangle = isZooming && Configuration.MiddleClickDragZoom && MouseDownDragged;
MouseDownTravelDistance += Math.Abs(input.X - MouseLocationX);
MouseDownTravelDistance += Math.Abs(input.Y - MouseLocationY);
MouseLocationX = input.X;
MouseLocationY = input.Y;
IUIEvent mouseMoveEvent = null;
if (PlottableBeingDragged != null)
mouseMoveEvent = EventFactory.CreatePlottableDrag(input.X, input.Y, input.ShiftDown, PlottableBeingDragged);
else if (IsLeftDown && !input.AltDown && Configuration.LeftClickDragPan)
mouseMoveEvent = EventFactory.CreateMousePan(input);
else if (IsRightDown && Configuration.RightClickDragZoom)
mouseMoveEvent = EventFactory.CreateMouseZoom(input);
else if (IsZoomingRectangle)
mouseMoveEvent = EventFactory.CreateMouseMovedToZoomRectangle(input.X, input.Y);
if (mouseMoveEvent != null)
ProcessEvent(mouseMoveEvent);
else
MouseMovedWithoutInteraction(input);
}
/// <summary>
/// Process an event using the render queue (non-blocking) or traditional rendering (blocking)
/// based on the UseRenderQueue flag in the Configuration module.
/// </summary>
private void ProcessEvent(IUIEvent uiEvent)
{
if (Configuration.UseRenderQueue)
{
// TODO: refactor to better support async
// TODO: document that draggable events aren't supported by the render queue
_ = EventsProcessor.ProcessAsync(uiEvent);
}
else
{
//Console.WriteLine($"[{DateTime.Now:ss.ffff}] PROCESSING: {uiEvent}");
uiEvent.ProcessEvent();
if (uiEvent.RenderType == RenderType.ProcessMouseEventsOnly)
return;
bool lowQuality =
uiEvent is EventProcess.Events.MouseMovedToZoomRectangle ||
uiEvent is EventProcess.Events.MousePanEvent ||
uiEvent is EventProcess.Events.MouseZoomEvent ||
uiEvent is EventProcess.Events.PlottableDragEvent ||
uiEvent is EventProcess.Events.RenderLowQuality;
bool allowSkip = lowQuality && Configuration.AllowDroppedFramesWhileDragging;
Render(lowQuality: lowQuality, skipIfCurrentlyRendering: allowSkip);
if (uiEvent is EventProcess.Events.PlottableDragEvent)
PlottableDragged(PlottableBeingDragged, EventArgs.Empty);
}
}
/// <summary>
/// Call this when the mouse moves without any buttons being down.
/// It will only update the cursor based on what's beneath the cursor.
/// </summary>
private void MouseMovedWithoutInteraction(InputState input)
{
UpdateCursor(input);
}
/// <summary>
/// Set the cursor based on whether a draggable is engaged or not,
/// then invoke the CursorChanged event.
/// </summary>
private void UpdateCursor(InputState input)
{
var draggableUnderCursor = GetDraggableUnderMouse(input.X, input.Y);
Cursor = (draggableUnderCursor is null) ? Cursor.Arrow : draggableUnderCursor.DragCursor;
CursorChanged(null, EventArgs.Empty);
}
/// <summary>
/// Indicate a mouse button has been released.
/// This may initiate a render (and/or a delayed render).
/// </summary>
/// <param name="input"></param>
public void MouseUp(InputState input)
{
var droppedPlottable = PlottableBeingDragged;
IUIEvent mouseEvent;
if (IsZoomingRectangle && MouseDownDragged && Configuration.MiddleClickDragZoom)
mouseEvent = EventFactory.CreateApplyZoomRectangleEvent(input.X, input.Y);
else if (IsMiddleDown && Configuration.MiddleClickAutoAxis && MouseDownDragged == false)
mouseEvent = EventFactory.CreateMouseAutoAxis();
else
mouseEvent = EventFactory.CreateMouseUpClearRender();
ProcessEvent(mouseEvent);
if (IsRightDown && MouseDownDragged == false)
RightClicked(null, EventArgs.Empty);
IsMiddleDown = false;
IsRightDown = false;
IsLeftDown = false;
UpdateCursor(input);
if (droppedPlottable != null)
PlottableDropped(droppedPlottable, EventArgs.Empty);
PlottableBeingDragged = null;
if (droppedPlottable != null)
ProcessEvent(EventFactory.CreateMouseUpClearRender());
}
/// <summary>
/// Indicate the left mouse button has been double-clicked.
/// The default action of a double-click is to toggle the benchmark.
/// </summary>
public void DoubleClick()
{
if (Configuration.DoubleClickBenchmark)
{
IUIEvent mouseEvent = EventFactory.CreateBenchmarkToggle();
ProcessEvent(mouseEvent);
}
}
/// <summary>
/// Apply a scroll wheel action, perform a low quality render, and later re-render in high quality.
/// </summary>
public void MouseWheel(InputState input)
{
if (!Settings.AllAxesHaveBeenSet)
Plot.SetAxisLimits(Plot.GetAxisLimits());
if (Configuration.ScrollWheelZoom)
{
IUIEvent mouseEvent = EventFactory.CreateMouseScroll(input.X, input.Y, input.WheelScrolledUp);
ProcessEvent(mouseEvent);
}
}
}
}

Intermediate Solution: Intercept GetInputState()?

Thinking out loud, I wonder if a quick solution could be realized by letting the user customize what buttons do what by targeting this function of code in the control itself

private Control.InputState GetInputState(MouseEventArgs e) =>
new()
{
X = e.X,
Y = e.Y,
LeftWasJustPressed = e.Button == MouseButtons.Left,
RightWasJustPressed = e.Button == MouseButtons.Right,
MiddleWasJustPressed = e.Button == MouseButtons.Middle,
ShiftDown = ModifierKeys.HasFlag(Keys.Shift),
CtrlDown = ModifierKeys.HasFlag(Keys.Control),
AltDown = ModifierKeys.HasFlag(Keys.Alt),
WheelScrolledUp = e.Delta > 0,
WheelScrolledDown = e.Delta < 0,
};

Maybe changing private to public virtual protected virtual would let the user create a new FormsPlotCustom that inherits FormsPlot and overrides this method with a custom one that lets them fully control what buttons do what?

@EFeru for the reasons discussed above I probably won't merge this PR as it is, but if this simpler solution works for you feel free to modify this PR or open a new one to change the function modifier and I'd be happy to merge it. If my description wasn't clear enough to communicate the suggestion let me know and I can try to put together a demo or something 👍

@bclehmann
Copy link
Member

Maybe changing private to public virtual

It can become protected virtual if they are intended to only override the method. public would allow them to call it themselves, which I suppose could be used to allow them to rebind keys by preventing the default behaviour and invoking the function with a modified MouseEventArgs parameter. However, that sounds like a bad idea, as you're deceiving the existing function instead of just replacing it.

@swharden
Copy link
Member

swharden commented Oct 8, 2021

It can become protected virtual

Good catch @bclehmann!

Changing GetInputState() from private to protected virtual sounds like a good non-invasive fix to let the user control what buttons do what until I get around to refactoring the control backend.

@EFeru if you try it and it works as expected I'd be happy to merge a PR that makes this change identically in all 3 controls.

@bclehmann
Copy link
Member

bclehmann commented Oct 8, 2021

Changing GetInputState() from private to protected virtual sounds like a good non-invasive fix to let the user control what buttons do what until I get around to refactoring the control backend.

If we do this we probably need to communicate somehow that this is not protected by the API contract, otherwise any change to InputState becomes breaking. Which is undesirable, as refactoring the backend probably would change parts of InputState

@EFeru
Copy link
Contributor Author

EFeru commented Oct 8, 2021

I will have a look at your suggestions and see if can try something.

Thinking of loud, all combinations would be a matrix, functions vs keys. Could be like a shortcut assignment table, where functions can be:

  • drag related (mouse only or including keyboard key press): pan, zoom box and, zoomdrag
  • single click related: show menu (currently assigned to right click)
  • double click related: show benchmark

@EFeru
Copy link
Contributor Author

EFeru commented Oct 8, 2021

Just tried swapping Left with Middle button in Input state :

private Control.InputState GetInputState(MouseEventArgs e) => 
     new() 
     { 
         X = e.X, 
         Y = e.Y, 
         LeftWasJustPressed = e.Button == MouseButtons.Middle, 
         RightWasJustPressed = e.Button == MouseButtons.Right, 
         MiddleWasJustPressed = e.Button == MouseButtons.Left, 
         ShiftDown = ModifierKeys.HasFlag(Keys.Shift), 
         CtrlDown = ModifierKeys.HasFlag(Keys.Control), 
         AltDown = ModifierKeys.HasFlag(Keys.Alt), 
         WheelScrolledUp = e.Delta > 0, 
         WheelScrolledDown = e.Delta < 0, 
     }; 

Which works as intended, swaps complete behavior left <-> middle. In my opinion making this mapping accesible to the user is a first step to allow some customization.
If I undestand correctly it is just changing private to protected virtual and then any suggestion on how to override the function on the user application side?

@swharden
Copy link
Member

swharden commented Oct 10, 2021

Inherit-and-Override Example

any suggestion on how to override the function on the user application side?

I worked this through on my end to try it out. It's a bit clumsy, but it works. I created a new WinForms application and I did not add a FormsPlot. Instead I created a new user control UserControl and inherited FormsPlot, then drag-dropped that onto my form.

UserControl1.cs

using ScottPlot.Control;
using System.Windows.Forms;

namespace WinFormsFrameworkApp
{
    public partial class UserControl1 : ScottPlot.FormsPlot // not "UserControl"
    {
        public UserControl1()
        {
            InitializeComponent();
        }

        protected override InputState GetInputState(MouseEventArgs e)
        {
            return new InputState()
            {
                X = e.X,
                Y = e.Y,
                LeftWasJustPressed = e.Button == MouseButtons.Middle, // CUSTOM
                RightWasJustPressed = e.Button == MouseButtons.Right, // CUSTOM
                MiddleWasJustPressed = e.Button == MouseButtons.Left, // CUSTOM
                ShiftDown = ModifierKeys.HasFlag(Keys.Shift),
                CtrlDown = ModifierKeys.HasFlag(Keys.Control),
                AltDown = ModifierKeys.HasFlag(Keys.Alt),
                WheelScrolledUp = e.Delta > 0,
                WheelScrolledDown = e.Delta < 0,
            };
        }
    }
}

Form1.cs

public Form1()
{
    InitializeComponent();
    userControl11.Plot.AddSignal(ScottPlot.DataGen.Sin(51));
    userControl11.Plot.AddSignal(ScottPlot.DataGen.Cos(51));
    userControl11.Refresh();
}

Should we make GetInputState() overridable?

The key to this example being possible is that GetInputState() was changed from private to protected virtual, allowing the override.

@bclehmann's hesitation about this is very justified - if we encourage people to inherit-and-override, we lose our ability to modify these functions later without breaking our users' code. I think decorating this method with [Obsolete] and including a warning would be enough to get the message across (see below).

@EFeru if this technique seems like a good intermediate solution for you, you're welcome to modify this PR or open a new one and I'd be happy to merge it in. #1274 These changes would be made in all 3 user controls:

  • change privateprotected virtual
  • add an obsolete decorator to GetInputState
  • add a pragma statement to the top of the file to suppress obsolete warnings
[Obsolete("WARNING: API may change in future (override with caution)")]
protected virtual Control.InputState GetInputState(MouseEventArgs e) =>
#pragma warning disable CS0618 // Type or member is obsolete

@EFeru
Copy link
Contributor Author

EFeru commented Oct 10, 2021

I will have look tomorrow if it's not too late. Maybe I close this one and create a new PR, i will see.

@EFeru
Copy link
Contributor Author

EFeru commented Oct 11, 2021

I gave it a bit more tought and even if this solution is feasible, on the long run I agree with both of you that is better not to override since it will be more difficult to update future releases of ScottPlot on NuGet. If I have overrides, I have to posibly always maintain my implementations.

In short, I have another idea. Do you think it is possible to add some mapping in the Configuration? Maybe an array giving the mapping for the used bindings, e.g.,:

formsPlot.Configuration.MouseBindings = {MouseButtons.Left, MouseButtons.Middle, MouseButtons.Right};
formsPlot.Configuration.KeyBindings = {Keys.Shift, Keys.Control, Keys.Alt};

And these bindings are used in GetInputState to get different behaviors. For me it looks feasible. Let me know if I miss something, because I am not sure how can we handle Avalonia and WPF while keeping Configuration.cs generic.


I did a quick test, and seems to works:
In Configuration.cs, I defined:

public MouseButtons[] MouseBindings = {MouseButtons.Middle, MouseButtons.Right, MouseButtons.Left };

And In FormsPlot.cs I did:

private Control.InputState GetInputState(MouseEventArgs e) =>
            new()
            {
                X = e.X,
                Y = e.Y,
                LeftWasJustPressed = e.Button == Configuration.MouseBindings[0], //MouseButtons.Left,
                RightWasJustPressed = e.Button == Configuration.MouseBindings[1], //MouseButtons.Right,
                MiddleWasJustPressed = e.Button == Configuration.MouseBindings[2], //MouseButtons.Middle,
                ShiftDown = ModifierKeys.HasFlag(Keys.Shift),
                CtrlDown = ModifierKeys.HasFlag(Keys.Control),
                AltDown = ModifierKeys.HasFlag(Keys.Alt),
                WheelScrolledUp = e.Delta > 0,
                WheelScrolledDown = e.Delta < 0,
            };

@swharden
Copy link
Member

I am not sure how can we handle Avalonia and WPF while keeping Configuration.cs generic

This is the fundamental problem that makes this hard... I'm guessing the best solution is to handle mapping in the control (not the backend), then eventually update the backend to use button-agnostic names (e.g., PanButton instead of LeftButton).

better not to override since it will be more difficult to update future releases

Do you think it is possible to add some mapping in the Configuration?

I like the direction this is going! Maybe something like this is even simpler...

public MouseButtons MouseButtonDragPan = MouseButtons.Left;
public MouseButtons MouseButtonDragZoomRectangle = MouseButtons.Middle;
public MouseButtons MouseButtonDragZoom = MouseButtons.Right;
public Keys KeyOnlyHorizontal = Keys.Control;
public Keys KeyOnlyVertical = Keys.Shift;
public Keys KeyZoomRegion = Keys.Alt;

private Control.InputState GetInputState(MouseEventArgs e) =>
    new()
    {
        X = e.X,
        Y = e.Y,
        LeftWasJustPressed = e.Button == MouseButtonDragPan,
        RightWasJustPressed = e.Button == MouseButtonDragZoom,
        MiddleWasJustPressed = e.Button == MouseButtonDragZoomRectangle,
        ShiftDown = ModifierKeys.HasFlag(KeyOnlyVertical),
        CtrlDown = ModifierKeys.HasFlag(KeyOnlyHorizontal),
        AltDown = ModifierKeys.HasFlag(KeyZoomRegion),
        WheelScrolledUp = e.Delta > 0,
        WheelScrolledDown = e.Delta < 0,
    };

@EFeru
Copy link
Contributor Author

EFeru commented Oct 12, 2021

I tried the change above. It works in normal plot but not as expected with draggble lines, see below:

2021-10-12.21-49.mp4

Also for WPF version is hard to implement this change becasuse the buttons are in the MouseEventArgs e:

LeftWasJustPressed = e.LeftButton == MouseButtonState.Pressed,
RightWasJustPressed = e.RightButton == MouseButtonState.Pressed,
MiddleWasJustPressed = e.MiddleButton == MouseButtonState.Pressed,
ShiftDown = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift),
CtrlDown = Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl),
AltDown = Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt),

Since this become more intricated than expected, I am thinking for now to close this PR, and maybe later if you have a better idea on how to do it we can re-address this topic.

@swharden
Copy link
Member

I tried the change above. It works in normal plot but not as expected with draggable lines...

It technically works because swapping left/right buttons means you have to right-click-drag axis lines 😅

I agree this isn't the intended result though

Since this become more intricated than expected, I am thinking for now to close this PR, and maybe later if you have a better idea on how to do it we can re-address this topic.

I'll close this PR for now but I started #1354 to track progress toward a true fix (deep refactoring of the control back-end and event handling system) and hopefully I can complete that soon 👍

@swharden swharden closed this Oct 12, 2021
@swharden swharden mentioned this pull request Dec 26, 2021
82 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants