Setup for Alexandria Development: III

The following code requires Alexandria trunk. For more information see Cathal’s article. You can get this file here or as a full gem source package through svn:

svn co http://alexandria.rubyforge.org/svn/alexandria/trunk/readinglist .

$:.unshift File.dirname(__FILE__)

=begin

This is a sample (but useable) app for maintaining a reading list that takes
its reading options directly from Alexandria. At the moment it allows you to
move books to the reading list, remove them, mark them as read, and reorder
them. It's commented so as to be a kind of tutorial. The reader is encouraged
to play with it and try to add functionality. 

This code demonstrates some of the most common operations in a Gtk app:
creating menus, hooking up a treeview and syncing it to data, and widget
packing. Of these, working with the TreeView will probably be the most
confusing. The basic concept behind treeviews is that a treeview is the
graphical widget that administers a "store" or short-term database (usually
either a ListStore or a TreeStore) which in turn is concerned with managing
either a list-like or tree-like structure of TreeIters. In the case of a
ListStore, a TreeIter represents a row that you see in the TreeView, and the
indices of the TreeIter (like iter[0], etc.) return the values for the columns
in that row. One thing to know about GTK Iter objects is that they like to get
"invalidated" whenever the managing View changes, so for example if you get the
iter for the user's selection of the third row of a TreeView and store it for
later, you'll find that the iter itself (as opposed to its TreePath, or
"absolute" location) is no longer available. Clear? It's definitely recommended
that you take a look at this tutorial:

http://ruby-gnome2.sourceforge.jp/hiki.cgi?tut-treeview

=end

