5 # A.k.a 'Best web-album Of the world, Or your money back, Humerus'.
7 # The acronyn sucks, however this is a tribute to Dragon Ball by
8 # Akira Toriyama, where the last enemy beaten by heroes of Dragon
9 # Ball is named "Boo". But there was already a free software project
10 # called Boo, so this one will be it "Booh". Or whatever.
13 # Copyright (c) 2004-2006 Guillaume Cottenceau <http://zarb.org/~gc/resource/gc_mail.png>
15 # This software may be freely redistributed under the terms of the GNU
16 # public license version 2.
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
30 bindtextdomain("booh")
32 require 'rexml/document'
35 require 'booh/booh-lib'
37 require 'booh/UndoHandler'
42 [ '--help', '-h', GetoptLong::NO_ARGUMENT, _("Get help message") ],
44 [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
48 puts _("Usage: %s [OPTION]...") % File.basename($0)
50 printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
55 parser = GetoptLong.new
56 parser.set_options(*$options.collect { |ary| ary[0..2] })
58 parser.each_option do |name, arg|
64 when '--verbose-level'
65 $verbose_level = arg.to_i
78 meminfo = IO.readlines('/proc/meminfo').join
79 meminfo =~ /MemFree:.*?(\d+)/ or return -1
81 meminfo =~ /Buffers:.*?(\d+)/ and buffers = $1
82 meminfo =~ /Cached:.*?(\d+)/ and cached = $1
83 return memfree.to_i + buffers.to_i + cached.to_i
88 $config_file = File.expand_path('~/.booh-classifier-rc')
89 if File.readable?($config_file)
90 $xmldoc = REXML::Document.new(File.new($config_file))
91 $xmldoc.root.elements.each { |element|
92 txt = element.get_text
95 $config[element.name] = txt.value.split(/~~~/)
97 $config[element.name] = txt.value
99 elsif element.elements.size == 0
100 $config[element.name] = ''
102 $config[element.name] = {}
103 element.each { |chld|
105 $config[element.name][chld.name] = txt ? txt.value : nil
110 $config['video-viewer'] ||= '/usr/bin/mplayer %f'
111 $config['display-mode'] ||= 'one-image'
112 $config['cache-memory-use'] ||= 'memfree_50%'
113 if $config['cache-memory-use'] =~ /memfree_(\d+)/
114 $config['cache-memory-use-figure'] = memfree*$1.to_f/100
116 $config['cache-memory-use-figure'] = $config['cache-memory-use'].to_i
118 msg 2, "Set cache memory use figure: #{$config['cache-memory-use-figure']} kB"
122 if !system("which identify >/dev/null 2>/dev/null")
123 show_popup($main_window, utf8(_("The program 'identify' is needed to get images sizes and EXIF data. Please install it.
124 It is generally available with the 'ImageMagick' software package.")), { :pos_centered => true })
126 missing = %w(transcode mencoder).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
128 show_popup($main_window, utf8(_("The following program(s) are needed to handle videos: '%s'. Videos will be ignored.") % missing.join(', ')), { :pos_centered => true })
131 viewer_binary = $config['video-viewer'].split.first
132 if viewer_binary && !File.executable?(viewer_binary)
133 show_popup($main_window, utf8(_("The configured video viewer seems to be unavailable.
134 You should fix this in Edit/Preferences so that you can view videos.
136 Problem was: '%s' is not an executable file.
137 Hint: don't forget to specify the full path to the executable,
138 e.g. '/usr/bin/mplayer' is correct but 'mplayer' only is not.") % viewer_binary), { :pos_centered => true, :not_transient => true })
143 ios = File.open($config_file, "w")
144 $xmldoc = Document.new "<booh-classifier-rc version='#{$VERSION}'/>"
145 $xmldoc << XMLDecl.new(XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET)
146 $config.each_pair { |key, value|
147 elem = $xmldoc.root.add_element key
149 $config[key].each_pair { |subkey, subvalue|
150 subelem = elem.add_element subkey
151 subelem.add_text subvalue.to_s
153 elsif value.is_a? Array
154 elem.add_text value.join('~~~')
159 elem.add_text value.to_s
163 $xmldoc.write(ios, 0)
167 def save_undo(name, closure, *params)
168 UndoHandler.save_undo(name, closure, [ *params ])
169 $undo_tb.sensitive = $undo_mb.sensitive = true
170 $redo_tb.sensitive = $redo_mb.sensitive = false
174 IO.readlines('/proc/self/status').join =~ /VmRSS.*?(\d+)\s*kB/
180 txt.length > 0 and print txt[0]
185 @@thumbnails_height = 64
187 def Entry.thumbnails_height
188 return @@thumbnails_height
191 attr_accessor :path, :type, :button
193 def initialize(path, type)
197 @@max_height = $main_window.root_window.size[1]
199 @protect_cleanup = Mutex.new
203 @protect_cleanup.synchronize {
205 puts ">>> pixbuf_full #{path}"
206 load_into_pixbuf_full
212 @protect_cleanup.synchronize {
216 puts ">>> free_pixbuf_full #{path}"
222 def pixbuf_main(width, height)
223 @protect_cleanup.synchronize {
224 if @pixbuf_main.nil? || width != @width || height != @height
225 puts ">>> pixbuf_main #{path}"
228 load_into_pixbuf_full #- make sure it is loaded
229 if @pixbuf_full.width.to_f / @pixbuf_full.height > width.to_f / height
230 resized_height = @pixbuf_full.height * (width.to_f/@pixbuf_full.width)
231 if @pixbuf_full.width > width || @pixbuf_full.height > resized_height
232 @pixbuf_main = @pixbuf_full.scale(width, resized_height, Gdk::Pixbuf::INTERP_BILINEAR)
234 @pixbuf_main = @pixbuf_full
237 resized_width = @pixbuf_full.width * (height.to_f/@pixbuf_full.height)
238 if @pixbuf_full.width > resized_width || @pixbuf_full.height > height
239 @pixbuf_main = @pixbuf_full.scale(resized_width, height, Gdk::Pixbuf::INTERP_BILINEAR)
241 @pixbuf_main = @pixbuf_full
249 @protect_cleanup.synchronize {
253 puts ">>> free_pixbuf_main #{path}"
260 return @protect_cleanup.synchronize {
261 if @pixbuf_thumbnail.nil?
262 puts ">>> pixbuf_thumbnail #{path}"
263 load_into_pixbuf_full #- make sure it is loaded
264 @pixbuf_thumbnail = @pixbuf_full.scale(@pixbuf_full.width * (@@thumbnails_height.to_f/@pixbuf_full.height), @@thumbnails_height, Gdk::Pixbuf::INTERP_BILINEAR)
266 return @pixbuf_thumbnail
271 def load_into_pixbuf_full
273 puts ">>> load_into_pixbuf_full #{path}"
275 @pixbuf_full = Gdk::Pixbuf.new(@path)
276 rescue Gdk::PixbufError
277 puts "Cannot load #{@path}: #{$!}"
281 angle = guess_rotate(path)
283 puts ">>> load_into_pixbuf_full #{path} => rotate #{angle}"
284 @pixbuf_full = rotate_pixbuf(@pixbuf_full, angle)
286 if @pixbuf_full.height > @@max_height
287 #- save a lot of memory, don't store in actual full size
288 @pixbuf_full = @pixbuf_full.scale(@pixbuf_full.width * (@@max_height.to_f/@pixbuf_full.height), @@max_height, Gdk::Pixbuf::INTERP_BILINEAR)
299 class MainView < Gtk::DrawingArea
303 signal_connect('expose-event') { draw }
304 signal_connect('configure-event') { update_shown; GC.start }
305 signal_connect('realize') {
306 @preloader = Thread.new {
307 #- background preloading
309 puts "background main preloading triggered..."
314 if i < $allentries.size
315 $allentries[i].pixbuf_main(w, h)
319 $allentries[i].pixbuf_main(w, h)
323 if mem > $config['cache-memory-use-figure']
324 puts "too much RSS, stopping main preloading"
328 check_memory_free_cache_if_needed
333 @preloader.priority = -2
337 def set_shown_entry(index)
345 @entry = $allentries[index]
348 #- should "freeze" or something to prevent blinking
360 width, height = window.size
361 @pixbuf = @entry.pixbuf_main(width, height)
362 if @pixbuf.width == width
364 @ypos = (height-@pixbuf.height)/2
366 @xpos = (width-@pixbuf.width)/2
377 window.draw_pixbuf(nil, @pixbuf, 0, 0, @xpos, @ypos, -1, -1, Gdk::RGB::DITHER_NONE, -1, -1)
383 def autoscroll_if_needed(img)
384 xpos_left = img.window.position[0]
385 xpos_right = img.window.position[0] + img.window.size[0]
386 current_minx_visible = $imagesline_sw.hadjustment.value
387 current_maxx_visible = $imagesline_sw.hadjustment.value + $imagesline_sw.hadjustment.page_size
388 # puts "xpos left: #{xpos_left}"
389 # puts "xpos right: #{xpos_right}"
390 # puts "current minx visible: #{current_minx_visible}"
391 # puts "current maxx visible: #{current_maxx_visible}"
392 if xpos_left < current_minx_visible
393 puts 'scroll_upper(scrolledwindow, ypos_top)'
394 elsif xpos_right > current_maxx_visible
395 puts 'scroll_lower(scrolledwindow, ypos_bottom)'
399 def check_memory_free_cache_if_needed
402 i = $mainview.get_shown_entry
403 puts "mem: #{mem} index: #{i}"
405 ($allentries.size - 1).downto(1) { |j|
406 if mem < $config['cache-memory-use-figure'] * 2 / 3
410 puts "too much RSS, freeing full size of #{i+j} and #{i-j}..."
411 freedsomething = false
412 if i + j < $allentries.size
413 freedsomething |= $allentries[i+j].free_pixbuf_full
416 freedsomething |= $allentries[i-j].free_pixbuf_full
421 puts "\tmem now: #{mem}"
430 ctid = $statusbar.get_context_id('images loading')
431 $statusbar.push(ctid, utf8(_("Loading images...")))
432 tooltips = Gtk::Tooltips.new
433 $allentries.each_with_index { |entry, i|
435 entry.pixbuf_thumbnail
436 gtk_thread_protect(proc { |i|
437 entry = $allentries[i]
438 entry.button = Gtk::Button.new.set_image(img = Gtk::Image.new(entry.pixbuf_thumbnail)).show_all
439 tooltips.set_tip(entry.button, File.basename(entry.path).gsub(/\.[^.]+$/, ''), nil)
440 $imagesline.pack_start(entry.button, false, false)
441 entry.button.signal_connect('clicked') { $mainview.set_shown_entry(i) }
442 entry.button.signal_connect('focus-in-event') { entry.button.clicked; autoscroll_if_needed(img) }
444 entry.button.grab_focus
448 check_memory_free_cache_if_needed
452 check_memory_free_cache_if_needed
454 puts "time: #{Time.now - t1}"
460 path = File.expand_path(path.sub(%r|/$|, ''))
461 examined_dirs = `find '#{path}' -type d -follow`.sort.collect { |v| v.chomp }
463 examined_dirs.each { |dir|
465 show_popup($main_window, utf8(_("Source directory or sub-directories can't contain a single-quote character, sorry: %s") % dir))
467 Dir.entries(dir).each { |file|
468 if file =~ /['"\[\]]/
469 show_popup($main_window, utf8(_("Files can't contain any of the characters ', \", [ or ], sorry: %s") % "#{dir}/#{file}"))
473 #- scan for populate second
475 examined_dirs.each { |dir|
476 if File.basename(dir) =~ /^\./
477 msg 1, _("Ignoring directory %s, begins with a dot (indicating a hidden directory)") % dir
480 Dir.entries(dir).each { |file|
481 type = entry2type(file)
483 #&& $allentries.size < 8
484 $allentries << Entry.new(File.join(dir, file), type)
492 fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory to work with")),
494 Gtk::FileChooser::ACTION_SELECT_FOLDER,
496 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
497 fc.transient_for = $main_window
500 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
501 msg = open_dir(fc.filename)
515 def gtk_thread_protect(proc, *params)
516 if Thread.current == Thread.main
519 $protect_gtk_pending_calls.synchronize {
520 $gtk_pending_calls << [ proc, params ]
526 $protect_gtk_pending_calls.synchronize {
527 if $gtk_pending_calls.size > 0
528 elem = $gtk_pending_calls.shift
529 elem[0].call(*elem[1])
534 def try_quit(*options)
541 mb = Gtk::MenuBar.new
543 filemenu = Gtk::MenuItem.new(utf8(_("_File")))
544 filesubmenu = Gtk::Menu.new
545 filesubmenu.append(open = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
546 filesubmenu.append( Gtk::SeparatorMenuItem.new)
547 filesubmenu.append($execute = Gtk::ImageMenuItem.new(Gtk::Stock::EXECUTE).set_sensitive(false))
548 filesubmenu.append( Gtk::SeparatorMenuItem.new)
549 filesubmenu.append(quit = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
550 filemenu.set_submenu(filesubmenu)
553 open.signal_connect('activate') { open_dir_popup }
554 $execute.signal_connect('activate') { execute }
555 quit.signal_connect('activate') { try_quit }
557 editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
558 editsubmenu = Gtk::Menu.new
559 editsubmenu.append($undo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
560 editsubmenu.append($redo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
561 editsubmenu.append( Gtk::SeparatorMenuItem.new)
562 editsubmenu.append(prefs = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
563 editmenu.set_submenu(editsubmenu)
566 $undo_mb.signal_connect('activate') { perform_undo }
567 $redo_mb.signal_connect('activate') { perform_redo }
568 prefs.signal_connect('activate') { preferences }
570 helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
571 helpsubmenu = Gtk::Menu.new
572 helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
573 tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
574 helpsubmenu.append(Gtk::SeparatorMenuItem.new)
575 helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
576 helpmenu.set_submenu(helpsubmenu)
579 tutos.signal_connect('activate') { open_url('http://booh.org/tutorial.html') }
580 about.signal_connect('activate') { share }
583 #- no toolbar, to save height
588 def create_main_window
592 main_vbox = Gtk::VBox.new(false, 0)
593 main_vbox.pack_start(mb, false, false)
594 main_vbox.pack_start($mainview = MainView.new, true, true)
595 $imagesline_sw = Gtk::ScrolledWindow.new(nil, nil)
596 $imagesline_sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_NEVER)
597 $imagesline_sw.add_with_viewport($imagesline = Gtk::HBox.new(false, 0))
598 main_vbox.pack_start($imagesline_sw, false, false)
599 main_vbox.pack_end($statusbar = Gtk::Statusbar.new, false, false)
601 $imagesline.set_size_request(-1, Entry.thumbnails_height)
603 $main_window = Gtk::Window.new
604 $main_window.add(main_vbox)
605 $main_window.signal_connect('delete-event') {
606 try_quit({ :disallow_cancel => true })
609 #- read/save size and position of window
610 if $config['pos-x'] && $config['pos-y']
611 $main_window.move($config['pos-x'].to_i, $config['pos-y'].to_i)
613 $main_window.window_position = Gtk::Window::POS_CENTER
615 msg 3, "size: #{$config['width']}x#{$config['height']}"
616 $main_window.set_default_size(($config['width'] || 600).to_i, ($config['height'] || 400).to_i)
617 $main_window.signal_connect('configure-event') {
618 msg 3, "configure: pos: #{$main_window.window.root_origin.inspect} size: #{$main_window.window.size.inspect}"
619 x, y = $main_window.window.root_origin
620 width, height = $main_window.window.size
623 $config['width'] = width
624 $config['height'] = height
628 $protect_gtk_pending_calls = Mutex.new
629 $gtk_pending_calls = []
630 Gtk.timeout_add(50) {
635 Gtk.timeout_add(10000) {
640 $statusbar.push(0, utf8(_("Ready.")))
641 $main_window.show_all
645 Thread.abort_on_exception = true
650 #- Gdk::Pixbuf#rotate memory leak check (in ruby-gnome2 <= 0.16.0)
651 pb = Gdk::Pixbuf.new("#{$FPATH}/images/logo.png")
652 1.upto(5) { pb = pb.rotate(Gdk::Pixbuf::ROTATE_CLOCKWISE) }
655 1.upto(5) { pb = pb.rotate(Gdk::Pixbuf::ROTATE_CLOCKWISE) }
659 puts _("Gdk::Pixbuf#scale memory leak detected (this is normal with unpatched ruby-gnome2 <= 0.16.0). Application would slow down to a crawl, won't proceed.")