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