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['preload-distance'] ||= 5
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..."
312 for j in 1 .. $config['preload-distance']
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 check_memory_free_cache_if_needed
386 i = $mainview.get_shown_entry
387 puts "mem: #{mem} index: #{i}"
389 ($allentries.size - 1).downto(1) { |j|
390 if mem < $config['cache-memory-use-figure'] * 2 / 3
394 puts "too much RSS, freeing full size of #{i+j} and #{i-j}..."
395 freedsomething = false
396 if i + j < $allentries.size
397 freedsomething |= $allentries[i+j].free_pixbuf_full
400 freedsomething |= $allentries[i-j].free_pixbuf_full
405 puts "\tmem now: #{mem}"
410 def autoscroll_if_needed(button)
411 xpos_left = button.allocation.x
412 xpos_right = button.allocation.x + button.allocation.width
413 hadj = $imagesline_sw.hadjustment
414 current_minx_visible = hadj.value
415 current_maxx_visible = hadj.value + hadj.page_size
416 if xpos_left < current_minx_visible
418 newval = hadj.value - (current_minx_visible - xpos_left)
420 button.queue_draw #- TOREMOVE: the visual focus is displayed incorrectly
421 elsif xpos_right > current_maxx_visible
423 newval = hadj.value + (xpos_right - current_maxx_visible)
424 if newval > hadj.upper - hadj.page_size
425 newval = hadj.upper - hadj.page_size
428 button.queue_draw #- TOREMOVE: the visual focus is displayed incorrectly
436 ctid = $statusbar.get_context_id('images loading')
437 $statusbar.push(ctid, utf8(_("Loading images...")))
438 tooltips = Gtk::Tooltips.new
440 $allentries.each_with_index { |entry, i|
442 entry.pixbuf_thumbnail
443 gtk_thread_protect(proc { |i|
444 entry = $allentries[i]
445 entry.button = Gtk::Button.new.set_image(img = Gtk::Image.new(entry.pixbuf_thumbnail))
446 tooltips.set_tip(entry.button, File.basename(entry.path).gsub(/\.[^.]+$/, ''), nil)
447 $imagesline.pack_start(entry.button.show_all, false, false)
448 entry.button.signal_connect('clicked') { $mainview.set_shown_entry(i) }
449 entry.button.signal_connect('focus-in-event') { entry.button.clicked; autoscroll_if_needed(entry.button) }
451 entry.button.grab_focus
455 check_memory_free_cache_if_needed
460 check_memory_free_cache_if_needed
462 $statusbar.push(0, utf8(_("%d images loaded.") % counter))
463 puts "time: #{Time.now - t1}"
469 path = File.expand_path(path.sub(%r|/$|, ''))
470 examined_dirs = `find '#{path}' -type d -follow`.sort.collect { |v| v.chomp }
472 examined_dirs.each { |dir|
474 show_popup($main_window, utf8(_("Source directory or sub-directories can't contain a single-quote character, sorry: %s") % dir))
476 Dir.entries(dir).each { |file|
477 if file =~ /['"\[\]]/
478 show_popup($main_window, utf8(_("Files can't contain any of the characters ', \", [ or ], sorry: %s") % "#{dir}/#{file}"))
482 #- scan for populate second
484 examined_dirs.each { |dir|
485 if File.basename(dir) =~ /^\./
486 msg 1, _("Ignoring directory %s, begins with a dot (indicating a hidden directory)") % dir
489 Dir.entries(dir).each { |file|
490 type = entry2type(file)
492 # && $allentries.size < 5
493 $allentries << Entry.new(File.join(dir, file), type)
501 fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory to work with")),
503 Gtk::FileChooser::ACTION_SELECT_FOLDER,
505 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
506 fc.transient_for = $main_window
509 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
510 msg = open_dir(fc.filename)
524 def gtk_thread_protect(proc, *params)
525 if Thread.current == Thread.main
528 $protect_gtk_pending_calls.synchronize {
529 $gtk_pending_calls << [ proc, params ]
535 $protect_gtk_pending_calls.synchronize {
536 if $gtk_pending_calls.size > 0
537 elem = $gtk_pending_calls.shift
538 elem[0].call(*elem[1])
543 def try_quit(*options)
550 mb = Gtk::MenuBar.new
552 filemenu = Gtk::MenuItem.new(utf8(_("_File")))
553 filesubmenu = Gtk::Menu.new
554 filesubmenu.append(open = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
555 filesubmenu.append( Gtk::SeparatorMenuItem.new)
556 filesubmenu.append($execute = Gtk::ImageMenuItem.new(Gtk::Stock::EXECUTE).set_sensitive(false))
557 filesubmenu.append( Gtk::SeparatorMenuItem.new)
558 filesubmenu.append(quit = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
559 filemenu.set_submenu(filesubmenu)
562 open.signal_connect('activate') { open_dir_popup }
563 $execute.signal_connect('activate') { execute }
564 quit.signal_connect('activate') { try_quit }
566 editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
567 editsubmenu = Gtk::Menu.new
568 editsubmenu.append($undo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
569 editsubmenu.append($redo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
570 editsubmenu.append( Gtk::SeparatorMenuItem.new)
571 editsubmenu.append(prefs = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
572 editmenu.set_submenu(editsubmenu)
575 $undo_mb.signal_connect('activate') { perform_undo }
576 $redo_mb.signal_connect('activate') { perform_redo }
577 prefs.signal_connect('activate') { preferences }
579 helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
580 helpsubmenu = Gtk::Menu.new
581 helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
582 tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
583 helpsubmenu.append(Gtk::SeparatorMenuItem.new)
584 helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
585 helpmenu.set_submenu(helpsubmenu)
588 tutos.signal_connect('activate') { open_url('http://booh.org/tutorial.html') }
589 about.signal_connect('activate') { share }
592 #- no toolbar, to save height
597 def create_main_window
601 main_vbox = Gtk::VBox.new(false, 0)
602 main_vbox.pack_start(mb, false, false)
603 main_vbox.pack_start($mainview = MainView.new, true, true)
604 $imagesline_sw = Gtk::ScrolledWindow.new(nil, nil)
605 $imagesline_sw.set_policy(Gtk::POLICY_ALWAYS, Gtk::POLICY_NEVER)
606 $imagesline_sw.add_with_viewport($imagesline = Gtk::HBox.new(false, 0))
607 main_vbox.pack_start($imagesline_sw, false, false)
608 main_vbox.pack_end($statusbar = Gtk::Statusbar.new, false, false)
610 $imagesline.set_size_request(-1, Entry.thumbnails_height + $imagesline_sw.hscrollbar.size_request[1])
612 $main_window = Gtk::Window.new
613 $main_window.add(main_vbox)
614 $main_window.signal_connect('delete-event') {
615 try_quit({ :disallow_cancel => true })
618 #- read/save size and position of window
619 if $config['pos-x'] && $config['pos-y']
620 $main_window.move($config['pos-x'].to_i, $config['pos-y'].to_i)
622 $main_window.window_position = Gtk::Window::POS_CENTER
624 msg 3, "size: #{$config['width']}x#{$config['height']}"
625 $main_window.set_default_size(($config['width'] || 700).to_i, ($config['height'] || 600).to_i)
626 $main_window.signal_connect('configure-event') {
627 msg 3, "configure: pos: #{$main_window.window.root_origin.inspect} size: #{$main_window.window.size.inspect}"
628 x, y = $main_window.window.root_origin
629 width, height = $main_window.window.size
632 $config['width'] = width
633 $config['height'] = height
637 $protect_gtk_pending_calls = Mutex.new
638 $gtk_pending_calls = []
639 Gtk.timeout_add(50) {
644 Gtk.timeout_add(10000) {
649 $main_window.show_all
653 Thread.abort_on_exception = true
658 #- Gdk::Pixbuf#rotate memory leak check (in ruby-gnome2 <= 0.16.0)
659 pb = Gdk::Pixbuf.new("#{$FPATH}/images/logo.png")
660 1.upto(5) { pb = pb.rotate(Gdk::Pixbuf::ROTATE_CLOCKWISE) }
663 1.upto(5) { pb = pb.rotate(Gdk::Pixbuf::ROTATE_CLOCKWISE) }
667 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.")