Channels ▼

Web Development

Graphical Interfacing with POE and Tk

April, 2004:

Randal is a coauthor of Programming Perl, Learning Perl, Learning Perl for Win32 Systems and Effective Perl Programming, as well as a founding board member of the Perl Mongers ( Randal can be reached at [email protected]

In an article I wrote for The Perl Journal a year ago ("Tailing Web Logs, April 2003), I introduced the Perl Object Environment, better known as "POE," as a means of executing many tasks at once. Recently, I've been playing with the Tk toolkit through the Perl-Tk interface. With Perl-Tk, I can write Perl programs that use standardized graphical widgets that respond to various interactions.

Perl-Tk is an event-driven framework. A large portion of writing a graphical application consists of responses to various events, such as pressing a button or typing into a text field. Perl-Tk also provides for a few, simple noninterface events as well: the passage of time, and a filehandle becoming ready to read or write. In theory, these two additional event categories are sufficient to write nearly everything I was doing with POE. But in practice, it's silly to reinvent the myriad of gadgets that the POE community has already created.

But why choose one or the other? The POE event loop can safely coexist with the Perl-Tk event loop! In fact, it's as simple as keeping in mind a few important rules:

  • Start your POE/Tk program with use Tk before use POE. This informs the POE framework that it will have to play nice with Perl-Tk.
  • Use $poe_main_window (exported automatically) as Perl-Tk's MainWindow, instead of instantiating one of your own. POE's events have to be bound to Perl-Tk's active main window, and this puts the hooks in place.
  • It's easiest to set up your graphic interface in the _start state in your primary POE session. This ensures that the graphic window is initialized and that POE is running the event loop.

As a simple application of a POE-Tk program, I chose to make use of the recently released "PoCo" (POE Component) called "POE::Component::RSSAggregator." This PoCo automatically fetches and parses RSS feeds, providing a callback when the feed has new headlines. This PoCo was originally created to feed an IRC bot with news headlines, but thanks to the pluggability of POE code, I created a GUI-based newsreader instead.

I chose a tabbed-notebook-style interface, with each news feed being a tab along the top. Selecting a tab brings up the headlines, with new headlines in bold. Single-clicking on a headline marks it as read. Double-clicking on a headline not only marks it as read but also fires off a command to open the corresponding detail URL with my favorite browser. And all this is in the 145 lines of code presented in Listing 1. (Please note that I'm using the hot-off-the-presses Tk 804 release with this program. I've heard from one of my reviewers that the code may not work on older releases.)

Lines 1-3 start nearly every program I write, enabling warnings, compiler restrictions, and unbuffering STDOUT. For this program, a buffered STDOUT really didn't hurt because there weren't any print operations, but I do these lines out of habit now.

Lines 5-19 provide the user-serviceable parts of the program. The idea is that everything after line 19 can be left alone.

First, lines 7-11 give the @FEEDS that I'm monitoring. Each line has three whitespace-delimited fields. The first field is the unique short name for the feed. Because this name also goes on the tab (and the tabs don't wrap), I'm interpreting a vertical bar in the feed name as a line break for the tab. The second field is the frequency in seconds with which to go back to check for updates. Here, 900 seconds is 15 minutes: a good compromise between having fresh news and not angering system administrators. The third field is the RSS-feed URL.

As sample feeds, I've included searches for new use Perl; journals, new Slashdot journals, and a wonderful little link exchange called to which I've only recently been introduced, but seems to be a continual source of cool corners of the Internet. (In fact, you may want to crank up the frequency of fetch a bit higher on that one to avoid missing anything, and be prepared to lose a few hours attempting to follow every link that comes by.)

Line 13 defines a path to a DBM that'll hold our already seen links between invocations of this program. While you'll probably want to just leave this thing running all day, you don't want to lose your place when you have to go away. The DBM provides a simple memory between invocations.

Lines 15-17 define the LAUNCH subroutine, which is very platform specific. On my Mac OS X box, I call open with a URL, and it launches my preferred browser on the URL. If you're using this program, you'll need to figure out how to do that for yourself.

Note that I'm being a bit casual with the POE rules here: When this subroutine is invoked, it blocks for a bit while the command is executing, so no other events are being processed. If this wasn't a very fast launcher, I'd need to use something like POE::Wheel::Run for the child process. But in practice, I don't care because while I'm launching a URL to see, I'm really not expecting any of the rest of my application to work properly.

Lines 21-26 define my needed globals: the POE session alias for the PoCo and the DBM hash for my persistent memory.

Line 28 cleans up my memory storage by deleting any headline that was seen more than three days ago. This seemed like a fair compromise, and is merely an optimization: The program would run fine without it. Had I expected this program to run for weeks at a time, I'd have put this code into a state that got invoked once a day or so. Again, I'm building this program to my expected usage, and not necessarily a general-purpose application.

Lines 30 and 31 pull in Tk and POE, as stated earlier. Lines 33-143 create the main POE session, providing all the user-side interface for this program. Setting that aside at the moment, we see that line 145 starts the POE main event loop, which exits only when the Tk main window is closed.

When the POE event loop starts, the only session created thus far is sent a start event, and this begins the code starting in line 36. Lines 37 and 38 extract the parameters sent to this state handler. Line 39 pulls in the POE::Component::RSSAggregator module, found in CPAN.

Lines 42 to 45 start the RSS PoCo. A separate session is created, named as $READER, with a callback to our current session's handle_feed state when one of the feeds has changed state.

Lines 47-52 set up the main Tk widget for the interface. A standard tabbed notebook widget called NoteBook is established as a child widget of the $poe_main_window, packed so that it fills that window.

Lines 54 and 55 add the initial (and only) feeds to be watched. I chose to do this as a separate state to keep it modular, and also because I eventually wanted to add some GUI components to manage feeds, rather than simply displaying feeds that were hard coded into the program.

Speaking of which, the handler for adding feeds begins immediately thereafter, starting in line 57. Lines 58 and 59 extract the context variables for this handler, as well as getting the $name, $delay, and $url values from the yield call in line 55.

Lines 61-68 add the page for the tabbed notebook, as a ROText widget. This is a text widget with the text entry bindings removed, so that I won't be tempted to type into the area where the URLs are listed. Line 63 patches up the vertical bars into newlines for the tab label. Line 64 adds the page, identified by the given name, and with a label that indicates that we haven't yet fetched the feed. Additionally, a Scrolled widget is created to provide a vertical scrollbar when necessary for the list of headlines. Some of this took a bit of fudging around, but this seems to have created the look that I needed.

Lines 69-77 add the bindings to the text area to respond to single and double clicks. First, two tags are created for new links and already seen links, differing only in their visual presentation (new links are in bold text). Then, tagBind is used to associate clicks on those tags with callbacks to our handle_click state handler, including an additional 1 or 2 parameter to indicate a single or double click. The Ev call also passes the relative x-y coordinates of the click, which we can translate into a line number rather easily (shown later).

Finally, once the tab and text window is established, we're ready to get headlines, as requested in lines 80 and 81. By posting an add_feed event to the $READER session, we'll start getting callbacks to our handle_feed handler as the data arrives and is fully parsed.

Lines 84-104 handle any clicks in the text area, as designated by the bindings in lines 73-76. Let's set that aside for a minute because we can't get any clicks until we actually have some text.

Lines 105-113 handle a notification from the RSS PoCo that we've got a new XML::RSS::Feed object, arriving in $feed in line 108. In this handler, we merely cache the value into our POE heap, and then trigger a callback to our own feed_changed handler.

The feed_changed handler (starting in line 114) is triggered when any feed possibly has new headlines, and also when items are clicked (which changes which items of a feed we've already seen). This handler's job is to take the model data into the view.

Lines 118-121 locate the feed object, the notebook widget, the specific notebook page widget, and the scrolled text widget. Line 124 remembers the scroll percentage for the current text view. If the list of URLs is long enough so that a scrollbar appears, I don't want the scroll bar to jump annoyingly whenever the headlines are updated.

Line 125 clears out any previous URL list. For simplicity, when any part of the data model changes, I start over on the display. For efficiency, I could have tried to compute a small set of differences and redrawn only those, but this was much simpler and, as they say, "fast enough."

Lines 127-137 add the URLs to the display. First, a count of new items is initialized to 0 (line 127). Then, for each of the headline objects, we set $tag to either link or seen, depending on whether the headline's ID property is present in the %SEEN data, counting the headline as new if needed (lines 129-134). Finally, lines 135 and 136 insert the headline text, tagged appropriately, followed by an untagged newline.

Line 138 restores the current scroll position as best as it can, as saved in line 124. Lines 140 and 141 update the tab label with the new number of items that are as-yet unread. That way, I can quickly glance across the tabs to see if there's anything new to read.

Now, back to handling clicks. Lines 88-92 get the feed name, the number of clicks, the text widget, and the x-y coordinates within that text widget where the click occurred. But we don't want the pixel of the click: We want the line number. Luckily, line 94 shows that we can simplify the expression with a simple call to the index method, which returns a line number, dot, and character number within that line.

Lines 96-103 mark the headline corresponding to that line to having been seen (at the current time), and triggers an event to redraw the window (described earlier), since the model data has now effectively changed. (Strictly speaking, it's a view aspect of the model that has changed, but this is close enough for practical purposes.) In addition, if it was a double click instead of a single click, we call LAUNCH on the URL, which launches my browser.

And that's all there is to it! I now have a nice desktop GUI application that watches RSS feeds in exactly the manner of my choosing, with lots of room to add further GUI improvements and functionality. It's also nicely resizable, and deals with large amounts of data as well. So, consider using POE and Tk together for your next GUI application. Until next time, enjoy!


Listing 1

=1=     #!/usr/bin/perl -w
=2=     use strict;
=3=     $|++;
=5=     ## config
=7=     my @FEEDS = map [split], split /\n/, <<'THE_FEEDS';
=8=     useperl|journal 900
=9=     slashdot|journal 900
=10= 900
=11=    THE_FEEDS
=13=    my ($DB) = glob("~/.newsee");   # save database here
=15=    sub LAUNCH {
=16=      system "open", shift;         # open $_[0] as a URL in favorite browser
=17=    }
=19=    ## end config
=21=    ## globals
=23=    my $READER = "reader";          # alias for reader session
=24=    dbmopen(my %SEEN, $DB, 0600) or die;
=26=    ## end globals
=28=    delete @SEEN{grep $SEEN{$_} < time - 86400 * 3, keys %SEEN}; # quick cleanup
=30=    use Tk;
=31=    use POE;
=33=    POE::Session->create
=34=      (inline_states =>
=35=       {
=36=        _start => sub {
=37=          my ($kernel, $session, $heap) =
=38=            @_[KERNEL, SESSION, HEAP];
=39=          require POE::Component::RSSAggregator;
=41=          ## start the reader
=42=          POE::Component::RSSAggregator->new
=43=              (alias => $READER,
=44=               callback => $session->postback('handle_feed'),
=45=              );
=47=          ## set up the NoteBook
=48=          require Tk::NoteBook;
=49=          $heap->{nb} = $poe_main_window
=50=            ->NoteBook
=51=              (-font => [-size => 10]
=52=              )->pack(-expand => 1, -fill => 'both');
=54=          ## add the initial subscriptions
=55=          $kernel->yield(add_feed => @$_) for @FEEDS;
=56=        },
=57=        add_feed => sub {
=58=          my ($kernel, $session, $heap, $name, $delay, $url) =
=59=            @_[KERNEL, SESSION, HEAP, ARG0, ARG1, ARG2];
=61=          ## add a notebook page
=62=          require Tk::ROText;
=63=          (my $label_name = $name) =~ tr/|/\n/;
=64=          my $scrolled = $heap->{nb}->add($name, -label => "$label_name: ?")
=65=            ->Scrolled
=66=              (ROText => -scrollbars => 'oe',
=67=               -spacing3 => '5',
=68=              )->pack(-expand => 1, -fill => 'both');
=69=          ## set up bindings on $scrolled here
=70=          $scrolled->tagConfigure('link', -font => [-weight => 'bold']);
=71=          $scrolled->tagConfigure('seen');
=72=          for my $tag (qw(link seen)) {
=73=            $scrolled->tagBind($tag, '<1>', 
=74=                           [$session->postback(handle_click => $name, 1), Ev('@')]);
=75=            $scrolled->tagBind($tag, '<Double-1>', 
=76=                           [$session->postback(handle_click => $name, 2), Ev('@')]);
=77=          }
=79=          ## start the feed, getting callbacks
=80=          $kernel->post($READER => add_feed =>
=81=                        {url => $url, name => $name, delay => $delay});
=83=        },
=84=        handle_click => sub {
=85=          my ($kernel, $session, $heap, $postback_args, $callback_args) =
=86=            @_[KERNEL, SESSION, HEAP, ARG0, ARG1];
=88=          my $name = $postback_args->[0];
=89=          my $count = $postback_args->[1]; # 1 = single click, 2 = double click
=91=          my $text = $callback_args->[0];
=92=          my $at = $callback_args->[1];
=94=          my ($line) = $text->index($at) =~ /^(\d+)\.\d+$/ or die;;
=96=          if (my $headline = $heap->{feed}{$name}->headlines->[$line - 1]) {
=97=            $SEEN{$headline->id} = time;
=98=            $kernel->yield(feed_changed => $name);
=100=           if ($count == 2) {      # double click: open URL
=101=             LAUNCH($headline->url);
=102=           }
=103=         }
=104=       },
=105=       handle_feed => sub {
=106=         my ($kernel, $session, $heap, $callback_args) =
=107=           @_[KERNEL, SESSION, HEAP, ARG1];
=108=         my $feed = $callback_args->[0];
=110=         my $name = $feed->name;
=111=         $heap->{feed}{$name} = $feed;
=112=         $kernel->yield(feed_changed => $name);
=113=       },
=114=       feed_changed => sub {
=115=         my ($kernel, $session, $heap, $name) =
=116=           @_[KERNEL, SESSION, HEAP, ARG0];
=118=         my $feed = $heap->{feed}{$name};
=119=         my $nb = $heap->{nb};
=120=         my $widget = $nb->page_widget($name);
=121=         my $scrolled = $widget->children->[0];
=123=         ## update the text
=124=         my ($pct) = $scrolled->yview;
=125=         $scrolled->delete("1.0", "end");
=127=         my $new_count = 0;
=128=         for my $headline (@{$feed->headlines}) {
=129=           my $tag = 'link';
=130=           if ($SEEN{$headline->id}) {
=131=             $tag = 'seen';
=132=           } else {
=133=             $new_count++;
=134=           }
=135=           $scrolled->insert('end', $headline->headline, $tag);
=136=           $scrolled->insert('end', "\n");
=137=         }
=138=         $scrolled->yviewMoveto($pct);
=140=         (my $label_name = $name) =~ tr/|/\n/;
=141=         $nb->pageconfigure($name, -label => "$label_name: $new_count");
=142=       },
=143=      });
=145=   $poe_kernel->run();
Back to article

Related Reading

More Insights

Currently we allow the following HTML tags in comments:

Single tags

These tags can be used alone and don't need an ending tag.

<br> Defines a single line break

<hr> Defines a horizontal line

Matching tags

These require an ending tag - e.g. <i>italic text</i>

<a> Defines an anchor

<b> Defines bold text

<big> Defines big text

<blockquote> Defines a long quotation

<caption> Defines a table caption

<cite> Defines a citation

<code> Defines computer code text

<em> Defines emphasized text

<fieldset> Defines a border around elements in a form

<h1> This is heading 1

<h2> This is heading 2

<h3> This is heading 3

<h4> This is heading 4

<h5> This is heading 5

<h6> This is heading 6

<i> Defines italic text

<p> Defines a paragraph

<pre> Defines preformatted text

<q> Defines a short quotation

<samp> Defines sample computer code text

<small> Defines small text

<span> Defines a section in a document

<s> Defines strikethrough text

<strike> Defines strikethrough text

<strong> Defines strong text

<sub> Defines subscripted text

<sup> Defines superscripted text

<u> Defines underlined text

Dr. Dobb's encourages readers to engage in spirited, healthy debate, including taking us to task. However, Dr. Dobb's moderates all comments posted to our site, and reserves the right to modify or remove any content that it determines to be derogatory, offensive, inflammatory, vulgar, irrelevant/off-topic, racist or obvious marketing or spam. Dr. Dobb's further reserves the right to disable the profile of any commenter participating in said activities.

Disqus Tips To upload an avatar photo, first complete your Disqus profile. | View the list of supported HTML tags you can use to style comments. | Please read our commenting policy.