Yesterday I spoke about IronRuby and Silverlight to the .NET Developers Association, or "NETDA", on (Microsoft's) campus tonight. In this post, I'll show one of the apps I built, a Flickr client.
Here's the live app: http://jimmy.schementi.com/silverlight/photoviewer
For starters, you'll need install Silverlight 2 Beta 2, as well as download the Silverlight Dynamic Languages SDK (Beta 2), or "sdl-sdk" for short. Unzip sdl-sdk.zip to a folder named "sdl-sdk" anywhere on your file system.
You'll also need photoviewer-start.zip, which contains images and libraries that this app depends on. (You can also grab photoviewer-final.zip if you want to cheat)
This should say something along the lines of "Your ruby Silverlight application was created in photoviewer". It does for you too? Awesome, let's move on. So, what got generated? Inside the new "photoviewer" folder you'll find:
index.html - This hosts the Ruby Silverlight app. If you're curious as to what's going on here, there's documentation inline, so read it!
ruby/app.rb - Entry point to the Silverlight app. This generated file just renders app.xaml and sets some text from Ruby.
ruby/app.xaml - XAML UI for the app. Actually, we're not going to use XAML for this app, so we'll delete this later.
ruby/silverlight.rb - Defined "SilverlightApplication" and makes existing Silverlight API's friendlier to Ruby I gave you a newer version of this file in photoviewer-start.zip, so you'll have to overwrite this file with that.
stylesheets/error.css - In the unfortunate event of you writing bad Ruby code, this stylesheet will format the in-browser error message all pretty-like for you.
javascripts/error.js - In case you turn Ruby error reporting off, this file will still catch any errors and not cause your users to see some ugly alert box.
This will start a Silverlight development web server called Chiron, and launch your default browser at index.html. Using the "/w" flag just starts the server and doesn't open your browser. Anyway, you should see this (in your default browser, of course):
images/loading.gif - loading indicator gif thingy.
ruby/System.Json.dll - JSON parser that ships in the Silverlight SDK.
ruby/json.rb - requires and monkey patches System.Json to be more ruby-esk.
ruby/silverlight.rb - This app will depend on a more up-to-date version of silverlight.rb, so you can replace the generated one with this.
lightbox/ - A javascript library for showing images prettily. Love it.
By changing the width/height to "1", we're making the control basically invisible, but still actually visible so it still loads. Clever hack, huh?
Now let's give our app a UI. Type this after the "</object>" tag at the bottom of index.html:
This is basic search box, submit button, and a place to render the images found. Let's style it ... open up stylesheets/screen.css. First comes first; delete the #silverlightControlHost style since we don't want our 1x1 control taking up 100% of the page.
Refresh your browser and you'll see this:
The point of this app is to type a keyword, hit search, and see images from Flickr that have something to do with the keyword. So, I propose the first step would be to get Ruby handle that search button press. Agree? Good. Replace the entire body of the App class with the following:
That will print "Search button pressed!" at the bottom of the page every time you press the search button. Duh, that's what the code says! =P
We're going to talk to Flickr using REST, because we're sane. The @options hash simply collects the various parts that the REST call requires; we'll build a function to make this into a URL later. The important part is that we're calling flickr.photos.search and asking for the response to be JSON. The onclick event calls this cryptically-name create method which doesn't exist yet, so let's write it.
I had a feeling I'd need to reuse create, so it just adds those arguments to the @options we built in initialize since they're also needed to be part of the URI, and call this request method. This is where the "talking" happens.
First, it makes the URL from the @options (we'll define that method soon enough). Then it news-up an instance of System::Windows::Net::WebClient, part of Silverlight, to actually make the request to Flickr. The images_loading.style[:display] = "inline" causes the loading indicator to start spinning. When the response comes back, I call show, which presumable should show the results in some way. For now, we'll just print the response, and stop our loading indicator.
Oh, and now would be a good time to implement make_url ;)
Nothing’s really special here, just puts the "?", "&", and "=" in the right places. Anyway, we're not ready to give her another run. Save app.rb, refresh your browser, type a search-term, click search, and you'll see the magic unfold:
That'll load that json.rb file we copied in the beginning. It simply monkey patches System::Json::JsonValue to give easier access to the data. Here's a loot at json.rb, just for fun.
This just adds the [] method to JsonValue so you can access JSON values like foo['bar'] where the JSON was foo = {'bar' : 'baz'}. Useful. Anyway, back to writing code ourselves. In app.rb, let's redefine the show method to parse our JSON and do something useful with it.
So, now show just calls System::Json::JsonValue.parse on our Flickr response, and then I've written a new method called render which delegates the rendering to a new class called Render (defining that is next), and stops the loading indicator. Let's make a new file called render.rb and require it in app.rb:
A bit of code here, but it's all straight-forward, except for all these "tag" calls. The private tag method does most of the work here, but generating HTML based on a name and options; it makes writing HTML more ruby-esk. You'll also notice that generate_pages and hook_page_events aren't implemented yet. You can not implement them now, refresh your browser, do a search, and you'll at least get the images back:
So, to implement generate_pages, we just need to render links for the pages, and to implement hook_page_events, we need to hook each one of those page links with an event that calls create for the given page. Simple enough ...
Do the whole save/refresh/search song-and-dance and you'll have pretty paging action.
That being said, I'd like to spruce up the "clicking on an image". Right now it opens in a completely new page, and breaks the back-button since your search is wiped away. It's be awesome to have some cool visual effect when clicking on the image, and even having it link to the real Flickr image page. Only if there was such a library ...
http://www.huddletogether.com/projects/lightbox2/
Like I said before, Lightbox is awesome. So, to pretty up zooming in on an image, we'll use Lightbox. First, let's go back to index.html and add some references to Lightbox:
Lightbox uses special properties on the anchor tag to store information about the image you are viewing, like if it's part of a collection, or what the title of the image should be, etc. Let's go back into our Render#photo method and add a :title and :rel values to our :a tag.
So, the entire tag(:a) call turns into this:
Lastly, let's initialize Lightbox when we show the response. Put the following after the render call in the show method in app.rb:
That last line isn't the prettiest, but our images are all pretty.
Here's the live app: http://jimmy.schementi.com/silverlight/photoviewer
Pre-requisites
This walk-through will work for both Mac and Windows, however the app seems to have problems in Safari currently. Firefox or IE will work fine.For starters, you'll need install Silverlight 2 Beta 2, as well as download the Silverlight Dynamic Languages SDK (Beta 2), or "sdl-sdk" for short. Unzip sdl-sdk.zip to a folder named "sdl-sdk" anywhere on your file system.
You'll also need photoviewer-start.zip, which contains images and libraries that this app depends on. (You can also grab photoviewer-final.zip if you want to cheat)
Creating a new project
To create a Silverlight app, there's a script included in the SDK called "sl", which takes two arguments: the language (ruby, python, or jscript), and the name of your application. A folder will be created with the name of your app in your current directory, and a default app generated inside it. Enough talking, let's get to it ... open cmd.exe/Terminal.app/whatever and type:$ cd path/to/sdl-sdk $ script/sl ruby photoviewer(Note: if you're on Windows, make sure to use backslashes "\" instead of forward slashes "/" in the path names)
This should say something along the lines of "Your ruby Silverlight application was created in photoviewer". It does for you too? Awesome, let's move on. So, what got generated? Inside the new "photoviewer" folder you'll find:
index.html - This hosts the Ruby Silverlight app. If you're curious as to what's going on here, there's documentation inline, so read it!
ruby/app.rb - Entry point to the Silverlight app. This generated file just renders app.xaml and sets some text from Ruby.
ruby/app.xaml - XAML UI for the app. Actually, we're not going to use XAML for this app, so we'll delete this later.
ruby/silverlight.rb - Defined "SilverlightApplication" and makes existing Silverlight API's friendlier to Ruby I gave you a newer version of this file in photoviewer-start.zip, so you'll have to overwrite this file with that.
stylesheets/error.css - In the unfortunate event of you writing bad Ruby code, this stylesheet will format the in-browser error message all pretty-like for you.
javascripts/error.js - In case you turn Ruby error reporting off, this file will still catch any errors and not cause your users to see some ugly alert box.
Running your newly created Ruby app
Enough talk, does this thing work? We'll, let's try ...$ cd photoviewer $ script/server /b:index.html(Note "/b" is the same on Windows/Mac, always a forward slash since its part of the argument and not the path)
This will start a Silverlight development web server called Chiron, and launch your default browser at index.html. Using the "/w" flag just starts the server and doesn't open your browser. Anyway, you should see this (in your default browser, of course):
Adding external libraries
Before we can start coding, this app depends on some external libraries, so let's put them in our project now. Since I'm such a good guy, I put all the external dependencies in that photoviewer-start.zip file I had you download already. Yep, I'm awesome, you're welcome. Anyway, take the contents of that zip and place it in your project. Your OS may complain about overwriting silverlight.rb, but it's ok, overwrite it. Did it? Good. So, what did you just add to your app?images/loading.gif - loading indicator gif thingy.
ruby/System.Json.dll - JSON parser that ships in the Silverlight SDK.
ruby/json.rb - requires and monkey patches System.Json to be more ruby-esk.
ruby/silverlight.rb - This app will depend on a more up-to-date version of silverlight.rb, so you can replace the generated one with this.
lightbox/ - A javascript library for showing images prettily. Love it.
Writing some freakin' code! UI, that is
As I hinted at before, this application with have a HTML UI with IronRuby driving it. Yes, you can use Silverlight without all the pretty graphics. ;) Anyway, to accomplish this, we need to make the Silverlight canvas virtually invisible. Open index.html and on line 28 change the width and height attributes from "100%" to "1":<object data="data:application/x-silverlight," type="application/x-silverlight-2-b2" width="1" height="1">
Now let's give our app a UI. Type this after the "</object>" tag at the bottom of index.html:
<div class="search"> <form id="search" action="javascript:void(0)"> <input type="text" id="keyword" /> <input type="submit" id="submit_search" value="search" /> <img src="images/loading.gif" id="images_loading" /> </form> <div id="search_results"> <div id="search_images"></div> <div id="search_links"></div> </div> </div> <div class="clear"></div>
Now let's style our plain UI we just made:#silverlightControlHost { height: 100%; }
body { font-family: "Trebuchet MS" Verdana sans-serif; border: 0px; padding: 0px; margin: 0px; } div.clear { clear: both; } /* main search box */ .search { padding: 20px; margin: 20px; border: 10px solid gray; background-color: #ccc; } form#search #images_loading { width: 18px; height: 15px; display: none; } /* search results */ #search_results { display: none; } /* search images */ #search_images { padding-top: 10px; } #search_images .image, .image a, .image a img { float: left; padding: 0px; margin: 0px; border: 0px; } #search_images .image a:link, #search_images .image a:visited { background-color: white; padding: 5px; margin: 5px; background-color: white; border: 1px solid gray; } #search_images .image a:hover { background-color: #ff9966; } /* search links */ #search_links { clear: both; padding-top: 10px; } #search_links a { border: 1px solid #003344; margin: 2px; padding: 0px 5px; color: #003344; background-color: white; text-decoration: none; } #search_links a:hover, #search_links a.active { color: white; background-color: #003344; border: 1px solid white; } #search_links a.active { cursor: default; }
Refresh your browser and you'll see this:
IronRuby loves the DOM
Awesome, now we have a UI, but it does nothing ... Ruby enters stage left. Open up ruby/app.rb and let's start hacking.The point of this app is to type a keyword, hit search, and see images from Flickr that have something to do with the keyword. So, I propose the first step would be to get Ruby handle that search button press. Agree? Good. Replace the entire body of the App class with the following:
def initialize document.submit_search.onclick do |s, e| puts "Search button pressed!" end end
Ruby loves Flickr
Now that we know how to hook a button click with Ruby, let's make it talk to Flickr and get the data about our search back. First off, we need to know what to say to Flickr. So, let's redefine initialize to do the following:def initialize @url = "http://api.flickr.com/services/rest" @options = { :method => "flickr.photos.search", :format => "json", :nojsoncallback => "1", :api_key => "6dba7971b2abf352b9dcd48a2e5a5921", :sort => "relevance", :per_page => "30" } document.submit_search.onclick do |s, e| create(document.keyword.value, 1) end end
def create(keyword, page) @options[:tags] = keyword @options[:page] = page request end def request make_url request = Net::WebClient.new request.download_string_completed do |sender, args| @response = args.result show end document.images_loading.style[:display] = "inline" request.download_string_async Uri.new(@url) end
First, it makes the URL from the @options (we'll define that method soon enough). Then it news-up an instance of System::Windows::Net::WebClient, part of Silverlight, to actually make the request to Flickr. The images_loading.style[:display] = "inline" causes the loading indicator to start spinning. When the response comes back, I call show, which presumable should show the results in some way. For now, we'll just print the response, and stop our loading indicator.
def show puts @response document.images_loading.style[:display] = "none" end
def make_url first, separator = true, '?' @options.each do |key, value| separator = "&" unless first @url += "#{separator}#{key}=#{value}" first = false end end
Let's make this gunk pretty
So, what to do with all this Flickr data ... hmmm ... thoughts? Somewhere in that muck are some pretty pictures, so let's squeeze them out ... aka parse the JSON. At the top of app.rb, add the following:require 'json'
require 'System.Json.dll' include System module System::Json class JsonValue def [](index) item = self.get_Item(index.to_clr_string) type = item.get_JsonType return item.to_string.to_s.to_f if type == JsonType.Number return item.to_string.to_s.split("\"").last if type == JsonType.String return System::Boolean.parse(item) if type == JsonType.Boolean item end def inspect to_string.to_s end end end
def show @flickr = System::Json::JsonValue.parse(@response) render end def render @render = Render.new(@flickr, @options[:tags], @options[:page]) document.search_images[:innerHTML] = @render.generate_photos document.search_links[:innerHTML] = @render.generate_pages @render.hook_page_events('search_links') document.images_loading.style[:display] = "none" document.search_results.style[:display] = "block" end
require 'render'Open render.rb and let's first tackle rendering the photos:
class Render def initialize(flickr, tags, current_page) @flickr = flickr @tags = tags @current_page = current_page end def generate_photos if @flickr['stat'] == "ok" && @flickr['photos']['total'].to_i > 0 tag(:div, :class => 'images') do @flickr['photos']['photo'].collect do |p| photo(p) end.join end else "No images found!" end end def photo(p) source = "http://farm#{p['farm'].to_i}.static.flickr.com/#{p['server']}/#{p['id']}_#{p['secret']}" thumb = "#{source}_s.jpg" img = "#{source}.jpg" tag(:div, :class => 'image') do tag(:a, :href => "#{img}") do tag(:img, :src => "#{thumb}") end end end def generate_pages "" end def hook_page_events(div) end private def tag(name, options, &block) output = "" output << "<#{name}" keyvalue = options.collect do |key, value| "#{key}=\"#{value}\"" end output << " #{keyvalue.join(" ")}" if keyvalue.size > 0 if block output << ">" output << yield output << "</#{name}>" else output << " />" end output end end
Paginating the Flickrnation
Flickr only sends you one page of your request. When we created the options for the Flickr request, there was a :per_page => "30" entry, saying that I want pages of 30 images ... duh. When we call create(document.keyword.value, 1) on the search button click, that second argument asks Flickr for the 1st page of the request. So, to get any page we want, you just give it to the create function, and it'll give is that page. Told you we'd need to reuse it!So, to implement generate_pages, we just need to render links for the pages, and to implement hook_page_events, we need to hook each one of those page links with an event that calls create for the given page. Simple enough ...
def generate_pages render = "" if @flickr['photos']['total'].to_i > 0 num_pages = @flickr['photos']['pages'].to_i > 10 ? 10 : @flickr['photos']['pages'].to_i num_pages.times { |i| render += page(i + 1) } if num_pages > 1 end render end def page(i) tag(:a, :href => 'javascript:void(0)', :id => "#{i}") { "#{i}" } end def hook_page_events(div) $app.document.get_element_by_id(div.to_s.to_clr_string).children.each do |child| if child.id.to_s.to_i == @current_page child.css_class = "active" else child.onclick { |s, args| $app.create(@tags, child.id.to_s.to_i) } end end end
Ruby, you play nice with Javascript, ya hear!
This is pretty awesome ... ya know, Ruby in the browser and all. But truth be told, I like Javascript too. Especially since there are a ton of Javascript libraries out there.That being said, I'd like to spruce up the "clicking on an image". Right now it opens in a completely new page, and breaks the back-button since your search is wiped away. It's be awesome to have some cool visual effect when clicking on the image, and even having it link to the real Flickr image page. Only if there was such a library ...
http://www.huddletogether.com/projects/lightbox2/
Like I said before, Lightbox is awesome. So, to pretty up zooming in on an image, we'll use Lightbox. First, let's go back to index.html and add some references to Lightbox:
<script type="text/javascript" src="lightbox/js/prototype.js"></script> <script type="text/javascript" src="lightbox/js/scriptaculous.js?load=effects"></script> <script type="text/javascript" src="lightbox/js/lightbox.js"></script> <link rel="stylesheet" href="lightbox/css/lightbox.css" type="text/css" media="screen" />
So, the entire tag(:a) call turns into this:
tag(:a, { :href => "#{img}", :title => "<a href="http://www.flickr.com/photos/#{p['owner']}/#{p['id']}" target="_blank">#{p['title']}</a>", :rel => "lightbox[#{@tags}]" }) do tag(:img, :src => "#{thumb}") end
if document.overlay && document.lightbox document.overlay.parent.remove_child document.overlay document.lightbox.parent.remove_child document.lightbox end HtmlPage.window.eval("initLightbox()")