module Readinglist
  require 'yaml'
  require 'alexandria'
  require 'gtk2'

  class ReadingListApp
    FILE_FORMAT = {:to_read => [], :have_read => []}
    READING_LIST_FILE = File.join(ENV["HOME"], ".reading_list.yml" )

    # I try to decompose methods as much as possible to show the procedural
    # skeleton of the program. I name the methods to read like sentences. 

    def initialize
      load_reading_list
      get_books
      setup_gui
      load_books_into_listview
    end

    # We use YAML as the "serialization" strategy. That is, we make sure a hash
    # is stored in a file in the user's home directory. We load it into memory,
    # manipulate it, and always make sure to sync it back to the file. This is
    # exactly how books are loaded in Alexandria. Alexandria even stores the
    # class instance to the file. 

    def load_reading_list
      unless File.exist?(READING_LIST_FILE)
        File.open(READING_LIST_FILE, 'w') do |file|
          file.write(FILE_FORMAT.to_yaml)
        end
      end
      database = YAML.load_file(File.join(ENV["HOME"], ".reading_list.yml" ))
      @reading_list = database[:to_read]
      @have_read = database[:have_read]
      puts @reading_list.inspect
    end

    # I have to make sure the listview and the data store are in sync, and I
    # want to make sure that when the program finishes the latest changes are
    # committed to file. This could get too expensive with enough books,
    # though. In Alexandria, some changes happen immediately (changes in book
    # attributes, covers) while others only occur on a clean program shutdown
    # (deleting, saving certain preferences).

    def save_to_yaml
      database = FILE_FORMAT
      database[:to_read] = @reading_list 
      database[:have_read] = @have_read 
      File.open(READING_LIST_FILE, 'w') do |file|
        file.write(database.to_yaml)
      end
    end

    # I initialize the Libraries simpleton (available through the above
    # require) and ask it to reload its list of libaries. Then the loadall class
    # method on Library gives me an array of arrays of books, which I then
    # flatten; that is, make into one long array. 

    def get_books
      libraries_simpleton = Alexandria::Libraries.instance
      libraries_simpleton.reload
      libraries = Alexandria::Library.loadall
      @books = libraries.flatten
    end

    # More declarative-style methods for erecting the GUI. This type of code
    # ends up being very mechanical to write, which is why people like to use
    # tools like Glade. In fact, keeping this gui layout code in the open is
    # probably a better idea for long-term maintenance. 

    def setup_gui
      setup_window
      setup_menus
      setup_current_reading_list
      setup_available_books_list 
      refresh_reading_list
      Alexandria::UI::Icons.init
      setup_callbacks
      @window.show_all # This needs to be called after widgets are packed. 
    end

    # For information on packing, see
    # http://ruby-gnome2.sourceforge.jp/hiki.cgi?tut-gtk2-packing-intro . Here
    # @window, which can only contain one child widget, gets a VBox. I'll put
    # several widgets inside @vbox, including more "container" widgets (like
    # HBox), in which I put yet more widgets.

    def setup_window
      @window = Gtk::Window.new("Reading List")
      @window.set_width_request(800)
      @window.set_height_request(600)
      @window.add(@vbox = Gtk::VBox.new)
    end

    # For information on callbacks in Gtk see
    # http://ruby-gnome2.sourceforge.jp/hiki.cgi?tut-gtk-signals . Ruby-gnome2
    # callbacks use the Ruby do/end block syntax. Since I don't like to define
    # the callback method inside the block, I use the method() function to turn
    # the method into a proc, and use the & syntax for passing in a "proc"
    # object to the block.

    def setup_callbacks
      @available_treeview.signal_connect("row-activated", &method(:on_row_activated))
      @window.signal_connect("delete-event", &method(:on_quit))      
    end

    # Pretty straight-forward, if wordy. The ImageMenuItems can use a
    # Gtk::Stock:: constant to save some work and get pretty icons. :expand =>
    # false is used to keep widgets from bulging out.

    def setup_menus
      @vbox.add(menubar = Gtk::MenuBar.new, :expand => false)
      menubar.append(file_menu = Gtk::MenuItem.new("_File"))
      menubar.append(edit_menu = Gtk::MenuItem.new("_Edit"))
      menubar.append(help_menu = Gtk::MenuItem.new("_Help"))
      file_menu.submenu = file_submenu = Gtk::Menu.new 
      edit_menu.submenu = edit_submenu = Gtk::Menu.new 
      help_menu.submenu = help_submenu = Gtk::Menu.new 
      file_submenu.add(Gtk::ImageMenuItem.new(Gtk::Stock::SAVE_AS))
      file_submenu.add(quit_item = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
      quit_item.signal_connect("activate", &method(:on_quit))
      edit_submenu.add(Gtk::MenuItem.new("Mark selected _read"))
      help_submenu.add(about_submenu = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
    end

    # To setup the "current reading list" on top. The TreeView is connected
    # directly to the ListStore, but the TreeViewColumns and the CellRenderer*s
    # connected to them are responsible for telling the TreeView _how_ to
    # display the data within the ListStore. The argument :text => n is a
    # shorthand to tell the TreeViewColumn to associate with a position or
    # index in the TreeIter (row).

    def setup_current_reading_list
      @vbox.add(Gtk::Label.new("Books I am reading:"), :expand => false)
      @vbox.add(hbox = Gtk::HBox.new)
      hbox.add(scrolley1 = Gtk::ScrolledWindow.new)
      hbox.add(@button_vbox = Gtk::VButtonBox.new, :expand => false)
      setup_side_buttons
      scrolley1.add(@reading_treeview = Gtk::TreeView.new)
      @reading_treeview.model = @reading_store = Gtk::ListStore.new(Integer, String, String)
      renderer = Gtk::CellRendererText.new 
      reading_column1 = Gtk::TreeViewColumn.new("Order", renderer, :text => 0)
      @reading_treeview.append_column(reading_column1)
      reading_column2 = Gtk::TreeViewColumn.new("Title", renderer, :text => 1)
      @reading_treeview.append_column(reading_column2)
      reading_column3 = Gtk::TreeViewColumn.new("Author", renderer, :text => 2)
      @reading_treeview.append_column(reading_column3)
    end

    def setup_side_buttons
      @button_vbox.layout_style = Gtk::VButtonBox::START 
      @button_vbox.add(up_button = Gtk::Button.new(Gtk::Stock::GO_UP))
      @button_vbox.add(down_button = Gtk::Button.new(Gtk::Stock::GO_DOWN))
      @button_vbox.add(read_button = Gtk::Button.new("Read"))
      @button_vbox.add(remove_button = Gtk::Button.new(Gtk::Stock::REMOVE))
      up_button.signal_connect("clicked", &method(:on_click_up))
      down_button.signal_connect("clicked", &method(:on_click_down))
      remove_button.signal_connect("clicked", &method(:on_click_remove))
      read_button.signal_connect("clicked", &method(:on_click_read))
    end

    # Same as above, except with the added wrinkle that a TreeModelSort, using
    # a TreeModelFilter, is acting as a kind of proxy for the ListStore. This
    # is to support sorting of columns. This code is ripped off wholesale from
    # Alexandria.  

    def setup_available_books_list
      @vbox.add(Gtk::Label.new("Available books:"), :expand => false)
      @vbox.add(scrolley2 = Gtk::ScrolledWindow.new)
      scrolley2.add(@available_treeview = Gtk::TreeView.new)

      @list_store = Gtk::ListStore.new(Gdk::Pixbuf, String, String)
      @filter = Gtk::TreeModelFilter.new(@list_store)
      @available_treeview.model = @available_books_model = Gtk::TreeModelSort.new(@filter)

      setup_available_books_title_column

      renderer = Gtk::CellRendererText.new
      column2 = Gtk::TreeViewColumn.new("Author", renderer, :text => 2)
      column2.resizable = true
      column2.sort_column_id = 2
      @available_treeview.append_column(column2)
    end

    # This TreeViewColumn has two widgets, a CellRendererPixbuf for the book's
    # icon, and a regular CellRendererText for the book's Title. I have to tell
    # the CellRendererPixBuf how to display itself int the set_cell_data_func.
    # The convert_iter_to_child_iter is some kind of bookkeeping for the
    # TreeModelFilter.

    def setup_available_books_title_column
      column = Gtk::TreeViewColumn.new("Title")

      renderer = Gtk::CellRendererPixbuf.new 
      column.sizing = Gtk::TreeViewColumn::FIXED
      column.fixed_width = 200 
      column.widget = Gtk::Label.new("Title").show
      column.pack_start(renderer, expand = false)
      column.set_cell_data_func(renderer) do |column, cell, model, iter|
        iter = @available_treeview.model.convert_iter_to_child_iter(iter)
        iter = @filter.convert_iter_to_child_iter(iter)
        cell.pixbuf = iter[0]
      end

      renderer = Gtk::CellRendererText.new
      renderer.ellipsize = Pango::ELLIPSIZE_END if Pango.ellipsizable?
      column.pack_start(renderer, expand = true)
      column.add_attribute(renderer, :text, 1)
      column.sort_column_id = 1
      column.resizable = true

      @available_treeview.append_column(column)
    end

    # This supplies the actual data to @available_treeview. Icons.cover creates
    # a Gdk::PixBuf (image object) from cover files stored in the .alexandria
    # directory.

    def load_books_into_listview
      @books.each do |book|
        icon = Alexandria::UI::Icons.cover(book.library, book)
        icon = icon.scale(20,25) 
        iter= @list_store.append
        iter[0] = icon 
        iter[1] = book.title
        iter[2] = book.authors.join(" ")
      end
    end

    # Call this when you want to repopulate and reorder the reading_list. 

    def refresh_reading_list 
      @count = 0
      @reading_store.clear
      @reading_list.each do |item| 
        iter = @reading_store.append # Gets a new iter (row) to work with.
        iter[0] = @count += 1
        iter[1] = item[0]
        iter[2] = item[1]
      end
    end

    # @reading_treeview.selection.selected is the iter of the selected row. 

    def on_click_remove widget
      selection = @reading_treeview.selection.selected
      @reading_list.delete_at(selection[0].to_i - 1)
      save_to_yaml
      refresh_reading_list
    end

    def on_click_read widget
      selection = @reading_treeview.selection.selected
      @have_read << @reading_list.delete_at(selection[0].to_i - 1)
      save_to_yaml
      refresh_reading_list
    end

    # The idea is to swap the iters in the TreeView and mirror the swap in the
    # reading list. The iter's path is like its current map coordinates. Since
    # iters are always getting invalidated, it's a good plan to work with the
    # path. 

    def on_click_up widget
      selection = @reading_treeview.selection.selected
      position = selection[0].to_i - 1
      previous_path = selection.path
      previous_path.prev!
      previous = @reading_store.get_iter(previous_path)
      @reading_store.move_before(selection, previous)
      unless (position - 1) < 0
        @reading_list.insert(position - 1, @reading_list.delete_at(position))
        refresh_reading_list
      end
      @reading_treeview.selection.select_path(previous_path)
    end

    # on_click_up and on_click_down call for refactoring to reduce duplicated
    # code. Try it for yourself if you're interested.  

    def on_click_down widget
      selection = @reading_treeview.selection.selected
      position = selection[0].to_i - 1
      previous_path = selection.path
      after_path = selection.path 
      after_path.next!
      after = @reading_store.get_iter(after_path) 
      unless (position + 1) == @reading_list.length
        @reading_store.move_after(selection, after)
        @reading_list.insert(position + 1, @reading_list.delete_at(position))
        refresh_reading_list
        @reading_treeview.selection.select_path(after_path)
      end
    end

    # This is the callback for when a row in the lower available books listview
    # gets clicked.

    def on_row_activated widget, path, column
      iter = @available_treeview.model.get_iter(path)
      puts "#{iter[0]} #{iter[1]} #{iter[2]}"
      reading_list_item = []
      reading_iter = @reading_store.append 
      reading_iter[0] = @count += 1
      reading_iter[1] = iter[1]
      reading_list_item << iter[1]
      reading_iter[2] = iter[2]
      reading_list_item << iter[2]
      @reading_list << reading_list_item 
      save_to_yaml
    end

    # This method is called when the window is closed and when the quit menu
    # option is activated. Event is used for when the window connects to the
    # 'delete-event' signal and requires both parameters. Gtk.main_quit kills
    # the Gtk.main loop. 

    def on_quit widget, event = nil
      Gtk.main_quit
    end
  end

  def self.main
    ReadingListApp.new
    Gtk.main
  end
end

=begin

Some features that could be added: 

* connect up menu items 
* figure out how save to... works; does it export to one specific format or
  several?  
* Not a feature, but can the code be made cleaner, cleaner, more testable? 
* Something indescribably awesome...

A couple random tips: 

install ruby-debug gem to step through this code to see how it works
See http://cheat.errtheblog.com/s/rdebug/ for more information

install the utility_belt gem for colorized irb and add  

require 'rubygems'
require 'utility_belt'

to a file ~/.irbrc

=end

if __FILE__ == $0
  Readinglist.main
end

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s