1f76f36785be658f746e2830c0394d8d66947475
[booh] / bin / booh-classifier
1 #! /usr/bin/ruby
2 #
3 #                         *  BOOH  *
4 #
5 # A.k.a 'Best web-album Of the world, Or your money back, Humerus'.
6 #
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.
11 #
12 #
13 # Copyright (c) 2004-2006 Guillaume Cottenceau <http://zarb.org/~gc/resource/gc_mail.png>
14 #
15 # This software may be freely redistributed under the terms of the GNU
16 # public license version 2.
17 #
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
21
22 require 'getoptlong'
23 require 'tempfile'
24 require 'thread'
25
26 require 'gtk2'
27
28 require 'gettext'
29 include GetText
30 bindtextdomain("booh")
31
32 require 'rexml/document'
33 include REXML
34
35 require 'booh/booh-lib'
36 include Booh
37 require 'booh/UndoHandler'
38
39
40 #- options
41 $options = [
42     [ '--help',          '-h', GetoptLong::NO_ARGUMENT,       _("Get help message") ],
43
44     [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
45 ]
46
47 def usage
48     puts _("Usage: %s [OPTION]...") % File.basename($0)
49     $options.each { |ary|
50         printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
51     }
52 end
53
54 def handle_options
55     parser = GetoptLong.new
56     parser.set_options(*$options.collect { |ary| ary[0..2] })
57     begin
58         parser.each_option do |name, arg|
59             case name
60             when '--help'
61                 usage
62                 exit(0)
63
64             when '--verbose-level'
65                 $verbose_level = arg.to_i
66
67             end
68         end
69         puts "args: #{ARGV}"
70     rescue
71         puts $!
72         usage
73         exit(1)
74     end
75 end
76
77 def memfree
78     meminfo = IO.readlines('/proc/meminfo').join
79     meminfo =~ /MemFree:.*?(\d+)/ or return -1
80     memfree = $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
84 end
85
86 def read_config
87     $config = {}
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
93             if txt
94                 if txt.value =~ /~~~/
95                     $config[element.name] = txt.value.split(/~~~/)
96                 else
97                     $config[element.name] = txt.value
98                 end
99             elsif element.elements.size == 0
100                 $config[element.name] = ''
101             else
102                 $config[element.name] = {}
103                 element.each { |chld|
104                     txt = chld.get_text
105                     $config[element.name][chld.name] = txt ? txt.value : nil
106                 }
107             end
108         }
109     end
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
115     else
116         $config['cache-memory-use-figure'] = $config['cache-memory-use'].to_i
117     end
118     msg 2, "Set cache memory use figure: #{$config['cache-memory-use-figure']} kB"
119 end
120
121 def check_config
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 })
125     end
126     missing = %w(transcode mencoder).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
127     if missing != []
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 })
129     end
130
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.
135
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 })
139     end
140 end
141
142 def write_config
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
148         if value.is_a? Hash
149             $config[key].each_pair { |subkey, subvalue|
150                 subelem = elem.add_element subkey
151                 subelem.add_text subvalue.to_s
152             }
153         elsif value.is_a? Array
154             elem.add_text value.join('~~~')
155         else
156             if !value
157                 elem.remove
158             else
159                 elem.add_text value.to_s
160             end
161         end
162     }
163     $xmldoc.write(ios, 0)
164     ios.close
165 end
166
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
171 end
172
173 def get_mem
174     IO.readlines('/proc/self/status').join =~ /VmRSS.*?(\d+)\s*kB/
175     puts $1
176     return $1.to_i
177 end
178
179 def show_mem(*txt)
180     txt.length > 0 and print txt[0]
181     puts get_mem
182 end
183
184 class Entry
185     @@thumbnails_height = 64
186     @@max_height = nil
187     def Entry.thumbnails_height
188         return @@thumbnails_height
189     end
190
191     attr_accessor :path, :type, :button
192
193     def initialize(path, type)
194         @path = path
195         @type = type
196         if @@max_height.nil?
197             @@max_height = $main_window.root_window.size[1]
198         end
199         @protect_cleanup = Mutex.new
200     end
201
202     def pixbuf_full
203         @protect_cleanup.synchronize {
204             if @pixbuf_full.nil?
205                 puts ">>> pixbuf_full #{path}"
206                 load_into_pixbuf_full
207             end
208             return @pixbuf_full
209         }
210     end
211     def free_pixbuf_full
212         @protect_cleanup.synchronize {
213             if @pixbuf_full.nil?
214                 return false
215             else
216                 puts ">>> free_pixbuf_full #{path}"
217                 @pixbuf_full = nil
218                 return true
219             end
220         }
221     end
222     def pixbuf_main(width, height)
223         @protect_cleanup.synchronize {
224             if @pixbuf_main.nil? || width != @width || height != @height
225                 puts ">>> pixbuf_main #{path}"
226                 @width = width
227                 @height = height
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)
233                     else
234                         @pixbuf_main = @pixbuf_full
235                     end
236                 else
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)
240                     else
241                         @pixbuf_main = @pixbuf_full
242                     end
243                 end
244             end
245             return @pixbuf_main
246         }
247     end
248     def free_pixbuf_main
249         @protect_cleanup.synchronize {
250             if @pixbuf_main.nil?
251                 return false
252             else
253                 puts ">>> free_pixbuf_main #{path}"
254                 @pixbuf_main = nil
255                 return true
256             end
257         }
258     end
259     def pixbuf_thumbnail
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)
265             end
266             return @pixbuf_thumbnail
267         }
268     end
269
270     private
271     def load_into_pixbuf_full
272         if @pixbuf_full.nil?
273             puts ">>> load_into_pixbuf_full #{path}"
274             begin
275                 @pixbuf_full = Gdk::Pixbuf.new(@path)
276             rescue Gdk::PixbufError
277                 puts "Cannot load #{@path}: #{$!}"
278                 return
279             end
280             if @pixbuf_full
281                 angle = guess_rotate(path)
282                 if angle != 0
283                     puts ">>> load_into_pixbuf_full #{path} => rotate #{angle}"
284                     @pixbuf_full = rotate_pixbuf(@pixbuf_full, angle)
285                 end
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)
289                 end
290             end
291         end
292     end
293
294     def to_s
295         @path
296     end
297 end
298
299 class MainView < Gtk::DrawingArea
300
301     def initialize
302         super()
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
308                 while true
309                     puts "background main preloading triggered..."
310                     if ! @index.nil?
311                         w, h = window.size
312                         for j in 1 .. 5
313                             i = @index + j
314                             if i < $allentries.size
315                                 $allentries[i].pixbuf_main(w, h)
316                             end
317                             i = @index - j
318                             if i >= 0
319                                 $allentries[i].pixbuf_main(w, h)
320                             end
321                             GC.start
322                             mem = get_mem
323                             if mem > $config['cache-memory-use-figure']
324                                 puts "too much RSS, stopping main preloading"
325                                 break
326                             end
327                         end
328                         check_memory_free_cache_if_needed
329                     end
330                     Thread.stop
331                 end
332             }
333             @preloader.priority = -2
334         }
335     end
336
337     def set_shown_entry(index)
338         if index == @index
339             return
340         end
341         @index = index
342         if index.nil?
343             @entry = nil
344         else
345             @entry = $allentries[index]
346         end
347         update_shown
348         #- should "freeze" or something to prevent blinking
349         window.clear
350         draw
351         @preloader.run
352     end
353
354     def get_shown_entry
355         return @index
356     end
357
358     def update_shown
359         if @entry
360             width, height = window.size 
361             @pixbuf = @entry.pixbuf_main(width, height)
362             if @pixbuf.width == width
363                 @xpos = 0
364                 @ypos = (height-@pixbuf.height)/2
365             else
366                 @xpos = (width-@pixbuf.width)/2
367                 @ypos = 0
368             end
369             @preloader.run
370         else
371             @pixbuf = nil
372         end
373     end
374
375     def draw
376         if @pixbuf
377             window.draw_pixbuf(nil, @pixbuf, 0, 0, @xpos, @ypos, -1, -1, Gdk::RGB::DITHER_NONE, -1, -1)
378         end
379     end
380
381 end
382
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)'
396     end
397 end
398
399 def check_memory_free_cache_if_needed
400     GC.start
401     mem = get_mem
402     i = $mainview.get_shown_entry
403     puts "mem: #{mem} index: #{i}"
404     return if i.nil?
405     ($allentries.size - 1).downto(1) { |j|
406         if mem < $config['cache-memory-use-figure'] * 2 / 3
407             break
408         end
409         index = i + j
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
414         end
415         if i - j > 0
416             freedsomething |= $allentries[i-j].free_pixbuf_full
417         end
418         if freedsomething
419             GC.start
420             mem = get_mem
421             puts "\tmem now: #{mem}"
422         end
423     }
424 end
425
426 def show_entries
427     e = Thread.new {
428         t1 = Time.now
429         show_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|
434             if entry.pixbuf_full
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) }
443                                        if i == 0
444                                            entry.button.grab_focus
445                                        end
446                                    }, i)
447                 if i % 4 == 0
448                     check_memory_free_cache_if_needed
449                 end
450             end
451         }
452         check_memory_free_cache_if_needed
453         $statusbar.pop(ctid)
454         puts "time: #{Time.now - t1}"
455     }
456     e.priority = -1
457 end
458
459 def open_dir(path)
460     path = File.expand_path(path.sub(%r|/$|, ''))
461     examined_dirs = `find '#{path}' -type d -follow`.sort.collect { |v| v.chomp }
462     #- validate first
463     examined_dirs.each { |dir|
464         if dir =~ /'/
465             show_popup($main_window, utf8(_("Source directory or sub-directories can't contain a single-quote character, sorry: %s") % dir))
466         end
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}"))
470             end
471         }
472     }
473     #- scan for populate second
474     $allentries = []
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
478             next
479         end
480         Dir.entries(dir).each { |file|
481             type = entry2type(file)
482             if type
483 #&& $allentries.size < 8
484                 $allentries << Entry.new(File.join(dir, file), type)
485             end
486         }
487     }
488     show_entries
489 end
490
491 def open_dir_popup
492     fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory to work with")),
493                                     nil,
494                                     Gtk::FileChooser::ACTION_SELECT_FOLDER,
495                                     nil,
496                                     [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
497     fc.transient_for = $main_window
498     ok = false
499     while !ok
500         if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
501             msg = open_dir(fc.filename)
502             if msg
503                 show_popup(fc, msg)
504                 ok = false
505             else
506                 ok = true
507             end
508         else
509             ok = true
510         end
511     end
512     fc.destroy
513 end
514
515 def gtk_thread_protect(proc, *params)
516     if Thread.current == Thread.main
517         proc.call(*params)
518     else
519         $protect_gtk_pending_calls.synchronize {
520             $gtk_pending_calls << [ proc, params ]
521         }
522     end
523 end
524
525 def gtk_thread_flush
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])
530         end
531     }
532 end
533
534 def try_quit(*options)
535     Gtk.main_quit
536 end
537
538 def create_menubar
539     
540     #- menu
541     mb = Gtk::MenuBar.new
542
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)
551     mb.append(filemenu)
552
553     open.signal_connect('activate') { open_dir_popup }
554     $execute.signal_connect('activate') { execute }
555     quit.signal_connect('activate') { try_quit }
556
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)
564     mb.append(editmenu)
565
566     $undo_mb.signal_connect('activate') { perform_undo }
567     $redo_mb.signal_connect('activate') { perform_redo }
568     prefs.signal_connect('activate') { preferences }
569     
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)
577     mb.append(helpmenu)
578
579     tutos.signal_connect('activate') { open_url('http://booh.org/tutorial.html') }
580     about.signal_connect('activate') { share }
581
582
583     #- no toolbar, to save height
584
585     return mb
586 end
587
588 def create_main_window
589
590     mb = create_menubar
591
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_ALWAYS, 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)
600
601     $imagesline.set_size_request(-1, Entry.thumbnails_height + $imagesline_sw.hscrollbar.size_request[1])
602
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 })
607     }
608
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)
612     else
613         $main_window.window_position = Gtk::Window::POS_CENTER
614     end
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
621         $config['pos-x'] = x
622         $config['pos-y'] = y
623         $config['width'] = width
624         $config['height'] = height
625         false
626     }
627
628     $protect_gtk_pending_calls = Mutex.new
629     $gtk_pending_calls = []
630     Gtk.timeout_add(50) {
631         gtk_thread_flush
632         true
633     }
634
635     Gtk.timeout_add(10000) {
636         show_mem
637         true
638     }
639  
640     $statusbar.push(0, utf8(_("Ready.")))
641     $main_window.show_all
642 end
643
644
645 Thread.abort_on_exception = true
646 read_config
647 Gtk.init
648
649
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) }
653 GC.start
654 mem = get_mem
655 1.upto(5) { pb = pb.rotate(Gdk::Pixbuf::ROTATE_CLOCKWISE) }
656 GC.start
657 mem2 = get_mem
658 if mem2 != mem
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.")
660     exit 1
661 end
662
663
664 create_main_window
665 check_config
666
667 if ARGV[0]
668     open_dir(ARGV[0])
669 end
670 Gtk.main
671
672 write_config