Introduction
I've been wanting to write an applet that would count down to an event's date and time. This was originally inspired by the millennium countdown displays that you could find in the post office. Ten years later, I'm finally writing something. The idea here is that you can create multiple events, and the window shows the time remaining to that event.
There are many options and possibilities to how to go about doing this--I finally settled on something fairly simple, and yet I could probably spend a couple of weeks just doing feature enhancements. In fact, there are actually display features that I coded that I haven't even exposed as options in the user interface. Regardless, here's what it does:
- Create multiple countdown events.
- Events can repeat at hourly, daily, weekly, monthly, or yearly intervals. I figure hourly would be useful for when you have to take some antibiotic three times a day.
- Events more than three days away are displayed in green.
- Events between one and three days away are displayed in yellow.
- Events less than 24 hours away are displayed in red.
- Events that have passed are displayed in purple.
- Events can be reset at a specified interval or cancelled.
- Events can be manually organized by moving them up and down in the list.
Right off the bat, I can see a few things I'd like to add:
- Create a UI for colors, fonts, background, etc.
- Customize the warning level alert times
- Remove counters from the display that are too far in the future
- Alert sounds or other visual effects
- Expose the count group display options
- Automate the group display options--for example, something 20 days away doesn't need to show hours, minutes, and seconds
- Mouse over for more event detail information
- Synchronize with Outlook Calendar
- Sort by event due date
- Other display modalities: horizontal, stacked, carousel, etc.
The Program
I chose to completely ignore what is considered to be standard programming practices, meaning there is no MVC or MVVM pattern here. Most of the work is done in static methods of the Program
class, and the only real objects are the UI components:
CounterFrame Class
So, we have a CounterFrame
. The most interesting thing the frame does is handle the events from the popup menu when the user right-clicks on the timers:
private void setupToolStripMenuItem_Click(object sender, EventArgs e)
{
new CounterEditor().ShowDialog();
}
private void cancelToolStripMenuItem_Click(object sender, EventArgs e)
{
Program.RemoveCurrentCounter();
Program.counterTable.AcceptChanges();
Program.SaveTable();
Program.LoadCounters();
}
private void resetToolStripMenuItem_Click(object sender, EventArgs e)
{
Program.ResetCurrentCounter();
Program.counterTable.AcceptChanges();
Program.SaveTable();
}
private void closeApplicationToolStripMenuItem_Click(object sender, EventArgs e)
{
Close();
}
Purists will be rolling over in their graves at the static calls and the use of the global "counterTable
" DataTable
!
Counter Class
The Counter
class is a user control that also maintains model information regarding the target event:
public partial class Counter : UserControl, ICounter
{
protected CounterConfiguration config;
protected DiagnosticDictionary<CounterConfiguration.DisplayOptions, Group> counterGroupMap;
protected bool showDays;
protected bool showHours;
protected bool showMinutes;
protected bool showSeconds;
protected bool expired;
public DateTime TargetEvent { get; set; }
public CounterConfiguration Config { get { return config; } }
public Counter(DateTime targetEvent, string descr)
{
TargetEvent = targetEvent;
config = new CounterConfiguration();
InitializeComponent();
counterGroupMap = new DiagnosticDictionary<CounterConfiguration.DisplayOptions, Group>();
CreateCounterGroups();
CreateDescription(descr);
}
...
The counter display is configurable, in the sense that any combination of the four groups (days, hours, minutes, seconds) can be shown or not. This feature is not exposed in a configuration UI right now, but it does work.
protected void CreateCounterGroups()
{
int pos = 0;
int groups=0;
showDays = ((config.CounterDisplayOptions &
CounterConfiguration.DisplayOptions.ShowDays)
== CounterConfiguration.DisplayOptions.ShowDays);
showHours = ((config.CounterDisplayOptions &
CounterConfiguration.DisplayOptions.ShowHours)
== CounterConfiguration.DisplayOptions.ShowHours);
showMinutes = ((config.CounterDisplayOptions &
CounterConfiguration.DisplayOptions.ShowMinutes)
== CounterConfiguration.DisplayOptions.ShowMinutes);
showSeconds = ((config.CounterDisplayOptions &
CounterConfiguration.DisplayOptions.ShowSeconds)
== CounterConfiguration.DisplayOptions.ShowSeconds);
foreach (CounterConfiguration.DisplayOptions option in
Enumerator<CounterConfiguration.DisplayOptions>.Items().OrderByDescending(x => x))
{
Group group = null;
if ((config.CounterDisplayOptions & option) == option)
{
string descr = EnumHelper.GetDescription(option);
group = new Group(descr);
}
else
{
group = new EmptyGroup();
}
group.Location = new Point(pos, 0);
pos += group.Width;
Controls.Add(group);
counterGroupMap[option] = group;
++groups;
}
Width = 6 * Group.GroupWidth;
}
The event description is a Label
added programmatically:
protected void CreateDescription(string descr)
{
Label lblDescr = new Label();
lblDescr.Text = descr;
lblDescr.Height = Height;
lblDescr.Width = Group.GroupWidth * 2;
lblDescr.Dock = DockStyle.Right;
lblDescr.BackColor = Color.Black;
lblDescr.ForeColor = Color.White;
lblDescr.Font = new System.Drawing.Font("Verdana", 7F,
System.Drawing.FontStyle.Regular,
System.Drawing.GraphicsUnit.Point, ((byte)(0)));
lblDescr.TextAlign = ContentAlignment.MiddleCenter;
lblDescr.MouseDown += new MouseEventHandler(Program.OnMouseDown);
lblDescr.MouseUp += new MouseEventHandler(Program.OnMouseUp);
lblDescr.MouseMove += new MouseEventHandler(Program.OnMouseMove);
Controls.Add(lblDescr);
}
Notice the mouse events are wired up to static method handlers in the Program
class. This occurs several times in the code, both here and in the Group
user control.
Every second, the Tick
method of the Counter
instance is called, which updates the display according to hard-coded logic:
public void Tick()
{
DisplayStruct disp = GetGroupValues();
TimeSpan when = TargetEvent - DateTime.Now;
Color color = Color.Red;
if (when.TotalDays > 3)
{
color = Color.Green;
}
else if (when.TotalDays > 1)
{
color = Color.Yellow;
}
else if (when.TotalSeconds < 0)
{
color = Color.Purple;
}
Group dayGroup = counterGroupMap[CounterConfiguration.DisplayOptions.ShowDays];
dayGroup.Value = disp.days;
dayGroup.UpdateDisplay("G", color);
Group hourGroup = counterGroupMap[CounterConfiguration.DisplayOptions.ShowHours];
hourGroup.Value = disp.hours;
hourGroup.UpdateDisplay(disp.hourFormat, color);
Group minuteGroup = counterGroupMap[CounterConfiguration.DisplayOptions.ShowMinutes];
minuteGroup.Value = disp.minutes;
minuteGroup.UpdateDisplay(disp.minuteFormat, color);
Group secondGroup = counterGroupMap[CounterConfiguration.DisplayOptions.ShowSeconds];
secondGroup.Value = disp.seconds;
secondGroup.UpdateDisplay(disp.secondFormat, color);
}
The interesting thing here is that the display format is adjusted based on whether the higher-order group is missing. Normally, except for days, all groups display as two digit values using the "D2" format. However, if a group is missing, the following group has to pick up the overflow. So if hours are not displayed, minutes has to display minutes + hours * 60. This exceeds the two digit format, so the code changes the display format to "G". At the moment, you can certainly exceed the width of the Group
control, which is why I haven't made this feature "public" in the UI.
Group Class
The Group
class is a user control that contains two controls: the Label
describing the group and a Label
for the counter value. The mouse events are wired up to capture mouse clicks that occur on these two controls.
public partial class Group : UserControl
{
public static int GroupWidth = 50;
public int Value { get; set; }
public Group(string header)
{
InitializeComponent();
lblGroup.Text = header;
Width = GroupWidth;
lblGroup.MouseDown += new MouseEventHandler(Program.OnMouseDown);
lblGroup.MouseUp += new MouseEventHandler(Program.OnMouseUp);
lblGroup.MouseMove += new MouseEventHandler(Program.OnMouseMove);
lblCounter.MouseDown += new MouseEventHandler(Program.OnMouseDown);
lblCounter.MouseUp += new MouseEventHandler(Program.OnMouseUp);
lblCounter.MouseMove += new MouseEventHandler(Program.OnMouseMove);
}
public virtual void UpdateDisplay(string format, Color foreColor)
{
lblCounter.Text = Value.ToString(format);
lblCounter.ForeColor = foreColor;
}
}
The Data Model
A DataView
, sorted on an Index
column, is used for managing the counters. This fits well with working with the counter editor DataGridView
:
The backing DataTable
is created with the columns shown above (except for "Index", which is hidden):
private static void CreateTable()
{
counterTable = new DataTable("Counters");
counterTable.Columns.Add("Index", typeof(Int32));
counterTable.Columns.Add("TargetDate", typeof(DateTime));
counterTable.Columns.Add("Repeat", typeof(bool));
counterTable.Columns.Add("RepeatInterval", typeof(Interval.IntervalOption));
counterTable.Columns.Add("IntervalAmount", typeof(Int32));
counterTable.Columns.Add("Description", typeof(string));
counterView = new DataView(Program.counterTable);
counterView.Sort = "Index";
}
The table is serialized and deserialized using the DataTable
's XML serialization feature:
private static void LoadTable()
{
if (File.Exists("counters.xml"))
{
counterTable.ReadXml("counters.xml");
}
}
public static void SaveTable()
{
counterTable.WriteXml("counters.xml", XmlWriteMode.WriteSchema);
}
Additional form-specific information (location and the always-on-top flag) are serialized to a different file:
public static void LoadFormPosition()
{
if (File.Exists("CounterConfig.txt"))
{
StreamReader sr = new StreamReader("CounterConfig.txt");
string onTop = sr.ReadLine();
string x = sr.ReadLine();
string y = sr.ReadLine();
sr.Close();
alwaysOnTop = Convert.ToBoolean(onTop);
counterFrame.Location = new Point(Convert.ToInt32(x), Convert.ToInt32(y));
counterFrame.TopMost = alwaysOnTop;
}
}
public static void SaveFormPosition()
{
StreamWriter sw = new StreamWriter("CounterConfig.txt");
sw.WriteLine(alwaysOnTop.ToString());
sw.WriteLine(counterFrame.Location.X);
sw.WriteLine(counterFrame.Location.Y);
sw.Close();
}
Program Startup
The program startup puts it all together. If there are no events on startup, the application brings up the event editor first and then launches the main application.
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
counterFrame = new CounterFrame();
CreateTable();
LoadTable();
Timer timer = new Timer();
timer.Tick += new EventHandler(OnTick);
timer.Interval = 1000;
timer.Start();
if (counterTable.Rows.Count == 0)
{
new CounterEditor().ShowDialog();
if (counterTable.Rows.Count > 0)
{
LoadCounters();
Application.Run(counterFrame);
}
}
else
{
LoadCounters();
Application.Run(counterFrame);
}
}
The Font
I've currently hardcoded an LED font that I found here, by Sizenko Alexander of "Style-7". It's a freeware font, and I've included it as part of the executable download.
Conclusion
What was interesting, for me at least, in writing this was where I decided to put effort into some architectural considerations and where I decided I wanted to keep things very simple. For example, the user controls and the internals on how the counters are configured is, I think, well architected. The choice to implement most of the control logic as static methods was because nothing more complicated was warranted. Before adding more complicated features, I'll probably go back and clean up the model-view entanglement.
Further Reading
Definitely off-topic, but given the screenshot, here's links to things I'm into lately.