At work we’ve got a .NET 2.0 Winforms time card-type application for entering information about which projects we work on. In this application we wanted to show the user which dates he’s entered work hours for. We decided to have these dates for show up in bold, just like in Outlook where it shows you which dates you have appointments for. This is easy, since this behavior is supported by the MonthCalendar control.
But we also wanted to indicate those days for which he’s not yet entered enough hours. We wanted those to show up in red, and since this is not standard behavior in the control we had to modify it. This turned out to be more difficult than I had expected, and therefore I decided to blog about it.
I do not claim that the stuff outlined below is the right way to mod the control. Actually, I sincerely hope it isn’t. Because it sure feels like climbing up a tree ass-first. So if anyone can point out a better way to achieve this stuff then I’d love to hear from you.
Step 1 – Checking the documentation
First I created a new control that derived from the MonthCalendar control. But when reading the documentation for MonthCalendar I started to suspect that implementing these red warnings might not be so easy after all. Because the control is drawn by the operating systems there are rather limited options for modifying it. In the documentation I found this gem:
“The MonthCalendar control is drawn by the operating system, so the Paint event is never raised. If you need to provide a customized look to the MonthCalendar control, you should override the OnPrint method, call the base implementation of OnPrint, and then perform custom painting.”
Sounds easy enough. There’s just one problem. OnPrint is never called. Ever. Asking nicely didn’t help. After fighting with this for a while and not really finding anything I decided to try and solve this some other way. I found very little info on the MonthCalendar control on Google, which became one of the reasons I decided to write this article.
Step 2 – First nasty workaround
OnPrint didn’t seem to be the way to go, but I managed to find something on Google in the end. Someone (sorry, can’t remember where I found this, so I can’t thank the person who provided this) pointed out that in WndProc it would be possible to catch the WM_PAINT event and use that. The code would look like this:
// Override WndProc and force a call to OnPaint when we get a WM_PAINT
protected static int WM_PAINT = 0x000F;
protected override void WndProc(ref System.Windows.Forms.Message m)
{
base.WndProc(ref m);
if (m.Msg == WM_PAINT)
{
Graphics graphics = Graphics.FromHwnd(this.Handle);
PaintEventArgs pe = new PaintEventArgs(graphics, new Rectangle(0, 0, this.Width, this.Height));
OnPaint(pe);
}
}
Fair enough, I decided to go with that.
Step 3 – Adding some basics
Next I needed a list of those dates that should show up in red that we want to warn the user about.
private List _warningDates = new List();
public List WarningDates
{
get
{
return _warningDates;
}
set
{
_warningDates = value;
}
}
Nothing strange there.
Step 4 – Figuring out the dimensions of the control
Now the fun really began. At this point so much time had been spent on figuring out the basics of this that I was really pressed for time and had to start cutting corners. Basically I wanted to be able to identify where in the MonthCalendar a certain date was appearing so that I could draw a red square on it. For this I needed to figure out the dimensions and appearance of the control. I started with trying to figure out where in the control the actual dates were being drawn. I didn’t come up with a good way of doing this, and ended up having to use a method called HitTest to try and figure out which area is which in the control. This was a really ugly approach, but I didn’t have time to look for another solution.
I went about it like this:
int firstWeekPosition = 0;
int lastWeekPosition = Height;
while ((HitTest(25, firstWeekPosition).HitArea != HitArea.PrevMonthDate &&
HitTest(25, firstWeekPosition).HitArea != HitArea.Date) && firstWeekPosition < Height)
{
firstWeekPosition++;
}
while ((HitTest(25, lastWeekPosition).HitArea != HitArea.NextMonthDate &&
HitTest(25, firstWeekPosition).HitArea != HitArea.Date) && firstWeekPosition >= 0)
{
lastWeekPosition--;
}
What basically is going on here is that I traverse the control first from the top and then from the bottom in a straight line looking for the Date-area of the control. I arbitrarily chose to do this at 25 pixels into the control from the left, simply because it was likely that I would be sure to come across the date-area when looking there. I will not be winning any beauty contests with this code.
When I’d done that I needed to calculate the area that is allocated for each date. I did that like this:
int dayBoxWidth = 0;
int dayBoxHeight = 0;
dayBoxWidth = Width / (ShowWeekNumbers ? 8 : 7);
dayBoxHeight = (int)(((float)(lastWeekPosition - firstWeekPosition)) / 6.0f);
This gave me everything I needed for finding out where to paint.
Step 5 – Painting warnings
My original idea was to draw on top of the date drawn by the control itself, and use a red color with the alpha channel set to around 50% in order to achieve the red highlight. However, when I implemented this I realized that it wouldn’t work. Since I called OnPaint every time anything needed to be redrawn I ended up redrawing all the warning areas every time, even for such portions of the control that was not in need of redrawing. Since all of these paints were cumulative I ended up with a control that got increasingly red for each repaint because all the paints were being done on top of each other and the alpha channel was slowly being eroded.
Because of time constraints I had to decide between doing the proper implementation, i.e. starting to take into account which section of the screen actually needed repainting, instead of just painting everything. The other option was to skip the alpha idea and instead draw a solid square and also draw the text on it. I decided to go with the latter, because I decided it would be easier even though it clearly wasn’t the “right” solution.
.NET contains a number of interesting methods for working on graphics, and the new TextRenderer in .NET 2.0 is very nice. By extracting each visible date that should be a warning and putting it in a variable called visDate I was able to do the following:
int row = 0;
int col = 0;
TimeSpan span = visDate.Subtract(calendarRange.Start);
row = span.Days / 7;
col = span.Days % 7;
Rectangle fillRect = new Rectangle((col + (ShowWeekNumbers ? 1 : 0)) * dayBoxWidth + 2, firstWeekPosition + row * dayBoxHeight + 1, dayBoxWidth - 2, dayBoxHeight - 2);
graphics.FillRectangle(warningBrush, fillRect);
The warningBrush is a brush that contains the red color I wanted to draw. row and col calculate the position of the date that should be drawn, by first figuring out where that date is placed in relationship to the first visible date in the calendar.
Adding the text was quite simple.
// Check if the date is in the bolded dates array bool
makeDateBolded = false;
foreach (DateTime boldDate in BoldedDates)
{
if (boldDate == visDate)
{
makeDateBolded = true;
}
}
using (Font textFont = new Font(Font, (makeDateBolded ? FontStyle.Bold : FontStyle.Regular)))
{
TextRenderer.DrawText(graphics, visDate.Day.ToString(), textFont, fillRect, Color.FromArgb(255,128,0,0), TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
By using a font based on the font of the control itself and only toggling whether it should be bold made sure that the squares I draw would look the same as the ones created by the control itself.
The end result
The resulting control you can see on the right of this text. Here I’ve given it a black color, and it’s running on a computer with Finnish regional settings. The “red” color I’ve chosen to use on the warnings is actually rather pink.
Please note that although this example does not show it the control can indicate days for which some time has been entered, but not enough. These are shown as both bolded and with a red background.
The finished OnPaint method
The code I’ve presented in this post so far isn’t complete, and it might be quite difficult to read it when I’ve cut up like I did above. Therefore I’ve included the completed OnPaint method below.
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
Graphics graphics = e.Graphics;
int dayBoxWidth = 0;
int dayBoxHeight = 0;
int firstWeekPosition = 0;
int lastWeekPosition = Height;
if ( WarningDates.Count > 0)
{
SelectionRange calendarRange = GetDisplayRange(false);
// Create a list of those dates that actually should be marked as warnings.
List visibleWarningDates = new List();
foreach (DateTime date in WarningDates)
{
if (date >= calendarRange.Start && date <= calendarRange.End)
{
visibleWarningDates.Add(date);
}
}
if (visibleWarningDates.Count > 0)
{
while ((HitTest(25, firstWeekPosition).HitArea != HitArea.PrevMonthDate && HitTest(25, firstWeekPosition).HitArea != HitArea.Date) && firstWeekPosition < Height)
{
firstWeekPosition++;
}
while ((HitTest(25, lastWeekPosition).HitArea != HitArea.NextMonthDate && HitTest(25, lastWeekPosition).HitArea != HitArea.Date) && lastWeekPosition >= 0)
{
lastWeekPosition--;
}
if (firstWeekPosition > 0 && lastWeekPosition > 0)
{
dayBoxWidth = Width / (ShowWeekNumbers ? 8 : 7);
dayBoxHeight = (ant)(((float)(lastWeekPosition - firstWeekPosition)) / 6.0f);
using (Brush warning Brush = new Solid Brush(Color.FromArgb(255, Color.FromArgb(255,240,240))))
{
foreach (DateTime visDate in visibleWarningDates)
{
ant row = 0;
ant col = 0;
Timespan span = visDate.Subtract(calendar Range.Start);
row = span.Days / 7;
col = span.Days % 7;
Rectangle fillRect = new Rectangle((col + (ShowWeekNumbers ? 1 : 0)) * dayBoxWidth + 2, firstWeekPosition + row * dayBoxHeight + 1, dayBoxWidth - 2, dayBoxHeight - 2);
graphics.Fill Rectangle(warning Brush, fillRect);
// Check if the date is in the bolded dates array
bool makeDateBolded = false;
foreach (DateTime boldDate in BoldedDates)
{
if (boldDate == visDate)
{
makeDateBolded = true;
}
}
using (Font textFont = new Font(Font, (makeDateBolded ? Font Style.Bold : Font Style.Regular)))
{
TextRenderer.DrawText(graphics, visDate.Day.ToString(), textFont, fillRect, Color.FromArgb(255,128,0,0), TextFormatFlags.HorizontalCenter | TextFormatFlags.Vertical Center);
}
}
}
}
}
}
}
How to improve this solution
First of all I put a lot of things into OnPrint that shouldn’t really be done every single time the calendar is being redrawn, for example the position of the actual calendar dates in the control. This way is a waste of processing time. I also waste a lot of GDI resources in this solution. I will redo this whenever I have the time™.
Another thing is that nothing in the control takes into account if you set the control up to show more than one month at a time. It wouldn’t be that difficult to support this scenario, but I haven’t had time to do it yet. In our project there is no need for it anyway. I’ve also not tested this approach with different regional settings, and I’m sure that some are bound to break this.
As I pointed out earlier, what obviously should be done differently is to take the update region (i.e. the region of the window that actually has to be repainted) into account. That way I could avoid unnecessary redraws. This would probably have improved the situation somewhat with regards to being able to use alpha colors when filling in the red. More information about this topic can be found here. It is every bit as scary as it looks.
Addendum 2006–05–23
I should post my code to my blog more often. Mikale already pointed out a bug in it that I had missed. 