Updated on 2020-07-14
Continuing our series with a knob control and a MIDI visualizer
While WinForms controls are great for doing things like data entry screens, they aren't really designed for audio applications. For instance, a knob is often more appropriate than a slider/trackbar. There is also no piano keyboard control nor any way to visualize MIDI performance data. This library aims to solve that, while being integrated where appropriate with my Midi library.
piano keyboard control Midi library
A Note About the Solution: This is a rollup of all of my MIDI code, including the Midi library source code and every demo. The two relevant projects here are MidiUI project and the MidiDisplay project that demonstrates it.
Knob provides slider/trackbar-like control but presents the user interface in rotary dial form. Despite the minimalist appearance above, nearly every aspect of the knob's appearance is customizable. The reason for the look above is to blend in with other Windows controls, which it does by default.
In order to make it work, the control has to handle resize, drawing, focus, keystrokes and mouse movement (including the wheel) plus do some math in the painting routine.
Handling focus is easy if you know how, but there's no good guide on how to do it that I've found, so I'll step through the process.
The first thing we have to do is add the following lines to the control's constructor. I recommend adding these above any other code:
SetStyle(ControlStyles.Selectable, true);
UpdateStyles();
TabStop = true;
That will ensure the control becomes part of the tab navigation.
Next, we have to hook OnMouseDown(), OnKeyDown(), and to be safe ProcessCmdKey() if you're already overriding it, adding the following line (but make sure you call the base afterward):
Focus();
Next, we have to make sure it repaints when it enters or leaves focus, so add this line to OnEnter() and OnLeave():
Invalidate();
Finally, in OnPaint(), we must draw the focus rectangle:
if(Focused)
ControlPaint.DrawFocusRectangle
(args.Graphics, new Rectangle(0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));
We go by the value of Width or Height, whichever is lowest in order to keep the rectangle square, but that behavior is specific to our knob control, which must maintain a 1:1 aspect ratio.
Handling mouse input involves overriding 4 events to cover mouse button down, mouse button up, mouse movement, and mouse wheel movement, since the knob responds to all of them. Generally, the idea is to record the mouse down event, check for left button down, and then store the current mouse location of the mouse in a member variable for later. We then use that stored location, computing the difference during mouse movement, and setting the Value based on that. Finally, when the mouse button is released, we simply reset the state of the mouse input flag. Handling the mouse wheel is a little different since it already gives us deltas. We simply add or subtract those from Value. In each case, we're clamping Value to Minimum and Maximum. One moderate limitation here is we don't currently do hit testing to make sure the mouse lands within the circle. This will be fixed in a future release:
/// <summary>
/// Called when a mouse button is pressed
/// </summary>
/// <param name="args"></param>
protected override void OnMouseDown(MouseEventArgs args)
{
Focus();
if (MouseButtons.Left == (args.Button & MouseButtons.Left)) {
_dragHit = args.Location;
if (!_dragging)
{
_dragging = true;
Focus();
Invalidate();
}
}
base.OnMouseDown(args);
}
/// <summary>
/// Called when a mouse button is released
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseUp(MouseEventArgs args)
{
// TODO: Implement Ctrl+Drag
if (_dragging)
{
_dragging = false;
int pos = Value;
pos += _dragHit.Y - args.Location.Y; // delta
int min=Minimum;
int max=Maximum;
if (pos < min) pos = min;
if (pos > max) pos = max;
Value = pos;
}
base.OnMouseUp(args);
}
/// <summary>
/// Called when a mouse button is moved
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseMove(MouseEventArgs args)
{
// TODO: Implement Ctrl+Drag
if (_dragging)
{
int opos = Value;
int pos = opos;
pos += _dragHit.Y - args.Location.Y; // delta
int min = Minimum;
int max = Maximum;
if (pos < min) pos = min;
if (pos > max) pos = max;
if (pos != opos)
{
Value = pos;
_dragHit = args.Location;
}
}
base.OnMouseMove(args);
}
/// <summary>
/// Called when the mouse wheel is scrolled
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseWheel(MouseEventArgs args)
{
int pos;
int m;
var delta = args.Delta;
if (0 < delta)
{
delta = 1;
pos = Value;
pos += delta;
m = Maximum;
if (pos > m)
pos = m;
Value = pos;
}
else if (0 > delta)
{
delta = -1;
pos = Value;
pos += delta;
m = Minimum;
if (pos < m)
pos = m;
Value = pos;
}
base.OnMouseWheel(args);
}
We handle eight keys for keyboard input. We have the arrow keys to change the knob position by small increments, although right now there's a bug where the right arrow key isn't getting processed and I'm not sure why yet. Up and down work fine. We also have the page up and page down keys, which change the knob by the value of LargeChange. Finally, we have home and end keys which set Value to Minimum or Maximum respectively. Note that we must handle the arrow keys in a separate function since normally the form uses them for its own purposes and we have to override that behavior:
/// <summary>
/// Called when a key is pressed
/// </summary>
/// <param name="args">The event args</param>
protected override void OnKeyDown(KeyEventArgs args)
{
Focus();
int pos;
int pg;
if(Keys.PageDown==(args.KeyCode & Keys.PageDown))
{
pg = LargeChange;
pos = Value + pg;
if (pos > Maximum)
pos = Maximum;
Value = pos;
}
if (Keys.PageUp == (args.KeyCode & Keys.PageUp))
{
pg = LargeChange;
pos = Value - pg;
if (pos < Minimum)
pos = Minimum;
Value = pos;
}
if (Keys.Home == (args.KeyCode & Keys.Home))
{
Value = Minimum;
}
if (Keys.End== (args.KeyCode & Keys.End))
{
Value = Maximum;
}
base.OnKeyDown(args);
}
/// <summary>
/// Called when a command key is pressed
/// </summary>
/// <param name="msg">The message</param>
/// <param name="keyData">The command key(s)</param>
/// <returns>True if handled, otherwise false</returns>
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
Focus();
int pos;
var handled = false;
// BUG: Right arrow doesn't seem to be working!
if (Keys.Up == (keyData & Keys.Up) || Keys.Right == (keyData & Keys.Right))
{
pos = Value+1;
if (pos < Maximum)
{
Value = pos;
}
else
Value = Maximum;
handled = true;
}
if (Keys.Down == (keyData & Keys.Down) || Keys.Left == (keyData & Keys.Left))
{
pos = Value-1;
if (pos > Minimum)
{
Value = pos;
}
else
Value = Minimum;
handled = true;
}
if (handled)
return true;
return base.ProcessCmdKey(ref msg, keyData);
}
Painting is a bit involved just to get the positions of the individual elements with all of the options available. There are little adjustments for things like pointer caps. Furthermore, there's a little bit of trig involved in order to calculate X and Y based on a radius and an angle. Also, we must convert from radians to degrees.
/// <summary>
/// Called when the control needs to be painted
/// </summary>
/// <param name="args">The event args</param>
protected override void OnPaint(PaintEventArgs args)
{
const double PI = 3.141592653589793238462643d;
base.OnPaint(args);
var g = args.Graphics;
float knobMinAngle = _minimumAngle;
float knobMaxAngle = _maximumAngle;
if (knobMinAngle < 0)
knobMinAngle = 360 + knobMinAngle;
if (knobMaxAngle <= 0)
knobMaxAngle = 360 + knobMaxAngle;
var crr = ClientRectangle;
// adjust the client rect so it doesn't overhang
--crr.Width; --crr.Height;
var size = (float)Math.Min(crr.Width-4, crr.Height-4);
crr.X += 2;
crr.Y += 2;
var radius = size / 2f;
var origin = new PointF(crr.Left +radius, crr.Top + radius);
var brf = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth / 2)));
var rrf = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth)));
var kr = (knobMaxAngle - knobMinAngle);
int mi=Minimum, mx=Maximum;
double ofs = 0.0;
double vr = mx - mi;
double rr = kr / vr;
if (0 > mi)
ofs = -mi;
double q = ((Value + ofs) * rr) + knobMinAngle;
double angle = (q + 90d);
if (angle > 360.0)
angle -= 360.0;
double angrad = angle * (PI / 180d);
double adj = 1;
if (_pointerEndCap != LineCap.NoAnchor)
adj += (_pointerWidth) / 2d;
var x2 = (float)(origin.X + (radius - adj) * (float)Math.Cos(angrad));
float y2 = (float)(origin.Y + (radius - adj) * (float)Math.Sin(angrad));
using (var backBrush = new SolidBrush(BackColor))
{
using (var bgBrush = new SolidBrush(_knobColor))
{
using (var borderPen = new Pen(_borderColor, _borderWidth))
{
using (var pointerPen = new Pen(_pointerColor, _pointerWidth))
{
pointerPen.StartCap = _pointerStartCap;
pointerPen.EndCap = _pointerEndCap;
g.SmoothingMode = SmoothingMode.AntiAlias;
// erase the background so it antialiases properly
g.FillRectangle(backBrush, (float)crr.Left - 1,
(float)crr.Top - 1, (float)crr.Width + 2, (float)crr.Height + 2);
g.DrawEllipse(borderPen, brf); // draw the border
g.FillEllipse(bgBrush, rrf);
g.DrawLine(pointerPen, origin.X, origin.Y, x2, y2);
}
}
}
}
if(Focused)
ControlPaint.DrawFocusRectangle(g, new Rectangle
(0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));
}
static RectangleF _GetCircleRect(float x, float y, float r)
{
return new RectangleF(x - r, y - r, r * 2, r * 2);
}
MidiVisualizer allows you to view a MIDI sequence as a series of notes on a "piano roll." It supports an optional cursor that can be used to track the current song position. It allows you to customize the colors of everything in the control. Each MIDI channel is a different color.
Other than some appearance settings, the meat of the control is the painting. Here, we paint the background and compute the scaling based on the width, height and overall note difference of the control, and then we take a note map of the current MIDI sequence. Next, we paint but only within the clip rectangle to speed up painting, adding a 3d effect using the alpha color channel on each note, if it's large enough. Finally we draw the cursor, if it's enabled. The reason we don't cache the note map is because the MIDI sequence may change at any time:
/// <summary>
/// Paints the control
/// </summary>
/// <param name="args">The event arguments</param>
protected override void OnPaint(PaintEventArgs args)
{
base.OnPaint(args);
var g = args.Graphics;
using (var brush = new SolidBrush(BackColor))
{
g.FillRectangle(brush, args.ClipRectangle);
}
if (null == _sequence)
return;
var len = 0;
var minNote = 127;
var maxNote = 0;
foreach (var ev in _sequence.Events)
{
// found note on
if(0x90==(ev.Message.Status & 0xF0))
{
var mw = ev.Message as MidiMessageWord;
// update minimum and maximum notes
if (minNote > mw.Data1)
minNote = mw.Data1;
if (maxNote < mw.Data1)
maxNote = mw.Data1;
}
// update the length
len += ev.Position;
}
if (0 == len || minNote > maxNote)
return;
// with what we just gathered now we have the scaling:
var pptx = Width / (double)len;
var ppty = Height / ((maxNote - minNote) + 1);
var crect = args.ClipRectangle;
// get a note map for easy drawing
var noteMap = _sequence.ToNoteMap();
for(var i = 0;i<noteMap.Count;++i)
{
var note = noteMap[i];
var x = unchecked((int)Math.Round(note.Position * pptx)) + 1;
if (x > crect.X + crect.Width)
break; // we're done because there's nothing left within the visible area
var y = Height - (note.NoteId - minNote + 1) * ppty - 1;
var w = unchecked((int)Math.Round(note.Length * pptx));
var h = ppty;
if (crect.IntersectsWith(new Rectangle(x, y, w, h)))
{
// choose the color based on the note's channel
using (var brush = new SolidBrush(_channelColors[note.Channel]))
{
// draw our rect based on scaling and note pos and len
g.FillRectangle(
brush,
x,
y,
w,
h);
// 3d effect, but it slows down rendering a lot.
// should be okay since we're only rendering
// a small window at once usually
if (2 < ppty && 2 < w)
{
using(var pen = new Pen(Color.FromArgb(127,Color.White)))
{
g.DrawLine(pen, x, y, w + x, y);
g.DrawLine(pen, x, y+1, x, y+h-1);
}
using (var pen = new Pen(Color.FromArgb(127, Color.Black)))
{
g.DrawLine(pen, x, y+h-1, w + x, y+h-1);
g.DrawLine(pen, x+w, y + 1, x+w, y + h);
}
}
}
}
var xt = unchecked((int)Math.Round(_cursorPosition * pptx));
var currect = new Rectangle(xt, 0, unchecked((int)Math.Max(pptx, 1)), Height);
if (_showCursor && crect.IntersectsWith(currect) &&
-1<_cursorPosition && _cursorPosition<len)
using(var curBrush = new SolidBrush(_cursorColor))
g.FillRectangle(curBrush, currect);
}
}
A thorough treatment of the PianoBox control is provided here.