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:
-
Start your POE/Tk program with
use Tk
beforeuse POE
. This informs thePOE
framework that it will have to play nice with Perl-Tk. -
Use
$poe_main_window
(exported automatically) as Perl-Tk'sMainWindow
, 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 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();