Copyright Notice

This text is copyright by CMP Media, LLC, and is used with their permission. Further distribution or use is not permitted.

This text has appeared in an edited form in Perl Journal magazine. However, the version you are reading here is as the author originally submitted the article for publication, not after their editors applied their creativity.

Please read all the information in the table of contents before using this article.
Download this listing!

Perl Journal Column 11 (Apr 2004)

[Suggested title: ``Graphical interaction with POE and Tk'']

Back in my first article for this publication nearly a year ago, 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 non-interface 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:

  1. 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.

  2. 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.

  3. 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 newsfeed 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 marked it as read, but also fires off a command to open the corresponding detail URL with my favorite browser. And all this in the 145 lines of code presented in [listing one, below]. (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 through 3 start nearly every program I write, enabling warnings, compiler restrictions, and unbuffering STDOUT. For this program, a buffered STDOUT really didn't hurt, since there weren't any print operations, but I do these lines out of habit now.

Lines 5 to 19 provide the user-servicable parts of the program. Hopefully, everything after line 19 can be left alone.

First, lines 7 to 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 fresh news and unangry system administrators. The third field is the RSS-feed URL.

As sample feeds, I've included searches for new use.perl.org journals, new slashdot.org journals, and a wonderful little link exchange called del.icio.us to which I've only recently been introduced, but seems to be a continual source of cool corners of the net. (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 to 17 define the LAUNCH subroutine, which is very platform specific. On my MacOS 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 to 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 to 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 the 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 to 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, and 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 to 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 to 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 to 104 handle any clicks in the text area, as designated by the bindings in lines 73 to 76. Let's set that aside for a minute, because we can't get any clicks until we actually have some text.

Lines 105 to 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 has possibly new headlines, and also when items are clicked (which changes which items of a feed that we've already seen). This handler's job is to take the model data into the view.

Lines 118 to 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 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 to 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 to 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 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 to 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 to 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!

Listings

        =1=     #!/usr/bin/perl -w
        =2=     use strict;
        =3=     $|++;
        =4=     
        =5=     ## config
        =6=     
        =7=     my @FEEDS = map [split], split /\n/, <<'THE_FEEDS';
        =8=     useperl|journal 900 http://use.perl.org/search.pl?op=journals&content_type=rss
        =9=     slashdot|journal 900 http://slashdot.org/search.pl?op=journals&content_type=rss
        =10=    del.icio.us 900 http://del.icio.us/rss/
        =11=    THE_FEEDS
        =12=    
        =13=    my ($DB) = glob("~/.newsee");   # save database here
        =14=    
        =15=    sub LAUNCH {
        =16=      system "open", shift;         # open $_[0] as a URL in favorite browser
        =17=    }
        =18=    
        =19=    ## end config
        =20=    
        =21=    ## globals
        =22=    
        =23=    my $READER = "reader";          # alias for reader session
        =24=    dbmopen(my %SEEN, $DB, 0600) or die;
        =25=    
        =26=    ## end globals
        =27=    
        =28=    delete @SEEN{grep $SEEN{$_} < time - 86400 * 3, keys %SEEN}; # quick cleanup
        =29=    
        =30=    use Tk;
        =31=    use POE;
        =32=    
        =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;
        =40=    
        =41=          ## start the reader
        =42=          POE::Component::RSSAggregator->new
        =43=              (alias => $READER,
        =44=               callback => $session->postback('handle_feed'),
        =45=              );
        =46=    
        =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');
        =53=    
        =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];
        =60=    
        =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=          }
        =78=    
        =79=          ## start the feed, getting callbacks
        =80=          $kernel->post($READER => add_feed =>
        =81=                        {url => $url, name => $name, delay => $delay});
        =82=    
        =83=        },
        =84=        handle_click => sub {
        =85=          my ($kernel, $session, $heap, $postback_args, $callback_args) =
        =86=            @_[KERNEL, SESSION, HEAP, ARG0, ARG1];
        =87=    
        =88=          my $name = $postback_args->[0];
        =89=          my $count = $postback_args->[1]; # 1 = single click, 2 = double click
        =90=    
        =91=          my $text = $callback_args->[0];
        =92=          my $at = $callback_args->[1];
        =93=    
        =94=          my ($line) = $text->index($at) =~ /^(\d+)\.\d+$/ or die;;
        =95=    
        =96=          if (my $headline = $heap->{feed}{$name}->headlines->[$line - 1]) {
        =97=            $SEEN{$headline->id} = time;
        =98=            $kernel->yield(feed_changed => $name);
        =99=    
        =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];
        =109=   
        =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];
        =117=   
        =118=         my $feed = $heap->{feed}{$name};
        =119=         my $nb = $heap->{nb};
        =120=         my $widget = $nb->page_widget($name);
        =121=         my $scrolled = $widget->children->[0];
        =122=   
        =123=         ## update the text
        =124=         my ($pct) = $scrolled->yview;
        =125=         $scrolled->delete("1.0", "end");
        =126=   
        =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);
        =139=   
        =140=         (my $label_name = $name) =~ tr/|/\n/;
        =141=         $nb->pageconfigure($name, -label => "$label_name: $new_count");
        =142=       },
        =143=      });
        =144=   
        =145=   $poe_kernel->run();

Randal L. Schwartz is a renowned expert on the Perl programming language (the lifeblood of the Internet), having contributed to a dozen top-selling books on the subject, and over 200 magazine articles. Schwartz runs a Perl training and consulting company (Stonehenge Consulting Services, Inc of Portland, Oregon), and is a highly sought-after speaker for his masterful stage combination of technical skill, comedic timing, and crowd rapport. And he's a pretty good Karaoke singer, winning contests regularly.

Schwartz can be reached for comment at merlyn@stonehenge.com or +1 503 777-0095, and welcomes questions on Perl and other related topics.