Contents
In this article, I will present a Windows desktop application which allows to browse xkcd comics. When you launch the application, it shows the latest xkcd that is published at the website. You can browse other xkcds as you would do it at the website. It also utilizes features new in Windows 7 such as jump lists, thumbnail previews, and thumbnail toolbar to provide better user experience.
In order to display comics and related information from xkcd.com, we first need to parse the page. We need to retrieve the comic title, ID, image location, and the mouse-over text. This can be easily done by using the HTML Agility Pack library. The HTML Agility Pack also provides an application called HAPExplorer where you can load the source of a webpage and build the XPath expression you need. As you can see from the image below, we need the following XPath expression:
/html[1]/body[1]/div[1]/div[2]/div[1]/div[2]/div[1]/div[1]
After that, we need the h3
and img
elements to retrieve the necessary information. Here is the corresponding code:
public xkcd(string xkcdUrl)
{
HtmlWeb loader = new HtmlWeb();
HtmlDocument doc = loader.Load(xkcdUrl);
HtmlNode mainnode = doc.DocumentNode.
SelectSingleNode("/html[1]/body[1]/div[1]/div[2]/div[1]/div[2]/div[1]/div[1]");
HtmlNode img = mainnode.SelectSingleNode("img");
HtmlNode h3 = mainnode.SelectSingleNode("h3");
ImagePath = img.Attributes[0].Value;
MouseOver = img.Attributes[1].Value;
Title = img.Attributes[2].Value;
int temp = 0;
int.TryParse(h3.InnerText.Replace("Permanent link to this comic: http://xkcd.com",
string.Empty).Replace("/", string.Empty),
out temp);
ID = temp;
Url = string.Format("http://xkcd.com/{0}/", ID);
using (WebClient client = new WebClient())
{
using (Stream imagestream = client.OpenRead(ImagePath))
{
Image = Image.FromStream(imagestream);
}
}
}
As the previous source code snippet shows, we have a class called xkcd
which knows how to get the required information. However, the form which displays it never instantiates the class directly. Instead, all the interaction goes through the xkcdService
class. This class exposes the following methods for getting an xkcd:
GetLast
GetFirst
GetRandom
GetPrevious
GetNext
As getting the source of a webpage requires some time, all the above methods are executed asynchronously. When an xkcd object is available, the xkcdService
class fires the xkcdLoaded
event and passes the downloaded object.
Apart from this, the xkcdService
class keeps track of the current xkcd ID, and exposes two properties indicating whether there is a previous and next xkcd or not. Also, it keeps a cache of the loaded objects so that if the object is requested for a second time, it is returned instantly. The code snippet below shows how all this is done:
public void GetNext()
{
if (cache.ContainsKey(currentID + 1))
{
OnxkcdLoaded(cache[currentID + 1]);
}
else
{
worker.RunWorkerAsync(string.Format("http://xkcd.com/{0}/", currentID + 1));
}
}
private void worker_DoWork(object sender, DoWorkEventArgs e)
{
string arg = e.Argument as string;
if (!string.IsNullOrEmpty(arg))
{
xkcd temp = new xkcd(arg);
if (!cache.ContainsKey(temp.ID))
{
cache.Add(temp.ID, temp);
}
if (arg.Equals("http://xkcd.com/"))
{
lastID = temp.ID;
}
currentID = temp.ID;
e.Result = temp;
}
}
private void OnxkcdLoaded(xkcd result)
{
currentID = result.ID;
HasPrevious = result.ID > 1;
HasNext = result.ID < lastID;
if (xkcdLoaded != null)
{
xkcdLoaded(this, new ExtendedEventArgs<xkcd>(result));
}
}
The form is subscribed to the xkcdLoaded
event, and displays the new comic.
In order to report progress when a comic is being loaded, the program uses the taskbar progress bar. As the time for loading a comic is not known, the progress bar is in an indeterminate state. To achieve the desired effect, only a single line of code is needed:
TaskbarManager.Instance.SetProgressState(TaskbarProgressBarState.Indeterminate);
The result we get looks like this:
The program also uses toolbar buttons for easier navigation between the comics. Toolbar buttons are ordinary buttons placed at the thumbnail preview. As we will see below, creating them is easy, and consists of three steps:
first = new ThumbnailToolbarButton(Properties.Resources.First, "First");
prev = new ThumbnailToolbarButton(Properties.Resources.Prev, "Previous");
random = new ThumbnailToolbarButton(Properties.Resources.Random, "Random");
next = new ThumbnailToolbarButton(Properties.Resources.Next, "Next");
last = new ThumbnailToolbarButton(Properties.Resources.Last, "Last");
first.Click += new EventHandler<thumbnailbuttonclickedeventargs>(firstButton_Click);
prev.Click += new EventHandler<ThumbnailButtonClickedEventArgs>(prevButton_Click);
random.Click += new EventHandler<ThumbnailButtonClickedEventArgs>(randomButton_Click);
next.Click += new EventHandler<ThumbnailButtonClickedEventArgs>(nextButton_Click);
last.Click += new EventHandler<ThumbnailButtonClickedEventArgs>(lastButton_Click);
TaskbarManager.Instance.ThumbnailToolbars.AddButtons(this.Handle, first, prev,
random, next, last);
Here is how these buttons appear:
Thumbnail preview appears when you hover the mouse over the window in the taskbar. Your application gets a default thumbnail without a single line of code, but to get the most out of them, you can customize it according to your needs. As the application displays comics, I decided that it would be better if the thumbnail displayed only the current comic and nothing else. To achieve the desired behaviour, I wrote an extension method which returns a rectangle corresponding to the current image. After that, using it as a thumbnail preview is as easy as:
TaskbarManager.Instance.TabbedThumbnail.SetThumbnailClip(
this.Handle, xkcdPictureBox.GetImageRectangle());
As a result, we get a thumbnail of the current comic which is displayed on the image above.
As the name suggests, a jump list is a list of tasks where you can jump to. They appear when you right-click a window in a taskbar and provide access to frequently used tasks. This program has two tasks: one for visiting http://xkcd.com/about/, and another for saving the current comic to disk. Tasks do not expose an event when they are clicked. Instead, they launch an external application. As a result, to create a task, you need to supply the path of the application that will be launched when the task is clicked and an icon displayed by the taskbar. For the first task, the application path needs to point to the default browser path. For the second task, we need to launch our program a second time, but we need to save an image from the first one. Let's see what we can do to overcome these problems.
As it turns out, we can retrieve the default browser path by using the AssocQueryString
function. According to MSDN, the function "Searches for and retrieves a file or protocol association-related string from the Registry." With the help of pinvoke.net, I wrote the following method to get the browser path:
private string GetDefaultBrowser()
{
uint pcchOut = 0;
NativeMethods.AssocQueryString(NativeMethods.AssocF.Verify,
NativeMethods.AssocStr.Executable, ".html",
null, null, ref pcchOut);
StringBuilder pszOut = new StringBuilder((int)pcchOut);
NativeMethods.AssocQueryString(NativeMethods.AssocF.Verify,
NativeMethods.AssocStr.Executable, ".html",
null, pszOut, ref pcchOut);
return pszOut.ToString();
}
As we cannot access the memory of the first instance from the second instance, we need to notify it that the user wants to save an image and then quit. We can achieve it by sending a Windows message by using the SendMessage
function. The message that we send is registered by the program with a call to the RegisterWindowMessage
function. The first instance listens to messages by overriding the WndProc
method. So, when the second instance is launched, it checks for command arguments, and if found, sends the message to the first instance. Before it sends the message, it first needs to find the handle of the window which will receive it. This is achieved by the FindWindow
function. It sounds a little bit complicated, but as we can see, it is quite easy:
static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
if (args.Length == 0)
{
Application.Run(new MainForm());
}
else
{
IntPtr handle = NativeMethods.FindWindow(null, "xkcd Browser");
NativeMethods.SendMessage(handle, NativeMethods.WM_SAVE,
IntPtr.Zero, IntPtr.Zero);
}
}
class NativeMethods
{
public static readonly uint WM_SAVE;
static NativeMethods()
{
WM_SAVE = RegisterWindowMessage("savexkcd");
}
}
protected override void WndProc(ref Message m)
{
if (m.Msg == NativeMethods.WM_SAVE)
{
using (CommonSaveFileDialog save = new CommonSaveFileDialog())
{
save.DefaultExtension = "png";
save.DefaultFileName = titleLabel.Text;
save.AlwaysAppendDefaultExtension = true;
save.Filters.Add(CommonFileDialogStandardFilters.PictureFiles);
if (save.ShowDialog() == CommonFileDialogResult.OK)
{
if (xkcdPictureBox.Image != null)
{
xkcdPictureBox.Image.Save(save.FileName, ImageFormat.Png);
}
}
}
return;
}
base.WndProc(ref m);
}
As we already know how to retrieve the browser path and communicate between different instances, we are now ready to create the tasks. The process is easy and straightforward. As you can see, the default browser path is used for the task icon too.
private void InitializeJumpList()
{
string browser = GetDefaultBrowser();
JumpList jumpList = JumpList.CreateJumpList();
jumpList.AddUserTasks(new JumpListLink(browser, "About xkcd")
{
IconReference = new IconReference(browser, 0),
Arguments = "http://xkcd.com/about/"
});
jumpList.AddUserTasks(new JumpListLink(
Application.ExecutablePath,"Save current comic")
{
IconReference =new IconReference(
Path.Combine(Application.StartupPath,"program.ico"), 0),
Arguments = "save"
});
jumpList.Refresh();
}
I guess it's time to see the result:
If you are wondering why I built this application, the answer is simple: It was fun. The problems were challenging, and playing with the Windows 7 API is also interesting.
Comments, ideas, suggestions, and votes are welcome.
- January 25, 2010 - Initial release.