Introduction
Summary
This article will demonstrate how to create a screen saver in C# and how to work with the Outlook object model.
Idea
It seems to me that programmers always get stuck with the bad cubicles. At a now defunct software company we were seated in an "open environment". This was a polite way of saying "cubicles without any walls". It was supposed to increase collaboration or some such, oddly none of the higher-ups felt the need to increase their collaboration in this manner. A result of this arrangement is that you'd have a cubicle-buddy, someone you spent 6-8 hours a day roughly 18 inches away from. I was quite fortunate to have a buddy who was away from his desk a lot. However, this resulted in me being asked for his whereabouts on a regular basis. The conversations went remarkably like:
Coworker: Hey, where's Skyler?
Me: I don't know.
Coworker: Do you know where he went?
Me: I don't know.
Coworker: Do you know when he'll be back?
Me: Look, I'm really not his secretary.
Coworker: Jerk.
After experiencing this delight a few dozen times I had a revelation. What Skyler needs is a screen saver that displays where he is. Something that goes out and reads his Outlook calendar and displays his public schedule. Then next time someone comes by and inquires "Where's Skyler?" I can grunt and point at his monitor.
Setting up the project
Project
The first step is to create a new console application. A Windows screen saver is essentially a .exe with a different extension.
Configuring post-build rename
By default console applications are compiled into a .exe. Since there's really no difference between a .exe and a .scr a simple rename task is all that's needed. However, Visual Studio likes to lock files so a copy is used instead.
Add references
The next step is to add a reference to the Outlook interop library.
This example uses Outlook 9 (AKA Outlook 2000) and has not been tested against other versions (but will probably work with Outlook XP/2003).
Main
The main program is pretty brief; the release portion of the code is the most interesting. This section checks for command-line arguments and either displays the configuration page or the screen saver. The supported command-lines are:
/c - show the configuration form
/p - preview the screen saver (called "Test" from the Windows context menu)
/s - show the screen saver
In this screen saver /p and /s do the same thing.
if((args.Length<1)||(string.Compare(args[0],"/c",true)==0))
{
Application.Run(new ConfigurationForm());
}
else if((string.Compare(args[0],"/p",true)==0)||(string.Compare(args[0],"/s",true)==0))
{
Application.Run(new SaverForm());
}
else
{
MessageBox.Show("Unsupported command line argument: "+args[0]);
}
Configuration form
Overview
The configuration form is used to set the options for the screen saver. The configurable options are:
Font: The name of the font to use.
Default Message: What gets displayed if no appointments are occurring at the specified time.
Show Errors: There's a region on the screen saver that displays any errors that occurred loading the appointments. If this box is checked they will be displayed.
ConfigurationForm_Load method
Two things happen on the load event. First, a list of all installed fonts is loaded into the font dropdown list. Next, any previously saved settings are loaded from the registry.
private void ConfigurationForm_Load(object sender, System.EventArgs e)
{
Cursor.Current=Cursors.WaitCursor;
//load the list of installed fonts
InstalledFontCollection installedFonts=new InstalledFontCollection();
foreach(FontFamily fontFamily in installedFonts.Families)
{
this.fontDropdown.Items.Add(fontFamily.Name);
}
//load saved values
RegistryKey key=Registry.CurrentUser.OpenSubKey("Software",false);
RegistryKey subKey=key.OpenSubKey(Application.ProductName,false);
if(subKey!=null)
{
this.fontDropdown.Text=(String)subKey.GetValue("fontName",string.Empty);
this.defaultMessageTextbox.Text=(String)subKey.GetValue("defaultMessage","I'll be right back.");
this.displayErrorsCheckbox.Checked=((String)subKey.GetValue("showErrors","True")).Equals("True");
}
Cursor.Current=Cursors.Default;
}
Saving the settings
All settings chosen by the user are saved to the registry.
private void okButton_Click(object sender, System.EventArgs e)
{
Cursor.Current=Cursors.WaitCursor;
//save settings
RegistryKey key=Registry.CurrentUser.OpenSubKey("Software",true);
RegistryKey subKey=key.OpenSubKey(Application.ProductName,true);
if(subKey==null)
{
subKey=key.CreateSubKey(Application.ProductName);
}
subKey.SetValue("fontName",this.fontDropdown.Text);
subKey.SetValue("defaultMessage",this.defaultMessageTextbox.Text);
subKey.SetValue("showErrors",this.displayErrorsCheckbox.Checked.ToString());
//exit
Cursor.Current=Cursors.Default;
this.Close();
}
SimpleAppointment.cs
Purpose
This class is used to shield the main application from having to work directly with the Outlook object model. There were a couple of motivations for doing this. It theoretically helps the application support other calendar programs. All the "business rules" work with SimpleAppointment and not the vendor-specific object model. Also by creating a custom appointment class comparison logic can be implemented.
[note: xml documentation snipped, some code re-formatted to reduce printed length]
public class SimpleAppointment
{
#region private members
private DateTime startTime;
private DateTime endTime;
private string description;
#endregion
#region public properties
public DateTime StartTime
{
set{ this.startTime=value; }
get{ return(this.startTime); }
}
public DateTime EndTime
{
set{ this.endTime=value; }
get{ return(this.endTime); }
}
public string Description
{
set{ this.description=value; }
get{ return(this.description); }
}
#endregion
#region constructors
public SimpleAppointment(DateTime startTime,DateTime endTime,string description)
{
this.StartTime=startTime;
this.EndTime=endTime;
this.Description=description;
}
public SimpleAppointment Clone()
{
return((SimpleAppointment)this.MemberwiseClone());
}
public override bool Equals(Object obj)
{
//Check for null and compare run-time types.
if((Object.ReferenceEquals(obj,null))||(GetType()!=obj.GetType()))
{
return(false);
}
else
{
SimpleAppointment task=(SimpleAppointment)obj;
return(
(task.StartTime.Equals(this.StartTime))&&
(task.EndTime.Equals(this.EndTime))&&
(task.Description.Equals(this.Description))
);
}
}
public override int GetHashCode(){
return(base.GetHashCode()); }
public static bool operator ==(SimpleAppointment left,SimpleAppointment right)
{
if(Object.ReferenceEquals(left,right)){ return(true); }
else if(Object.ReferenceEquals(left,null)){ return(false); }
else{ return(left.Equals(right));
}
}
public static bool operator !=(SimpleAppointment left,SimpleAppointment right)
{
return(!(left==right));
}
public static bool operator <(SimpleAppointment left,SimpleAppointment right)
{
return(left.StartTime.CompareTo(right.StartTime)<0);
}
public static bool operator >(SimpleAppointment left,SimpleAppointment right)
{
return(left.StartTime.CompareTo(right.StartTime)>0);
}
public static bool operator <=(SimpleAppointment left,SimpleAppointment right)
{
return((left<right)||(left==right));
}
public static bool operator >=(SimpleAppointment left,SimpleAppointment right)
{
return((left>right)||(left==right));
}
#endregion
}
public class SimpleAppointmentComparer:IComparer
{
public int Compare(object x,object y)
{
if((x is SimpleAppointment)&&(y is SimpleAppointment))
{
return(CompareSA((SimpleAppointment)x,(SimpleAppointment)y));
}
else
{
return(x.GetHashCode()-y.GetHashCode());
}
}
private static int Compare(SimpleAppointment x,SimpleAppointment y)
{
return(CompareSA(x,y));
}
private static int CompareSA(SimpleAppointment x,SimpleAppointment y)
{
if(x<y){ return(-1); }
else if(x>y){ return(1); }
else{ return(0); }
}
}
OutlookUtil.cs
PurposeThis class serves as a static utility class for working with Outlook. It would be good practice to go back and have this inherit some common "ICalendar" interface to better support the theoretical addition of other calendar programs.
Members and properties
Neither of those should really be pluralized. There's only one member, it's a static reference to the Outlook application instance. There's only one property, it's a static reference to the last error encountered.
#region private static members
private static Application app;
private static Application App
{
get
{
if(app==null)
{
app=new Application();
}
return(app);
}
}
#endregion
#region publc static properties
private static System.Exception lastError;
public static System.Exception LastError
{
get{ return(lastError); }
set{ lastError=value; }
}
#endregion
private static MAPIFolder GetCalendar()
{
MAPIFolder calendar=null;
NameSpace session=App.Session;
try
{
calendar=session.GetDefaultFolder(OlDefaultFolders.olFolderCalendar);
}
catch(System.Exception x)
{
LastError=new System.Exception("Error getting the calendar folder",x);
}
return(calendar);
}
FilterItems method
The method takes a collection of Outlook items and filters them using the built-in Restrict method. The Restrict method takes a SQL-like parameter containing the filter statement. In this case, the items are restricted to ones that end during the current date, do not have a status of "free", and are public (obviously a user doesn't want their private appointments showing up).
private static Outlook.Items FilterItems(Outlook.Items items)
{
System.Text.StringBuilder builder=new System.Text.StringBuilder();
builder.Append("[End]>='");
builder.Append(DateTime.Now.ToShortDateString());
builder.Append(" 12:00 AM'");
builder.Append(" and [BusyStatus]<>0"); //0=olFree
builder.Append(" and [Sensitivity]=0"); //0=olNormal
string filter=builder.ToString();
try
{
Outlook.Items filteredItems=items.Restrict(filter);
filteredItems.Sort("[Start]",false);
return(filteredItems);
}
catch(System.Exception x)
{
LastError=new System.Exception("Error filtering
items.",x);
return(items);
}
}
GetAppointments method
This method grabs a reference to the default calendar and filters the items (see previous two methods). It then loops through all the items and converts them to SimpleAppointment objects.
public static ArrayList GetAppointments()
{
ArrayList list=new ArrayList();
MAPIFolder calendar=GetCalendar();
if(calendar!=null)
{
Outlook.Items appointments=FilterItems(calendar.Items);
DateTime now=DateTime.Now;
object obj;
//try getting the first item
try
{
obj=appointments.GetFirst();
}
catch(System.Exception x)
{
LastError=new System.Exception("Error getting first calendar item.",x);
obj=null;
}
while(obj!=null)
{
if(obj is AppointmentItem)
{
AppointmentItem appointment=(AppointmentItem)obj;
SimpleAppointment simpleAppointment=new SimpleAppointment(appointment.Start,appointment.End,appointment.Subject);
list.Add(simpleAppointment);
}
//try getting the next item
try
{
obj=appointments.GetNext();
}
catch(System.Exception x)
{
LastError=new System.Exception("Error getting next calendar item.",x);
obj=null;
}
}
//sort the list
list.Sort(new SimpleAppointmentComparer());
}
return(list);
}
Saver Form
Purpose
This is the form that displays the actual screen saver. It loads the list of appointments and displays the current/future ones. On a timed basis it moves the text to a random location.
Members
startingPoint: The initial location of the mouse, used to track if it was moved.
appointments: The list of appointments.
defaultMessage & fontName: Defaults to use if no user-defined settings are present.
apptColors & errorColors: Arrays of colors to use when displaying items. Yeah, there's certainly a better way to do this but I was going for quick.
SaverForm_Load method
The first thing this method does is resize the form to fill the entire screen. Then it loads saved settings from the registry. Next-up is loading the list of appointments, this call takes a few seconds. Finally, it sets the startingPoint member to (-1,-1) and kicks-off a timer tick.
private void SaverForm_Load(object sender, System.EventArgs e)
{
Cursor.Current=Cursors.WaitCursor;
//fill the entire screen
this.Bounds=Screen.PrimaryScreen.Bounds;
//load saved values
try
{
RegistryKey key=Registry.CurrentUser.OpenSubKey("Software",false);
RegistryKey subKey=key.OpenSubKey(Application.ProductName,false);
if(subKey!=null)
{
this.displayErrors=((String)subKey.GetValue("showErrors","True")).Equals("True");
this.fontName=(String)subKey.GetValue("fontName",string.Empty);
this.defaultMessage=(String)subKey.GetValue("defaultMessage","I'll be right back.");
}
}
catch(Exception x)
{
if(this.displayErrors)
{
this.errorTextbox.Text=x.Message+Environment.NewLine+x.StackTrace;
this.errorTextbox.Visible=true;
Random random=new Random();
Color foreColor=this.errorColors[random.Next(0,this.errorColors.Length)];
this.errorTextbox.ForeColor=foreColor;
this.errorTextbox.Font=new Font(this.fontName,random.Next(4,6)*2);
}
}
//load the appointments
ArrayList appointmentList=OutlookUtil.GetAppointments();
this.appointments=(SimpleAppointment[])appointmentList.ToArray(typeof(SimpleAppointment));
//set startingPoint to (-1,-1), see OnMouseMove event
this.startingPoint=new Point(-1,-1);
//force a tick
this.timer_Tick(null,null);
Cursor.Current=Cursors.Default;
}
OnMouseMove & OnKeyPress methods
This method checks if the mouse has moved, if so the screen saver is exited. Ditto if a key is pressed. The (-1,-1) check is used to prevent the application from exiting immediately.
private void OnMouseMove(object sender,MouseEventArgs args)
{
if((this.startingPoint.X==-1)&&(this.startingPoint.Y==-1))
{
this.startingPoint=new Point(args.X,args.Y);
}
else if((this.startingPoint.X!=args.X)||(this.startingPoint.Y!=args.Y))
{
this.Close();
}
}
private void OnKeyPress(object sender,KeyPressEventArgs args)
{
this.Close();
}
timer_Tick method
When the timer event fires its time to update the screen saver. The current & future appointments are loaded, random colors are chosen for the text, and the text is moved to a random location on the screen.
private void timer_Tick(object sender, System.EventArgs e)
{
//stop in case this runs long
this.timer.Stop();
//clear the last error
OutlookUtil.LastError=null;
//random number generator used to pick colors
Random random=new Random();
#region show the current appointments
SimpleAppointment[] currentAppointments=this.getCurrentAppointments();
int length=currentAppointments.Length;
StringBuilder builder=new StringBuilder();
if(length<=0)
{
builder.Append(this.defaultMessage);
}
else
{
for(int index=0;index<length;index++)
{
builder.Append(currentAppointments[index].StartTime.ToString());
builder.Append("-");
builder.Append(currentAppointments[index].EndTime.ToString());
builder.Append(" ");
builder.Append(currentAppointments[index].Description);
builder.Append(Environment.NewLine);
}
}
this.currentAppointmentsTextbox.Text=builder.ToString();
this.currentAppointmentsTextbox.SelectionLength=0;
Color foreColor=this.apptColors[random.Next(0,this.apptColors.Length)];
this.whereNowLabel.ForeColor=foreColor;
this.currentAppointmentsTextbox.ForeColor=foreColor;
this.whereNowLabel.Font=new Font(this.fontName,random.Next(8,10)*2);
this.currentAppointmentsTextbox.Font=new Font(this.fontName,random.Next(4,6)*2);
#endregion
#region show the future appointments
SimpleAppointment[] futureAppointments=this.getFutureAppointments();
length=futureAppointments.Length;
if(length>0)
{
builder=new StringBuilder();
for(int index=0;index<length;index++)
{
builder.Append(futureAppointments[index].StartTime.ToString());
builder.Append("-");
builder.Append(futureAppointments[index].EndTime.ToString());
builder.Append(" ");
builder.Append(futureAppointments[index].Description);
builder.Append(Environment.NewLine);
}
this.futureAppointmentsTextbox.Text=builder.ToString();
this.futureAppointmentsTextbox.Visible=true;
this.whereLaterLabel.Visible=true;
foreColor=this.apptColors[random.Next(0,this.apptColors.Length)];
this.whereLaterLabel.ForeColor=foreColor;
this.whereLaterLabel.Font=new Font(this.fontName,random.Next(8,10)*2);
this.futureAppointmentsTextbox.ForeColor=foreColor;
this.futureAppointmentsTextbox.Font=new Font(this.fontName,random.Next(4,6)*2);
}
else
{
//hide the future appointments
this.futureAppointmentsTextbox.Visible=false;
this.whereNowLabel.Visible=false;
}
#endregion
#region check for errors
if((this.displayErrors==false)||(OutlookUtil.LastError==null))
{
this.errorTextbox.Visible=false;
}
else
{
builder=new StringBuilder();
builder.Append(OutlookUtil.LastError.Message);
builder.Append(Environment.NewLine);
builder.Append(OutlookUtil.LastError.StackTrace);
if(OutlookUtil.LastError.InnerException!=null)
{
builder.Append(Environment.NewLine);
builder.Append("Inner Exception: ");
builder.Append(OutlookUtil.LastError.InnerException.Message);
builder.Append(Environment.NewLine);
builder.Append(OutlookUtil.LastError.InnerException.StackTrace);
}
this.errorTextbox.Text=builder.ToString();
this.errorTextbox.Visible=true;
foreColor=this.errorColors[random.Next(0,this.errorColors.Length)];
this.errorTextbox.ForeColor=foreColor;
this.errorTextbox.Font=new Font(this.fontName,random.Next(4,6)*2);
}
#endregion
#region now move everything to a random location
int maxTop=this.Height-this.contentPanel.Height;
int maxLeft=this.Width-this.contentPanel.Width;
int top=random.Next(0,maxTop);
int left=random.Next(0,maxLeft);
this.contentPanel.Location=new Point(top,left);
#endregion
//restart
this.timer.Start();
}
getCurrentAppointments & getFutureAppointments methods
These two methods are used to sort out the current & future appointments. These methods are roughly the same, there's probably some way to accomplish this in one method, again, going for quick on this.
private SimpleAppointment[] getCurrentAppointments()
{
ArrayList list=new ArrayList();
DateTime now=DateTime.Now;
int length=this.appointments.Length;
int index=0;
int apptCount=0;
while((index<length)&&(apptCount<MAX_APPT_COUNT)) //this assumes that appointments is sorted by start date
{
int compareStart=now.CompareTo(this.appointments[index].StartTime);
int compareEnd=now.CompareTo(this.appointments[index].EndTime);
if(compareStart>=0)
{
if(compareEnd<0)
{
list.Add(this.appointments[index]);
apptCount++;
}
else
{
index=length;
}
}
index++;
}
return((SimpleAppointment[])list.ToArray(typeof(SimpleAppointment)));
}
private SimpleAppointment[] getFutureAppointments()
{
ArrayList list=new ArrayList();
DateTime now=DateTime.Now;
int length=this.appointments.Length;
int index=0;
int apptCount=0;
while((index<length)&&(apptCount<MAX_APPT_COUNT)) //this assumes that appointments is sorted by start date
{
int compareStart=now.CompareTo(this.appointments[index].StartTime);
if(compareStart<0)
{
list.Add(this.appointments[index]);
apptCount++;
}
index++;
}
return((SimpleAppointment[])list.ToArray(typeof(SimpleAppointment)));
}
The Final Product
The final product looks like this:
After a few tests it seems to working as expected. The initial startup is kinda slow because of the time needed to load up the calendar items. After writing this article and reflecting on it there are a few other lingering issues..
Issues
The reason I didn't post this in Github is because it's an incomplete project. It does what I needed it to do so I stopped working on it. However, the basic framework would probably be useful to someone else so I released it as a "How To" tutorial instead. Here are a few of the things that need to be done to make this a "complete" application:
Reoccurring appointments don't display
This is a very painful issue to fix. A reoccurring appointment has the start/end date of the first instance. This causes it to appear as a past event that isn't displayed on the screen saver.
Linking to unmanaged code
This screen saver links to the Outlook 9 libraries. COM/COM+ interop always seems to have problems so it would be nice to avoid this binding. There's not much I can do to fix this though.
Registry
I'm not a big fan of storing things in the registry. It would be preferable to use an xml configuration file instead.
If Outlook isn't running this probably doesn't work
Yeah, it's easy enough to kick-off an Outlook session if it's not. I pretty much always have Outlook running so this didn't bother me too much.
Only tested against Outlook 2000
I have a feeling that this won't work with other versions of Outlook because it's bound to the Outlook 9 libraries. Well, it will probably work once the references are updated and the code recompiled.
Portability
It would be nice to if this could support different calendar programs.
Related