In a recent series of items I discussed ways of turning an Internet data feed into a video crawl for use on a local public access cable television channel. In the last installment the solution had evolved into an IronPython script that fetches the data, writes XAML code to animate the crawl, and runs that XAML as a fullscreen WPF (Windows Presentation Foundation) application.
This week we finally got a chance to try out the live feed, and we didn’t like what we saw. For starters, the animation was jerky. The PC that became available for this project is an older box running Windows XP. I installed .NET Framework 3.0 on the box, and it now supports WPF apps, but not with the graphics acceleration needed for smooth scrolling.
Even with the smooth scrolling that we see on my laptop, though, it wasn’t quite right. This application displays a long list of events, and it’s going to grow even longer. We decided that a paginated display would be better, so I went back to the drawing board.
We’re happy with the result. It displays pages like so:
Community Calendar 06:30 PM open/lap swim (ymca) 07:00 PM Caregiving for Individuals with Dementia (unh coop extension) 07:00 PM Vicky Cristina Barcelona (colonial theatre) 07:30 PM Faculty Recital-Jazz (eventful: Redfern Arts Center) events from http://elmcity.info page 9 of 12
Pages fade in, display for 8 seconds, then fade out. There are a million ways to do this, but since I was already exploring IronPython, XAML, and WPF I decided to remix those ingredients. For my own future reference, and for anyone else heading down the same path, here are some notes on what I learned. As always, I welcome suggestions and corrections. I’m still a XAML beginner, and will be very interested to learn about alternative approaches.
The approach I take here is clearly influenced by my own past experience doing web development using dynamic languages. There’s no C# code, no compilation, no Visual Studio. The solution is minimal in the way I strongly prefer for simple projects: a single IronPython script that depends only on IronPython and .NET Framework 3.0.
I started with the contents of a single page:
<Canvas ClipToBounds="True" Background="Black" Width="800" Height="600"> <TextBlock x:Name="page1" Canvas.Top="0" Canvas.Left="20" Foreground="#FFFFFF" FontSize="36" FontFamily="Arial" xml:space="preserve"> <![CDATA[ 06:30 PM open/lap swim (ymca) 07:00 PM Caregiving for Individuals with Dementia (unh coop extension) ]]> </TextBlock> </Canvas>
I found that text formatting isn’t WPF’s strong suit, so I’m using the XAML equivalent of an HTML <pre> tag to display text that’s preformatted in IronPython.
Next, I added the fade-in and fade-out effects
<Canvas ClipToBounds="True" Background="Black" Width="800" Height="600"><TextBlock.Triggers> <EventTrigger RoutedEvent="FrameworkElement.Loaded"> <BeginStoryboard> <Storyboard> <DoubleAnimation BeginTime="0:0:0" Storyboard.TargetName="page1" Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:1" /> # 1 sec fade in <DoubleAnimation BeginTime="0:0:9" # wait 8 sec Storyboard.TargetName="page1" Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="0:0:1" /> # 1 sec fade out </Storyboard> </BeginStoryboard> </EventTrigger> </TextBlock.Triggers>+ <TextBlock x:Name="page1" Canvas.Top="0" ...> </Canvas>
I thought it would be possible to chain together a series of these animations, and nest that series inside another animation in order to create the infinite loop that’s required. There may be a way to do that in XAML, but I didn’t find it. So, since I was already planning to generate the XAML — in order to interpolate current event data, plus a variety of attribute values — I went with a generator that produces a series of these pages. That solved chaining, but not looping. To make the sequence loop, I added a second timer/event-handler pair to the IronPython script. The first handler reloads the data once a day. The second handler reloads the XAML at intervals computed according to the number of pages for each day, thus looping the animation.
Next I added XAML elements for the header and footer. The header is static, but the footer has a dynamic page counter so I animated it in the same way as the page.
Next I made templates for all the XAML elements. Here’s the footer template:
template_footer = """<Label x:Name="footer___FOOTER_PAGE_NUM___" Canvas.Top="___FOOTER_CANVAS_TOP___" Canvas.Left="___ FOOTER_CANVAS_LEFT___" Foreground="#FFFFFF" xml:space="preserve" FontSize="___FOOTER_FONTSIZE___" FontFamily="Arial" Opacity="0"> page ___FOOTER_PAGE_NUM___ of ___FOOTER_PAGE_COUNT___ <Label.Triggers> <EventTrigger RoutedEvent="FrameworkElement.Loaded"> <BeginStoryboard> <Storyboard> <DoubleAnimation BeginTime="___BEGIN_FADE_IN___" Storyboard.TargetName="footer___FOOTER_PAGE_NUM___" Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="___FADE_DURATION___" /> <DoubleAnimation BeginTime="___BEGIN_FADE_OUT___" Storyboard.TargetName="footer___FOOTER_PAGE_NUM___" Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="___FADE_DURATION___" /> </Storyboard> </BeginStoryboard> </EventTrigger> </Label.Triggers> </Label> """
The script uses variables that correspond to the uppercase triple-underscore-bracketed names. So, for example:
___FOOTER_CANVAS_TOP___ = 520 ___FOOTER_CANVAS_LEFT___ = 10 ___FOOTER_FONTSIZE___ = 28
To avoid typing all these names twice in order to interpolate variables into the template, I cheated by defining this pair of Python functions:
def isspecial(key): import re return re.match('^___.+___$',key) is not None def interpolate(localdict,template): specialkeys = filter(isspecial,localdict.keys()) for key in specialkeys: exec("""template = template.replace("%s", str(localdict['%s']))""" % (key,key)) return template
Given that setup, here’s the core of the XAML generator:
def create_xaml(raw_text,watch_time,fade_duration): ___TITLE_TEXT___ = 'Community Calendar' ___BODY_TEXT___ = '' ___BODIES_AND_FOOTERS___ = '' ___BODY_NUM___ = 0 ___FOOTER_PAGE_NUM___ = 0 ___BODY_CANVAS_TOP___ = 0 ___BODY_CANVAS_LEFT___ = 20 ___BODY_FONTSIZE___ = 36 ___TITLE_CANVAS_TOP___ = -30 ___TITLE_CANVAS_LEFT___ = 200 ___TITLE_FONTSIZE___ = 34 ___FOOTER_CANVAS_TOP___ = 520 ___FOOTER_CANVAS_LEFT___ = 10 ___FOOTER_FONTSIZE___ = 28 ___FOOTER_PAGE_COUNT___ = 0 ___FOOTER_PAGE_NUM___ = 0 ___BEGIN_FADE_IN___ = '' ___BEGIN_FADE_OUT___ = '' ___FADE_DURATION___ = '' pagecount = 0 for page in page_iterator(raw_text): pagecount += 1 ___FOOTER_PAGE_COUNT___ = pagecount begin_fade_in = 0 begin_fade_out = begin_fade_in + fade_duration + watch_time pagenum = 0 for page in page_iterator(raw_text): pagenum += 1 ___BODY_TEXT___ = page ___BODY_NUM___ = pagenum ___FOOTER_PAGE_NUM___ = pagenum ___BEGIN_FADE_IN___ = makeMinsSecs(begin_fade_in) ___BEGIN_FADE_OUT___ = makeMinsSecs(begin_fade_out) ___FADE_DURATION___ = makeMinsSecs(fade_duration) body = interpolate(locals(),template_body) footer = interpolate(locals(),template_footer) ___BODIES_AND_FOOTERS___ += body + footer begin_fade_in = begin_fade_out + fade_duration begin_fade_out = begin_fade_in + fade_duration + watch_time xaml = interpolate(locals(),template_xaml) return (pagecount,xaml)
Finally, here’s the core of the application itself:
class CalendarDisplay(Application): def load_xaml(self,filename): from System.Windows.Markup import XamlReader f = FileStream(filename, FileMode.Open) try: element = XamlReader.Load(f) finally: f.Close() return element def loop_handler(self,sender,args): # reload XAML def update_xaml(): self.window.Content = self.load_xaml(self.xamlfile) self.loop_timer.Dispatcher.Invoke(DispatcherPriority.Normal, CallTarget0(update_xaml)) def day_handler(self,sender,args): # fetch data, generate XAML def update_xaml(): self.pagecount = calendarToXaml(self.path,self.xamlfile,self.url, self.cachefile,self.watch_time,self.fade_duration) self.window.Content = self.load_xaml(self.xamlfile) self.day_timer.Dispatcher.Invoke(DispatcherPriority.Normal, CallTarget0(update_xaml)) def __init__(self): Application.__init__(self) self.xamlfile = 'display.xaml' self.path = '.' self.cachefile = 'last.txt' self.url = 'http://elmcity.info/events/todayAsText' self.watch_time = 8 self.fade_duration = 1 self.pagecount = calendarToXaml(self.path,self.xamlfile,self.url, self.cachefile,self.watch_time,self.fade_duration) self.window = Window() self.window.Content = self.load_xaml(self.xamlfile) self.window.WindowStyle = WindowStyle.None self.window.WindowState = WindowState.Maximized self.window.Topmost = True self.window.Cursor = Cursors.None self.window.Background = Brushes.Black self.window.Foreground = Brushes.White self.window.Show() self.day_timer = DispatcherTimer() self.day_timer.Interval = TimeSpan(24, 0, 0) self.day_timer.Tick += self.day_handler self.day_timer.Start() self.loop_timer = DispatcherTimer() interval = self.pagecount * (self.watch_time + self.fade_duration*2) self.loop_timer.Interval = TimeSpan(0, 0, interval) self.loop_timer.Tick += self.loop_handler self.loop_timer.Start() CalendarDisplay().Run()