debian testing repo attempt
[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-2013 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 begin
23     require 'rubygems'
24 rescue LoadError
25 end
26
27 require 'getoptlong'
28 require 'tempfile'
29
30 require 'gtk2'
31 require 'booh/libadds'
32
33 require 'gettext'
34 include GetText
35 bindtextdomain("booh")
36
37 require 'booh/rexml/document'
38 include REXML
39
40 require 'booh/booh-lib'
41 include Booh
42 require 'booh/UndoHandler'
43
44
45 #- options
46 $options = [
47     [ '--help',          '-h', GetoptLong::NO_ARGUMENT, _("Get help message") ],
48     [ '--sort-by-exif-date', '-s', GetoptLong::NO_ARGUMENT, _("Sort entries by EXIF date") ],
49     [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
50     [ '--version',       '-V', GetoptLong::NO_ARGUMENT, _("Print version and exit") ],
51 ]
52
53 $preloader_allowed = false
54
55 def usage
56     puts _("Usage: %s [OPTION]...") % File.basename($0)
57     $options.each { |ary|
58         printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
59     }
60 end
61
62 def handle_options
63     parser = GetoptLong.new
64     parser.set_options(*$options.collect { |ary| ary[0..2] })
65     begin
66         parser.each_option do |name, arg|
67             case name
68             when '--help'
69                 usage
70                 exit(0)
71
72             when '--sort-by-exif-date'
73                 $sort_by_exif_date = true
74
75             when '--verbose-level'
76                 $verbose_level = arg.to_i
77
78             when '--version'
79                 puts _("Booh version %s
80
81 Copyright (c) 2005-2013 Guillaume Cottenceau.
82 This is free software; see the source for copying conditions.  There is NO
83 warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.") % $VERSION
84
85                 exit(0)
86
87             end
88         end
89     rescue
90         puts $!
91         usage
92         exit(1)
93     end
94 end
95
96 def total_memory
97     meminfo = IO.readlines('/proc/meminfo').join
98     meminfo =~ /MemTotal:.*?(\d+)/ or return -1
99     memory = $1.to_i
100     meminfo =~ /SwapTotal:.*?(\d+)/ or return -1
101     return memory + $1.to_i
102 end
103
104 def startup_memfree
105     if $startup_memfree.nil?
106         meminfo = IO.readlines('/proc/meminfo').join
107         meminfo =~ /MemFree:.*?(\d+)/ or return -1
108         memfree = $1
109         meminfo =~ /Buffers:.*?(\d+)/ and buffers = $1
110         meminfo =~ /Cached:.*?(\d+)/ and cached = $1
111         $startup_memfree = memfree.to_i + buffers.to_i + cached.to_i
112     end
113     return $startup_memfree
114 end
115
116 def set_cache_memory_use_figure
117     
118     if $config['cache-memory-use'] =~ /memfree_(\d+)/
119         $config['cache-memory-use-figure'] = startup_memfree*$1.to_f/100
120     else
121         $config['cache-memory-use-figure'] = $config['cache-memory-use'].to_i
122     end
123     #- cannot fork if process is > 0.5 total memory
124     if $config['cache-memory-use-figure'] > total_memory * 0.4
125         $config['cache-memory-use-figure'] = total_memory * 0.4
126         msg 2, _("Cache memory used: %s kB (reduced because cannot exceed 50%% of total memory)") % $config['cache-memory-use-figure']
127     else
128         msg 2, _("Cache memory used: %s kB") % $config['cache-memory-use-figure']
129     end
130 end
131
132 def read_config
133     $config = {}
134     $config_file = File.expand_path('~/.booh-classifier-rc')
135     if File.readable?($config_file)
136         $xmldoc = REXML::Document.new(File.new($config_file))
137         $xmldoc.root.elements.each { |element|
138             txt = element.get_text
139             if txt
140                 if txt.value =~ /~~~/
141                     $config[element.name] = txt.value.split(/~~~/)
142                 else
143                     $config[element.name] = txt.value
144                 end
145             elsif element.elements.size == 0
146                 $config[element.name] = ''
147             else
148                 $config[element.name] = {}
149                 element.each { |chld|
150                     txt = chld.get_text
151                     $config[element.name][chld.name] = txt ? txt.value : nil
152                 }
153             end
154         }
155     end
156     $config['video-viewer'] ||= '/usr/bin/mplayer %f || /usr/bin/vlc %f'
157     $config['browser'] ||= "/usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f || /usr/bin/firefox -remote 'openURL(%f,new-window)' || /usr/bin/firefox %f"
158     $config['preload-distance'] ||= '5'
159     $config['cache-memory-use'] ||= 'memfree_80%'
160     $config['rotate-set-exif'] ||= 'true'
161     $config['thumbnails-height'] ||= '64'
162     set_cache_memory_use_figure
163 end
164
165 def check_config
166     missing = %w(mplayer).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
167     if missing != []
168         show_popup($main_window, utf8(_("The following program(s) are needed to handle videos: '%s'. Videos will be ignored.") % missing.join(', ')), { :pos_centered => true })
169     end
170
171     if !system("which exif >/dev/null 2>/dev/null")
172         show_popup($main_window, utf8(_("The program 'exif' is needed to view EXIF data. Please install it.")), { :pos_centered => true })
173     end
174     viewer_binary = $config['video-viewer'].split.first
175     if viewer_binary && ! File.executable?(viewer_binary)
176         show_popup($main_window, utf8(_("The configured video viewer seems to be unavailable.
177 You should fix this in Edit/Preferences so that you can view videos.
178
179 Problem was: '%s' is not an executable file.
180 Hint: don't forget to specify the full path to the executable,
181 e.g. '/usr/bin/mplayer' is correct but 'mplayer' only is not.") % viewer_binary), { :pos_centered => true, :not_transient => true })
182     end
183     check_browser
184 end
185
186 def write_config
187     ios = File.open($config_file, "w")
188     $xmldoc = Document.new "<booh-classifier-rc version='#{$VERSION}'/>"
189     $xmldoc << XMLDecl.new(XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET)
190     $config.each_pair { |key, value|
191         elem = $xmldoc.root.add_element key
192         if value.is_a? Hash
193             $config[key].each_pair { |subkey, subvalue|
194                 subelem = elem.add_element subkey
195                 subelem.add_text subvalue.to_s
196             }
197         elsif value.is_a? Array
198             elem.add_text value.join('~~~')
199         else
200             if !value
201                 elem.remove
202             else
203                 elem.add_text value.to_s
204             end
205         end
206     }
207     $xmldoc.write(ios)
208     ios.close
209 end
210
211 def save_undo(name, closure, *params)
212     UndoHandler.save_undo(name, closure, [ *params ])
213     $undo_mb.sensitive = true
214     $redo_mb.sensitive = false
215 end
216
217 def get_mem
218     IO.readlines('/proc/self/status').join =~ /VmSize.*?(\d+)\s*kB/
219     msg 3, "VmSize: #{$1}"
220     return $1.to_i
221 end
222
223 def show_mem(*txt)
224     txt.length > 0 and print txt[0]
225     msg 2, "RSS: #{get_mem}"
226 end
227
228 class Gdk::Color
229     def darker
230         color = dup
231         color.red = [ color.red - 10000, 0 ].max
232         color.green = [ color.green - 10000, 0 ].max
233         color.blue = [ color.blue - 10000, 0 ].max
234         return color
235     end
236     def lighter
237         color = dup
238         color.red = [ color.red + 10000, 65535 ].min
239         color.green = [ color.green + 10000, 65535 ].min
240         color.blue = [ color.blue + 10000, 65535 ].min
241         return color
242     end
243 end
244
245 $color_red = Gdk::Color.new(65535, 0, 0)
246 $colors = [ Gdk::Color.new(0, 65535, 0),
247             Gdk::Color.new(0, 0, 65535),
248             Gdk::Color.new(65535, 65535, 0),
249             Gdk::Color.new(0, 65535, 65535),
250             Gdk::Color.new(65535, 0, 65535) ]
251
252 class Label
253     attr_accessor :color, :name, :button, :counter
254     def initialize(name)
255         @name = name
256     end
257 end
258
259 class InterruptedLoading < Exception
260     #- not a StandardError, not catched by a simple rescue
261 end
262
263 def show_pixbufs_present
264     if 3 <= $verbose_level
265         out = 'Full pixbufs ['
266         for entry in $allentries
267             out += entry.pixbuf_full_present? ? 'F' : '.'
268         end
269         msg 3, out + ']'
270     end
271 end
272
273 class Entry
274     @@max_width = nil
275     def Entry.thumbnails_height
276         return $config['thumbnails-height'].to_i
277     end
278
279     attr_accessor :path, :guipath, :type, :angle, :button, :image, :alignment, :removed, :labeled, :loader
280
281     def initialize(path, type, guipath)
282         @path = path
283         @type = type
284         @guipath = guipath
285         if @@max_width.nil?
286             @@max_width = $main_window.root_window.size[0] - $labels_vbox.allocation.width - ( $videoborder_pixbuf.width + MainView.borders_thickness) * 2
287         end
288     end
289
290     def pixbuf_full_present?
291         return ! @pixbuf_full.nil?
292     end
293     def free_pixbuf_full
294         if @pixbuf_full.nil?
295             return false
296         else
297             msg 3, ">>> free_pixbuf_full #{path}"
298             @pixbuf_full = nil
299             return true
300         end
301     end
302
303     def pixbuf_main
304         Gtk.main_iteration while Gtk.events_pending?
305         width, height = $mainview.window.size 
306         width = MainView.get_usable_width(width)
307         height = MainView.get_usable_height(height)
308         if @pixbuf_main.nil? || width != @width || height != @height
309             @width = width
310             @height = height
311             load_into_pixbuf_full  #- make sure it is loaded
312             if @pixbuf_full.nil?
313                 return
314             end
315             if @pixbuf_full.width.to_f / @pixbuf_full.height > width.to_f / height
316                 resized_height = @pixbuf_full.height * (width.to_f/@pixbuf_full.width)
317                 if @pixbuf_full.width > width || @pixbuf_full.height > resized_height
318                     @pixbuf_main = @pixbuf_full.scale(width, resized_height, Gdk::Pixbuf::INTERP_BILINEAR)
319                 else
320                     @pixbuf_main = @pixbuf_full
321                 end
322             else
323                 resized_width = @pixbuf_full.width * (height.to_f/@pixbuf_full.height)
324                 if @pixbuf_full.width > resized_width || @pixbuf_full.height > height
325                     @pixbuf_main = @pixbuf_full.scale(resized_width, height, Gdk::Pixbuf::INTERP_BILINEAR)
326                 else
327                     @pixbuf_main = @pixbuf_full
328                 end
329             end
330         end
331         return @pixbuf_main
332     end
333     def pixbuf_main_present?
334         return ! @pixbuf_main.nil?
335     end
336     def free_pixbuf_main
337         if @pixbuf_main.nil?
338             return false
339         else
340             msg 3, ">>> free_pixbuf_main #{path}"
341             @pixbuf_main = nil
342             return true
343         end
344     end
345
346     def pixbuf_thumbnail
347         Gtk.main_iteration while Gtk.events_pending?
348         if @pixbuf_thumbnail.nil?
349             if @pixbuf_main
350                 msg 3, ">>> pixbuf_thumbnail from main #{path}"
351                 @pixbuf_thumbnail = @pixbuf_main.scale(@pixbuf_main.width * (Entry.thumbnails_height.to_f/@pixbuf_main.height), Entry.thumbnails_height, Gdk::Pixbuf::INTERP_BILINEAR)
352             else
353                 msg 3, ">>> pixbuf_thumbnail from file #{path}"
354                 @pixbuf_thumbnail = load_into_pixbuf_at_size { |w, h|
355                     if @angle == 0
356                         if h > Entry.thumbnails_height
357                             [ w * Entry.thumbnails_height.to_f/h, Entry.thumbnails_height ]
358                         else
359                             [ w, h ]
360                         end
361                     else
362                         if w > Entry.thumbnails_height
363                             [ Entry.thumbnails_height, h * Entry.thumbnails_height.to_f/w ]
364                         else
365                             [ w, h ]
366                         end
367                     end
368                 }
369             end
370         end
371         return @pixbuf_thumbnail
372     end
373     def free_pixbuf_thumbnail
374         if @pixbuf_thumbnail.nil?
375             return false
376         else
377             msg 3, ">>> free_pixbuf_thumbnail #{path}"
378             @pixbuf_thumbnail = nil
379             return true
380         end
381     end
382
383     def outline_color
384         if removed
385             return $color_red
386         elsif labeled
387             return labeled.color
388         else
389             return nil
390         end
391     end
392
393     def show_bg
394         if outline_color.nil?
395             button.modify_bg(Gtk::StateType::NORMAL, nil)
396             button.modify_bg(Gtk::StateType::PRELIGHT, nil)
397             button.modify_bg(Gtk::StateType::ACTIVE, nil)
398         else
399             button.modify_bg(Gtk::StateType::NORMAL, outline_color)
400             button.modify_bg(Gtk::StateType::PRELIGHT, outline_color.lighter)
401             button.modify_bg(Gtk::StateType::ACTIVE, outline_color)
402         end
403     end
404
405     def get_beautified_name
406         if type == 'image'
407             size = get_image_size(path)
408             return _("%s (%sx%s, %s KB)") % [@guipath.gsub(/\.[^.]+$/, ''),
409                                              size[:x],
410                                              size[:y],
411                                              commify(file_size(path)/1024)]
412         else
413             return _("%s (video - %s KB)") % [@guipath.gsub(/\.[^.]+$/, ''),
414                                              commify(file_size(path)/1024)]
415         end
416     end
417
418     def cancel_loader
419         if ! @loader.nil?
420             #- avoid unneeded memory allocation
421             @loader.signal_handler_disconnect(@area_prepared_cb)
422             begin
423                 @loader.close
424             rescue
425                 #- ignore loader errors, at that point they are fairly normal, we're canceling a partial load
426             end
427             @loader = nil
428         end
429     end
430
431     private
432     def load_into_pixbuf_full
433         if @pixbuf_full.nil?
434             msg 3, ">>> load_into_pixbuf_full #{path}"
435             @pixbuf_full = load_into_pixbuf_at_size { |w, h|
436                 if @angle == 0
437                     if w > @@max_width
438                         #- save memory and speedup (+35%) loading 
439                         [ w * (factor = @@max_width.to_f/w), h * factor ]
440                     else
441                         [ w, h ]
442                     end
443                 else
444                     if h > @@max_width
445                         [ w * (factor = @@max_width.to_f/h), h * factor ]
446                     else
447                         [ w, h ]
448                     end
449                 end
450             }
451             show_pixbufs_present
452         end
453     end
454
455     def load_into_pixbuf_at_size(&specify_size)
456         if @type == 'video'
457             if @video_image_path.nil?
458                 orig_base = File.basename(path)
459                 tmpdir = gen_video_thumbnail(path, false, 0)
460                 if tmpdir.nil?
461                     return
462                 end
463                 @video_image_path = "#{tmpdir}/00000001.jpg"
464             end
465             image_path = @video_image_path
466         else
467             image_path = @path
468         end
469         if @angle.nil?
470             if @type == 'image'
471                 @angle = guess_rotate(image_path)
472             else
473                 @angle = 0
474             end
475         end
476         begin
477             #- use a pixbuf loader and check Gtk.events_pending? on each chunk, to keep the UI responsive even
478             #- if loaded pictures are several MBs large
479             if @loader.nil?
480                 @loader = Gdk::PixbufLoader.new
481                 @loader.signal_connect('size-prepared') { |l, w, h|
482                     @loader.set_size(*specify_size.call(w, h))
483                 }
484                 @area_prepared_cb = @loader.signal_connect('area-prepared') { @loaded_pixbuf = @loader.pixbuf }
485                 @loader_offset = 0
486             end
487             msg 3, "calling load_not_freezing_ui on #{image_path}, offset #{@loader_offset}"
488             @loader_offset = @loader.load_not_freezing_ui(image_path, @loader_offset)
489             if @loader_offset > 0
490                 #- interrupted
491                 raise InterruptedLoading
492             end
493             @loader = nil
494             if @loaded_pixbuf.nil?
495                 raise "Loaded pixbuf nil - #{path} #{image_path}"
496             end
497         rescue
498             msg 0, "Cannot load #{image_path}: #{$!}"
499             return
500         ensure
501             if @video_image_path && @loader.nil?
502                 File.delete(@video_image_path)
503                 Dir.rmdir(File.dirname(@video_image_path))
504                 @video_image_path = nil
505             end
506         end
507         if @loaded_pixbuf
508             if @angle != 0
509                 msg 3, ">>> load_into_pixbuf_full #{image_path} => rotate #{@angle}"
510                 @loaded_pixbuf = rotate_pixbuf(@loaded_pixbuf, @angle)
511             end
512         end
513         retval = @loaded_pixbuf
514         @loaded_pixbuf = nil
515         return retval
516     end
517
518     def to_s
519         @path
520     end
521 end
522
523 $allentries = []
524
525 def gc
526     start = Time.now
527     GC.start
528     msg 3, "GC in #{Time.now - start} s"
529 end
530
531 def free_cache_if_needed
532     i = $allentries.index($mainview.get_shown_entry)
533     return if i.nil?
534     if get_mem > $config['cache-memory-use-figure']
535         msg 3, "too much RSS, triggering GC"
536         gc
537     end
538     if get_mem < $config['cache-memory-use-figure']
539         return
540     end
541     msg 3, "too much RSS, freeing some cache"
542     start = Time.now
543     freed = 0
544     ($allentries.size - 1).downto($config['preload-distance'].to_i + 1) { |j|
545         index = i + j
546         if i + j < $allentries.size
547             $allentries[i + j].free_pixbuf_full
548             if $allentries[i + j].free_pixbuf_main
549                 freed += 1
550             end
551         end
552         if i - j >= 0
553             $allentries[i - j].free_pixbuf_full
554             if $allentries[i - j].free_pixbuf_main
555                 freed += 1
556             end
557         end
558         if freed >= 10
559             gc
560             if get_mem < $config['cache-memory-use-figure'] * 3 / 4
561                 msg 3, "RSS down enough - freeing done in #{Time.now - start} s"
562                 show_pixbufs_present
563                 return
564             end
565             freed = 0
566         end
567     }
568     msg 3, "freeing done in #{Time.now - start} s"
569     show_pixbufs_present
570 end
571
572 def run_preloader_real
573     msg 3, "*** >> main preloading triggered..."
574     if $mainview.get_shown_entry
575         free_cache_if_needed
576         if $config['preload-distance'].to_i == 0
577             return true
578         end
579         index = $allentries.index($mainview.get_shown_entry)
580         index_right = index
581         index_left = index
582         loaded_right = 0
583         loaded_left = 0
584         right_done = false
585         left_done = false
586         loaded = []
587         while ! right_done || ! left_done
588             if ! right_done
589                 index_right += 1
590                 while index_right < $allentries.size && ! visible($allentries[index_right])
591                     index_right += 1
592                 end
593                 if index_right == $allentries.size
594                     right_done = true
595                 else
596                     if ! $allentries[index_right].pixbuf_main_present?
597                         msg 3, "preloading #{$allentries[index_right].path}"
598                         begin
599                             $allentries[index_right].pixbuf_main
600                         rescue InterruptedLoading
601                             msg 3, "*** >>>> interrupted, rerun"
602                             return false
603                         end
604                     end
605                     loaded << index_right
606                     loaded_right += 1
607                     if loaded_right == $config['preload-distance'].to_i
608                         right_done = true
609                     end
610                 end
611             end
612
613             if ! left_done
614                 index_left -= 1
615                 while index_left >= 0 && ! visible($allentries[index_left])
616                     index_left -= 1
617                 end
618                 if index_left == -1
619                     left_done = true
620                 else
621                     if ! $allentries[index_left].pixbuf_main_present?
622                         msg 3, "preloading #{$allentries[index_left].path}"
623                         begin
624                             $allentries[index_left].pixbuf_main
625                         rescue InterruptedLoading
626                             msg 3, "*** >>>> interrupted, rerun"
627                             return false
628                         end
629                     end
630                     loaded << index_left
631                     loaded_left += 1
632                     if loaded_left == $config['preload-distance'].to_i
633                         left_done = true
634                     end
635                 end
636             end
637
638             #- in case just loaded another directory
639             if $preloader_force_exit
640                 $preloader_force_exit = false
641                 return true
642             end
643             #- in case moved fast
644             if index != $allentries.index($mainview.get_shown_entry)
645                 msg 3, "*** >>>> moved already, rerun"
646                 return false
647             end
648         end
649     end
650     msg 3, "*** << main preloading finished"
651     return true
652 end
653
654 def run_preloader
655     if ! $preloader_allowed
656         msg 3, "*** preloader not yet allowed"
657         return
658     end
659
660     if $preloader_running
661         msg 3, "preloader already running"
662         return
663     end
664     msg 3, "run preloader"
665     $preloader_running = true
666     Gtk.idle_add {
667         msg 3, "begin preloader from timeout "
668         if run_preloader_real
669             $preloader_running = false
670             false
671         else
672             true
673         end
674     }
675 end
676
677 class MainView < Gtk::DrawingArea
678
679     @@borders_thickness = 5
680     @@borders_length = 25
681     @@redraw_pending = nil
682
683     def MainView.borders_thickness
684         return @@borders_thickness
685     end
686
687     def MainView.get_usable_width(available_width)
688         return available_width - ($videoborder_pixbuf.width + @@borders_thickness) * 2
689     end
690
691     def MainView.get_usable_height(available_height)
692         return available_height - @@borders_thickness * 2
693     end
694     
695     def initialize
696         super()
697         signal_connect('expose-event') { draw }
698         signal_connect('configure-event') { update_shown }
699     end
700
701     def try_show_entry(entry)
702         if entry && entry.button
703             if entry.button.has_focus?
704                 redraw
705             else
706                 entry.button.grab_focus
707             end
708         end
709     end
710
711     def set_shown_entry(entry)
712         t1 = Time.now
713         if entry && entry == @entry
714             return
715         end
716         if entry && ! entry.button
717             #- not loaded yet
718             return
719         end
720         @entry = entry
721         @entry and msg 3, "*** set entry to #{@entry.path}"
722         redraw
723         msg 3, "entry shown in: #{Time.now - t1} s"
724     end
725
726     def get_shown_entry
727         return @entry
728     end
729
730     def show_next_entry(entry)
731         index = $allentries.index(entry)
732         if index < $allentries.size - 1
733             index += 1
734         end
735         while index < $allentries.size - 1 && $allentries[index] && $allentries[index].button && ! $allentries[index].button.visible?
736             index += 1
737         end
738         while $allentries[index] && $allentries[index].button && ! $allentries[index].button.visible? && index > 0
739             index -= 1
740         end
741         if index < $allentries.size && $allentries[index] && $allentries[index].button && $allentries[index].button.visible?
742             try_show_entry($allentries[index])
743             return
744         end
745         #- find a fallback before
746         while index < $allentries.size && index > 0 && $allentries[index] && (! $allentries[index].button || ! $allentries[index].button.visible?)
747             index -= 1
748         end
749         if index < $allentries.size && index > 0 && $allentries[index] && $allentries[index].button && $allentries[index].button.visible?
750             try_show_entry($allentries[index])
751         end
752     end
753
754     def redraw
755         if @@redraw_pending
756             msg 3, "redraw already pending"
757             return
758         end
759         msg 3, "redraw"
760         @@redraw_pending = Gtk.idle_add {
761             msg 3, "begin redraw from timeout "
762             begin
763                 msg 3, "try redraw from timeout"
764                 redraw_real
765                 @@redraw_pending = nil
766                 run_preloader
767                 false
768             rescue InterruptedLoading
769                 msg 3, "interrupted, will retry"
770                 true
771             end
772         }
773     end
774
775     def redraw_real
776         @entry and sb_msg(_("Selected %s") % @entry.get_beautified_name)
777         if ! update_shown
778             return
779         end
780         w, h = window.size
781         window.begin_paint(Gdk::Rectangle.new(0, 0, w, h))
782         window.clear
783         draw
784         window.end_paint
785     end
786
787     def update_shown
788         if @entry
789             msg 3, "################################################ trying to show #{@entry.path}"
790             pixbuf = @entry.pixbuf_main
791             if pixbuf
792                 @pixbuf = pixbuf
793                 width, height = window.size 
794                 @xpos = (width - @pixbuf.width)/2
795                 @ypos = (height - @pixbuf.height)/2
796                 return true
797             else
798                 return false
799             end
800         else
801             @pixbuf = nil
802             return true
803         end
804     end
805
806     def draw
807         if @pixbuf
808             window.draw_pixbuf(nil, @pixbuf, 0, 0, @xpos, @ypos, -1, -1, Gdk::RGB::DITHER_NONE, -1, -1)
809             if @entry && @entry.type == 'video'
810                 window.draw_borders($videoborder_pixbuf, @xpos - $videoborder_pixbuf.width, @xpos + @pixbuf.width, @ypos, @ypos + @pixbuf.height)
811             end
812             if @entry && ! @entry.outline_color.nil?
813                 gc = Gdk::GC.new(window)
814                 colormap.alloc_color(@entry.outline_color, false, true)
815                 gc.set_foreground(@entry.outline_color)
816                 if @entry.type == 'video'
817                     xleft = @xpos - $videoborder_pixbuf.width
818                     xright = @xpos + @pixbuf.width + $videoborder_pixbuf.width
819                 else
820                     xleft = @xpos
821                     xright = @xpos + @pixbuf.width
822                 end
823                 window.draw_polygon(gc, true, [[xleft - @@borders_thickness, @ypos - @@borders_thickness],
824                                                [xright + @@borders_thickness, @ypos - @@borders_thickness],
825                                                [xright + @@borders_thickness, @ypos + @pixbuf.height + @@borders_thickness],
826                                                [xleft - @@borders_thickness, @ypos + @pixbuf.height + @@borders_thickness],
827                                                [xleft - @@borders_thickness, @ypos - 1],
828                                                [xleft - 1, @ypos - 1],
829                                                [xleft - 1, @ypos + @pixbuf.height + 1],
830                                                [xright + 1, @ypos + @pixbuf.height + 1],
831                                                [xright + 1, @ypos - 1],
832                                                [xleft - @@borders_thickness, @ypos - 1]])
833             end
834         end
835     end
836 end
837
838 def autoscroll_if_needed(button, center)
839     xpos_left = button.allocation.x
840     xpos_right = button.allocation.x + button.allocation.width
841     hadj = $imagesline_sw.hadjustment
842     current_minx_visible = hadj.value
843     current_maxx_visible = hadj.value + hadj.page_size
844     if ! center
845         if xpos_left < current_minx_visible
846             #- autoscroll left
847             newval = hadj.value - (current_minx_visible - xpos_left)
848             hadj.value = newval
849         elsif xpos_right > current_maxx_visible
850             #- autoscroll right
851             newval = hadj.value + (xpos_right - current_maxx_visible)
852             if newval > hadj.upper - hadj.page_size
853                 newval = hadj.upper - hadj.page_size
854             end
855             hadj.value = newval
856         end
857     else
858         hadj.value = clamp((xpos_left + xpos_right) / 2 - hadj.page_size / 2, 0, hadj.upper - hadj.page_size)
859     end
860 end
861
862 def show_popup(parent, msg, *options)
863     dialog = Gtk::Dialog.new
864     if options[0]
865         options = options[0]
866     else
867         options = {}
868     end
869     if options[:title]
870         dialog.title = options[:title]
871     else
872         dialog.title = utf8(_("Booh message"))
873     end
874     lbl = Gtk::Label.new
875     if options[:nomarkup]
876         lbl.text = msg
877     else
878         lbl.markup = msg
879     end
880     if options[:centered]
881         lbl.set_justify(Gtk::Justification::CENTER)
882     end
883     if options[:selectable]
884         lbl.selectable = true
885     end
886     if options[:scrolled]
887         sw = Gtk::ScrolledWindow.new(nil, nil)
888         sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
889         sw.add_with_viewport(lbl)
890         dialog.vbox.add(sw)
891         dialog.set_default_size(500, 600)
892     else
893         dialog.vbox.add(lbl)
894         dialog.set_default_size(200, 120)
895     end
896     if options[:bottomwidget]
897         dialog.vbox.add(options[:bottomwidget])
898     end
899     if options[:okcancel]
900         cancel = dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL)
901         dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
902     elsif options[:yestoall]
903         cancel = dialog.add_button(Gtk::Stock::NO, Gtk::Dialog::RESPONSE_NO)
904         if ! options[:bottomwidget]
905             cancel.grab_focus
906         end
907         dialog.add_button(Gtk::Stock::YES, Gtk::Dialog::RESPONSE_YES)
908         dialog.add_button(utf8(_("Yes to all")), Gtk::Dialog::RESPONSE_ACCEPT)
909     else
910         ok = dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
911         if ! options[:bottomwidget]
912             ok.grab_focus
913         end
914     end
915
916     if options[:pos_centered]
917         dialog.window_position = Gtk::Window::POS_CENTER
918     else
919         dialog.window_position = Gtk::Window::POS_MOUSE
920     end
921
922     if options[:linkurl]
923         linkbut = Gtk::Button.new('')
924         linkbut.child.markup = "<span foreground=\"#00000000FFFF\" underline=\"single\">#{options[0][:linkurl]}</span>"
925         linkbut.signal_connect('clicked') {
926             open_url(options[0][:linkurl] + '/index.html')
927             dialog.response(Gtk::Dialog::RESPONSE_OK)
928             set_mousecursor_normal
929         }
930         linkbut.relief = Gtk::RELIEF_NONE
931         linkbut.signal_connect('enter-notify-event') { set_mousecursor(Gdk::Cursor::HAND2, linkbut); false }
932         linkbut.signal_connect('leave-notify-event') { set_mousecursor(nil, linkbut); false }
933         dialog.vbox.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(linkbut))
934     end
935
936     dialog.show_all
937
938     if options[:stuff_connector]
939         options[:stuff_connector].call({ :dialog => dialog })
940     end
941                                         
942     if !options[:not_transient]
943         dialog.transient_for = parent
944         dialog.run { |response|
945             if options[:data_getter]
946                 options[:data_getter].call
947             end
948             dialog.destroy
949             if options[:okcancel]
950                 return response == Gtk::Dialog::RESPONSE_OK
951             elsif options[:yestoall]
952                 return response == Gtk::Dialog::RESPONSE_YES ? 'yes' : response == Gtk::Dialog::RESPONSE_ACCEPT ? 'yestoall' : 'no'
953             end
954         }
955     else
956         dialog.signal_connect('response') { dialog.destroy }
957     end
958 end
959
960 def view_entry(entry)
961     if entry.type == 'image'
962         show_popup($main_window,
963                    utf8(`exif -m '#{entry.path}'`),
964                    { :title => utf8(_("EXIF data of %s") % File.basename(entry.path)), :nomarkup => true, :scrolled => true, :not_transient => true })
965     else
966         cmd = from_utf8($config['video-viewer']).gsub('%f', "'#{entry.path}'") + ' &'
967         msg 2, cmd
968         system(cmd)
969     end
970 end
971
972 def update_counters
973     value = 0
974     $allentries.each { |entry|
975         if ! entry.removed && entry.labeled.nil?
976             value += 1
977         end
978     }
979     $unlabelled_counter.set_markup('<tt>' + value.to_s + '</tt>')
980     value = 0
981     $allentries.each { |entry|
982         if entry.removed 
983             value += 1
984         end
985     }
986     $toremove_counter.set_markup('<tt>' + value.to_s + '</tt>')
987     $labels.values.each { |label|
988         value = 0
989         $allentries.each { |entry|
990             if entry.labeled == label
991                 value += 1
992             end
993         }
994         label.counter.set_markup('<tt>' + value.to_s + '</tt>')
995     }
996 end
997
998 def thumbnail_keypressed(entry, event)
999     if event.state & Gdk::Window::MOD1_MASK != 0
1000         #- ALT pressed: Alt-Left and Alft-Right rotate
1001         if event.keyval == Gdk::Keyval::GDK_Left || event.keyval == Gdk::Keyval::GDK_Right
1002             if event.keyval == Gdk::Keyval::GDK_Left
1003                 entry.angle = (entry.angle - 90) % 360
1004             else
1005                 entry.angle = (entry.angle + 90) % 360
1006             end
1007             entry.free_pixbuf_full
1008             entry.free_pixbuf_main
1009             entry.free_pixbuf_thumbnail
1010             show_pixbufs_present
1011             $mainview.redraw
1012             entry.image.pixbuf = entry.pixbuf_thumbnail
1013             if $config['rotate-set-exif'] == 'true' && entry.type == 'image'
1014                 Exif.set_orientation(entry.path, angle_to_exif_orientation(entry.angle))
1015             end
1016         end
1017
1018     elsif event.state & Gdk::Window::CONTROL_MASK != 0
1019         #- CONTROL pressed: Ctrl-z and Ctrl-r for undo/redo, Ctrl-space for recentre
1020         if event.keyval == Gdk::Keyval::GDK_z
1021             perform_undo
1022         end
1023         if event.keyval == Gdk::Keyval::GDK_r
1024             perform_redo
1025         end
1026         if event.keyval == Gdk::Keyval::GDK_space
1027             shown = $mainview.get_shown_entry
1028             shown and autoscroll_if_needed(shown.button, true)
1029         end
1030
1031     else
1032         removed_before = entry.removed
1033         label_before = entry.labeled
1034
1035         if event.keyval == Gdk::Keyval::GDK_Delete
1036             if ! FileTest.writable?(entry.path)
1037                 show_popup($main_window, utf8(_("Notice: no write access to '%s', permission will be denied at execute step.") % entry.path))
1038             end
1039             entry.removed = true
1040             entry.labeled = nil
1041             entry.show_bg
1042             update_visibility(entry)
1043             update_counters
1044             $mainview.show_next_entry(entry)
1045
1046             save_undo(_("set for removal"),
1047                       proc {
1048                           entry.removed = removed_before
1049                           entry.labeled = label_before
1050                           entry.show_bg
1051                           update_visibility(entry)
1052                           update_counters
1053                           if entry.button.visible?
1054                               $mainview.try_show_entry(entry)
1055                           end
1056                           proc {
1057                               entry.removed = true
1058                               entry.labeled = nil
1059                               entry.show_bg
1060                               update_visibility(entry)
1061                               update_counters
1062                               if entry.button.visible?
1063                                   $mainview.try_show_entry(entry)
1064                               end
1065                           }
1066                       })
1067
1068         elsif event.keyval == Gdk::Keyval::GDK_space
1069             if entry.labeled
1070                 msg = _("Cleared label")
1071             elsif entry.removed
1072                 msg = _("Cleared set for removal")
1073             end
1074             entry.removed = false
1075             entry.labeled = nil
1076             entry.show_bg
1077             update_counters
1078             $mainview.show_next_entry(entry)
1079
1080             save_undo(msg,
1081                       proc {
1082                           entry.removed = removed_before
1083                           entry.labeled = label_before
1084                           entry.show_bg
1085                           update_counters
1086                           $mainview.try_show_entry(entry)
1087                           proc {
1088                               entry.removed = false
1089                               entry.labeled = nil
1090                               entry.show_bg
1091                               update_counters
1092                               $mainview.try_show_entry(entry)
1093                           }
1094                       })
1095
1096         elsif event.keyval == Gdk::Keyval::GDK_Return
1097             view_entry(entry)
1098
1099         elsif event.keyval == Gdk::Keyval::GDK_Home
1100             index = 0
1101             while $allentries[index] && $allentries[index].button && !visible($allentries[index])
1102                 index += 1
1103             end
1104             if $allentries[index] && $allentries[index].button
1105                 $allentries[index].button.grab_focus
1106             end
1107
1108         elsif event.keyval == Gdk::Keyval::GDK_End
1109             index = $allentries.size - 1
1110             while $allentries[index] && ! $allentries[index].button
1111                 #- not yet loaded
1112                 index -= 1
1113             end
1114             while $allentries[index] && $allentries[index].button && !visible($allentries[index])
1115                 index -= 1
1116             end
1117             if $allentries[index] && $allentries[index].button
1118                 $allentries[index].button.grab_focus
1119             end
1120
1121         else
1122             char = [ Gdk::Keyval.to_unicode(event.keyval) ].pack("C*")
1123             if char =~ /^[a-zA-z0-9]$/
1124                 label = $labels[char]
1125                 
1126                 if label.nil?
1127                     vb = Gtk::VBox.new(false, 0)
1128                     vb.pack_start(labelentry = Gtk::Entry.new.set_text(char), false, false)
1129                     vb.pack_start(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(bt = Gtk::ColorButton.new))
1130                     color = bt.color = Gdk::Color.new(16384 + rand(49151), 16384 + rand(49151), 16384 + rand(49151))
1131                     bt.signal_connect('color-set') { color = bt.color }
1132                     text = nil
1133                     labelentry.signal_connect('changed') {  #- cannot add a new label with first letter of an existing label
1134                         while $labels.has_key?(labelentry.text[0,1])
1135                             labelentry.text = labelentry.text.sub(/./, '')
1136                         end
1137                     }
1138                     if show_popup($main_window,
1139                                   utf8(_("You typed the text character '%s', which is not associated with a label.\nType in the full name of the label below to create a new one.")) % char,
1140                                   { :okcancel => true, :bottomwidget => vb, :data_getter => proc { text = labelentry.text },
1141                                     :stuff_connector => proc { |stuff| labelentry.select_region(0, 0)
1142                                                                        labelentry.position = -1
1143                                                                        labelentry.signal_connect('activate') { stuff[:dialog].response(Gtk::Dialog::RESPONSE_OK) } } } )
1144                         if text.length > 0
1145                             char = text[0,1]  #- in case it changed
1146                             label = Label.new(text)
1147                             label.color = color
1148                             $labels[char] = label
1149                             $ordered_labels << label
1150                             lbl = Gtk::Label.new.set_markup('<b>(' + char + ')</b>' + text[1..-1]).set_justify(Gtk::Justification::CENTER)
1151                             $labels_vbox.pack_start(Gtk::HBox.new(false, 5).pack_start(label.button = Gtk::CheckButton.new.add(evt = Gtk::EventBox.new.add(lbl))).
1152                                                                             pack_start(Gtk::Label.new, true, true).
1153                                                                             pack_start(label.counter = Gtk::Label.new.set_markup('<tt>0</tt>'), false, false).show_all)
1154                             label.button.active = true
1155                             label.button.signal_connect('toggled') { update_all_visibilities }
1156                             evt.modify_bg(Gtk::StateType::NORMAL, label.color)
1157                             evt.modify_bg(Gtk::StateType::PRELIGHT, label.color.lighter.lighter)
1158                             evt.modify_bg(Gtk::StateType::ACTIVE, label.color.lighter)
1159                         end
1160                     end
1161                 end
1162
1163                 if label
1164                     entry.removed = false
1165                     entry.labeled = label
1166                     entry.show_bg
1167                     update_visibility(entry)
1168                     update_counters
1169                     $mainview.show_next_entry(entry)
1170
1171                     save_undo(_("set label"),
1172                               proc {
1173                                   entry.removed = removed_before
1174                                   entry.labeled = label_before
1175                                   entry.show_bg
1176                                   update_visibility(entry)
1177                                   update_counters
1178                                   if entry.button.visible?
1179                                       $mainview.try_show_entry(entry)
1180                                   end
1181                                   proc {
1182                                       entry.removed = false
1183                                       entry.labeled = label
1184                                       entry.show_bg
1185                                       update_visibility(entry)
1186                                       update_counters
1187                                       if entry.button.visible?
1188                                           $mainview.try_show_entry(entry)
1189                                       end
1190                                   }
1191                               })
1192                 end
1193             end
1194         end
1195     end
1196 end
1197
1198 def sb_msg(msg)
1199     $statusbar.pop(0)
1200     if msg
1201         $statusbar.push(0, utf8(msg))
1202     end
1203 end
1204
1205 def show_entry(entry, i, tips)
1206     #- scope entry
1207     #msg 3, "showing entry #{entry}"
1208     entry.image = Gtk::Image.new(entry.pixbuf_thumbnail)
1209     if entry.type == 'video'
1210         entry.button = Gtk::Button.new.add(Gtk::HBox.new.pack_start(da1 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false).
1211                                            pack_start(entry.image).
1212                                            pack_start(da2 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false))
1213         da1.signal_connect('realize') { da1.window.set_back_pixmap($videoborder_pixmap, false) }
1214         da2.signal_connect('realize') { da2.window.set_back_pixmap($videoborder_pixmap, false) }
1215     else
1216         entry.button = Gtk::Button.new.add(entry.image)
1217     end
1218     tips.set_tip(entry.button, entry.get_beautified_name, nil)
1219     $imagesline.pack_start(entry.alignment = Gtk::Alignment.new(0.5, 1, 0, 0).add(entry.button).show_all, false, false)
1220     entry.button.signal_connect('clicked') {
1221         shown = $mainview.get_shown_entry
1222         if shown != entry
1223             shown and shown.alignment.set(0.5, 1, 0, 0)
1224             entry.alignment.set(0.5, 0, 0, 0)
1225             autoscroll_if_needed(entry.button, false)
1226             $mainview.set_shown_entry(entry)
1227         end
1228     }
1229     entry.button.signal_connect('button-press-event') { |w, event|
1230         if entry.type == 'video' && event.event_type == Gdk::Event::BUTTON2_PRESS
1231             video_view(entry)
1232         end
1233     }
1234     entry.button.signal_connect('focus-in-event') { entry.button.clicked }
1235     entry.button.signal_connect('key-press-event') { |w, e| thumbnail_keypressed(entry, e) }
1236     if i == 0
1237         entry.button.grab_focus
1238     end
1239     update_visibility(entry)
1240 end
1241
1242 def show_entries(allentries)
1243     update_counters
1244     sb_msg(_("Loading images..."))
1245     $loading_progressbar.fraction = 0
1246     $loading_progressbar.text = utf8(_("Loading... %d%") % 0)
1247     $loading_progressbar.show
1248     t1 = Time.now
1249     total_loaded_files = 0
1250     total_loaded_size = 0
1251     i = 0
1252     tips = Gtk::Tooltips.new
1253     while i < allentries.size
1254         begin
1255             entry = allentries[i]
1256             if i == 0
1257                 loaded_pixbuf = entry.pixbuf_main
1258             else
1259                 loaded_pixbuf = entry.pixbuf_thumbnail
1260             end
1261         rescue InterruptedLoading
1262             redo
1263         end
1264
1265         if $allentries != allentries
1266             #- loaded another directory while this one was not yet finished
1267             msg 3, "allentries differ, stopping this deprecated load"
1268             return
1269         end
1270
1271         if loaded_pixbuf
1272             show_entry(entry, i, tips)
1273             if $allentries != allentries
1274                 #- loaded another directory while this one was not yet finished
1275                 msg 3, "allentries differ, stopping this deprecated load"
1276                 return
1277             end
1278
1279             total_loaded_size += file_size(entry.path)
1280             total_loaded_files += 1
1281             i += 1
1282             if i > $config['preload-distance'].to_i && i <= $config['preload-distance'].to_i * 2
1283                 #- when we're at preload distance, begin preloading to preload distance
1284                 begin
1285                     allentries[i - $config['preload-distance'].to_i].pixbuf_main
1286                 rescue InterruptedLoading
1287                 end
1288             end
1289             if i == $config['preload-distance'].to_i * 2 + 1
1290                 #- when we're after double preload distance, activate normal preloading
1291                 $preloader_allowed = true
1292             end
1293             
1294         else
1295             allentries.delete_at(i)
1296         end
1297         $loading_progressbar.fraction = i.to_f / allentries.size
1298         $loading_progressbar.text = utf8(_("Loading... %d%") % (100 * $loading_progressbar.fraction))
1299         if $quit
1300             return
1301         end
1302         if i % 25 == 0
1303             gc
1304         end
1305     end
1306     $preloader_allowed = true
1307     if i <= $config['preload-distance'].to_i * 2
1308         #- not yet preloaded correctly
1309         run_preloader
1310     end
1311     sb_msg(_("%d images of total %s kB loaded in %3.2f seconds.") % [ total_loaded_files, commify(total_loaded_size / 1024), Time.now - t1 ])
1312     $loading_progressbar.hide
1313     $execute.sensitive = true
1314 end
1315
1316 def reset_all
1317     reset_labels
1318     reset_thumbnails
1319     $mainview.set_shown_entry(nil)
1320     sb_msg(nil)
1321     $preloader_allowed = false
1322     $execute.sensitive = false
1323 end
1324
1325 def open_dir(*paths)
1326     #- remove visual stuff, so that user will see something is happening
1327     reset_all
1328     sb_msg(_("Scanning source directory... %s") % "")
1329     Gtk.main_iteration while Gtk.events_pending?
1330
1331     for path in paths
1332         path = File.expand_path(path.sub(%r|/$|, ''))
1333         $workingdir = path
1334         entries = []
1335         if File.directory?(path)
1336             examined_dirs = `find '#{path}' -type d -follow`.split("\n").sort.collect { |v| v.chomp }
1337             #- validate first
1338             examined_dirs.each { |dir|
1339                 if dir =~ /'/
1340                     return utf8(_("Source directory or sub-directories can't contain a single-quote character, sorry: %s") % dir)
1341                 end
1342                 begin
1343                     Dir.entries(dir).each { |file|
1344                         if file =~ /'/ && type = entry2type(file) && type == 'video'
1345                             return utf8(_("Videos can't contain a single quote character ('), sorry: %s") % "#{dir}/#{file}")
1346                         end
1347                     }
1348                 rescue
1349                     puts "Failed to open directory #{dir}: #{$!}"
1350                 end
1351             }
1352             
1353             #- scan for populate second
1354             examined_dirs.each { |dir|
1355                 if File.basename(dir) =~ /^\./
1356                     msg 1, _("Ignoring directory %s, begins with a dot (indicating a hidden directory)") % dir
1357                     next
1358                 end
1359                 begin
1360                     entries += Dir.entries(dir).collect { |file| File.join(dir, file) }
1361                 rescue
1362                     #- already puts'ed 10 lines upper
1363                 end
1364                 sb_msg(_("Scanning source directory... %s") % (_("%d entries found") % entries.size))
1365                 Gtk.main_iteration while Gtk.events_pending?
1366             }
1367
1368         else
1369             entries << path
1370         end
1371
1372         if $sort_by_exif_date
1373             dates = {}
1374             entries.each { |file|
1375                 date_time = Exif.datetimeoriginal(file)
1376                 if ! date_time.nil?
1377                     dates[file] = date_time
1378                 elsif file =~ /(20\d{2}).?(\d{2}).?(\d{2}).(\d{2}).?(\d{2}).?(\d{2})/
1379                     dates[file] = "#$1:#$2:#$3 #$4:#$5:#$6"
1380                 end
1381             }
1382             entries = smartsort(entries, dates)
1383         else
1384             entries.sort!
1385         end
1386         entries.each { |file|
1387             type = entry2type(file)
1388             if type
1389                 if File.directory?(path)
1390                     $allentries << Entry.new(file, type, file[path.length + 1 .. -1])
1391                 else
1392                     $allentries << Entry.new(file, type, file)
1393                 end
1394             end
1395         }
1396     end
1397     return nil
1398 end
1399
1400 def open_dir_popup
1401     fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory to work with")),
1402                                     nil,
1403                                     Gtk::FileChooser::ACTION_SELECT_FOLDER,
1404                                     nil,
1405                                     [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1406     fc.transient_for = $main_window
1407     if $workingdir
1408         fc.current_folder = $workingdir
1409     end
1410     ok = false
1411     load = false
1412     while !ok
1413         if fc.run == Gtk::Dialog::RESPONSE_ACCEPT && fc.filename
1414             msg = open_dir(fc.filename)
1415             if msg
1416                 show_popup(fc, msg)
1417                 ok = false
1418             else
1419                 ok = true
1420                 load = true
1421             end
1422         else
1423             ok = true
1424         end
1425     end
1426     fc.destroy
1427     if load
1428         show_entries($allentries)
1429     end
1430 end
1431
1432 def try_quit(*options)
1433     if ! $allentries.detect { |e| e.removed || e.labeled } || show_popup($main_window,
1434                                                                          utf8(_("You have not executed the classification. Are you sure you want to quit?")),
1435                                                                          { :okcancel => true })
1436         Gtk.main_quit
1437         $quit = true
1438         return false
1439     else
1440         return true
1441     end
1442 end
1443
1444 def execute
1445     dialog = Gtk::Dialog.new
1446     dialog.title = utf8(_("Booh message"))
1447
1448     vb1 = Gtk::VBox.new(false, 5)
1449     label = Gtk::Label.new.set_markup(utf8(_("You're about to <b>execute</b> actions on the marked images.\nPlease confirm below the actions. You cannot undo this operation!")))
1450     vb1.pack_start(label, false, false)
1451
1452     lastpath = $workingdir
1453
1454     table = Gtk::Table.new(0, 0, false)
1455     table.set_row_spacings(5)
1456     table.set_column_spacings(5)
1457     table.attach(Gtk::Label.new.set_markup(utf8(_("<b>Label name:</b>"))).set_justify(Gtk::Justification::CENTER), 0, 1, 0, 1, Gtk::FILL, Gtk::FILL, 5, 0)
1458     table.attach(Gtk::Label.new.set_markup(utf8(_("<b>Amount of pictures:</b>"))).set_justify(Gtk::Justification::CENTER), 1, 2, 0, 1, Gtk::FILL, Gtk::FILL, 5, 0)
1459     table.attach(Gtk::Label.new.set_markup(utf8(_("<b>Pictures examples:</b>"))).set_justify(Gtk::Justification::CENTER), 2, 3, 0, 1, Gtk::FILL, Gtk::FILL, 5, 0)
1460     table.attach(Gtk::Label.new.set_markup(utf8(_("<b>Action to perform:</b>"))).set_justify(Gtk::Justification::CENTER), 3, 4, 0, 1, Gtk::FILL, Gtk::FILL, 5, 0)
1461     add_row = proc { |row, name, color, truthproc, normal|
1462         table.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(Gtk::EventBox.new.add(Gtk::Label.new.set_markup(name)).modify_bg(Gtk::StateType::NORMAL, color)),
1463                      0, 1, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1464         counter = 0
1465         examples = Gtk::HBox.new(false, 5)
1466         $allentries.each { |entry|
1467             if truthproc.call(entry)
1468                 counter += 1
1469                 if counter < 4
1470                     thumbnail = Gtk::Image.new(entry.pixbuf_thumbnail)
1471                     if entry.type == 'video'
1472                         thumbnail = Gtk::HBox.new.pack_start(da1 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false).
1473                                                   pack_start(thumbnail).
1474                                                   pack_start(da2 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false)
1475                         da1.signal_connect('realize') { da1.window.set_back_pixmap($videoborder_pixmap, false) }
1476                         da2.signal_connect('realize') { da2.window.set_back_pixmap($videoborder_pixmap, false) }
1477                     end
1478                     examples.pack_start(thumbnail, false, false)
1479                 elsif counter == 4
1480                     examples.pack_start(Gtk::Label.new.set_markup("<b>...</b>"), false, false)
1481                 end
1482             end
1483         }
1484         table.attach(Gtk::Label.new(counter.to_s).set_justify(Gtk::Justification::CENTER), 1, 2, row, row + 1, 0, 0, 5, 5)
1485         table.attach(examples, 2, 3, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1486
1487         if counter == 0
1488             return {}
1489         end
1490
1491         combostore = Gtk::ListStore.new(Gdk::Pixbuf, String)
1492         iter = combostore.append
1493         if normal
1494             iter[0] = $main_window.render_icon(Gtk::Stock::PASTE, Gtk::IconSize::MENU)
1495             iter[1] = utf8(_("Copy to:"))
1496             iter = combostore.append
1497             iter[0] = $main_window.render_icon(Gtk::Stock::GO_FORWARD, Gtk::IconSize::MENU)
1498             iter[1] = utf8(_("Move to:"))
1499         else
1500             iter[0] = $main_window.render_icon(Gtk::Stock::DELETE, Gtk::IconSize::MENU)
1501             iter[1] = utf8(_("Permanently remove"))
1502         end
1503         iter = combostore.append
1504         iter[0] = $main_window.render_icon(Gtk::Stock::MEDIA_STOP, Gtk::IconSize::MENU)
1505         iter[1] = utf8(_("Do nothing"))
1506         combo = Gtk::ComboBox.new(combostore)
1507         combo.active = 0
1508         renderer = Gtk::CellRendererPixbuf.new
1509         combo.pack_start(renderer, false)
1510         combo.set_attributes(renderer, :pixbuf => 0)
1511         renderer = Gtk::CellRendererText.new
1512         combo.pack_start(renderer, true)
1513         combo.set_attributes(renderer, :text => 1)
1514
1515         if normal
1516             pathbutton = Gtk::Button.new.add(pathlabel = Gtk::Label.new.set_markup(utf8(_("<i>(unset)</i>"))))
1517             pathbutton.signal_connect('clicked') {
1518                 fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory where to move the pictures to")),
1519                                                 nil,
1520                                                 Gtk::FileChooser::ACTION_SELECT_FOLDER,
1521                                                 nil,
1522                                                 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1523                 fc.transient_for = dialog
1524                 if lastpath
1525                     fc.current_folder = lastpath
1526                 end
1527                 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT && fc.filename
1528                     pathlabel.text = fc.filename
1529                     pathlabel.set_alignment(0, 0.5)
1530                 end
1531                 lastpath = fc.filename
1532                 fc.destroy
1533             }
1534             combo.signal_connect('changed') {
1535                 pathbutton.sensitive = combo.active <= 1
1536             }
1537             vb = Gtk::VBox.new(false, 5)
1538             vb.pack_start(combo, false, false)
1539             vb.pack_start(pathbutton, false, false)
1540             table.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(vb), 3, 4, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1541             { :combo => combo, :pathlabel => pathlabel }
1542         else
1543             table.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(combo), 3, 4, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1544             { :combo => combo }
1545         end
1546     }
1547     stuff = {}
1548     stuff['toremove'] = add_row.call(1, utf8(_("<i>to remove</i>")), $color_red, proc { |entry| entry.removed }, false)
1549     $ordered_labels.each_with_index { |label, row| stuff[label] = add_row.call(row + 2, label.name, label.color, proc { |entry| entry.labeled == label }, true) }
1550     vb1.pack_start(sw = Gtk::ScrolledWindow.new(nil, nil).add_with_viewport(table).set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC), true, true)
1551
1552     toremove_amount = $allentries.find_all { |entry| entry.removed }.size
1553     toremove_size = commify($allentries.find_all { |entry| entry.removed }.collect { |entry| file_size(entry.path) }.sum / 1024)
1554     check_removal = Gtk::CheckButton.new(utf8(_("I have noticed I am about to permanently remove the %d above mentioned pictures (total %s kB).") % [ toremove_amount, toremove_size ]))
1555     if toremove_amount > 0
1556         vb1.pack_start(check_removal, false, false)
1557         stuff['toremove'][:combo].signal_connect('changed') { |widget|
1558             check_removal.sensitive = widget.active == 0
1559         }
1560     end
1561
1562     dialog.vbox.add(vb1)
1563
1564     dialog.set_default_size(800, 600)
1565     dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL)
1566     dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
1567     dialog.window_position = Gtk::Window::POS_MOUSE
1568     dialog.transient_for = $main_window
1569
1570     dialog.show_all
1571
1572     while true
1573         dialog.run { |response|        
1574             if response == Gtk::Dialog::RESPONSE_OK
1575                 problem = false
1576                 if toremove_amount > 0 && stuff['toremove'][:combo].active == 0
1577                     if ! check_removal.active?
1578                         show_popup(dialog, utf8(_("You have not confirmed that you noticed the permanent removal of the pictures marked for deletion.")))
1579                         problem = true
1580                         break
1581                     end
1582                     $allentries.each { |entry|
1583                         if entry.removed
1584                             if ! FileTest.writable?(entry.path)
1585                                 show_popup(dialog, utf8(_("Sorry, permission denied to remove '%s'.") % [ entry.path ]))
1586                                 problem = true
1587                                 break
1588                             end
1589                         end
1590                     }
1591                 end
1592                 label2entries = {}
1593                 $labels.values.each { |label| label2entries[label] = [] }
1594                 $allentries.each { |entry| entry.labeled and label2entries[entry.labeled] << entry }
1595                 stuff.keys.each { |key|
1596                     if key.is_a?(Label) && stuff[key][:combo] && stuff[key][:combo].active <= 1
1597                         destination = stuff[key][:pathlabel].text
1598                         if destination[0] != ?/
1599                             show_popup(dialog, utf8(_("You have not selected a directory where to move/copy %s.") % key.name))
1600                             problem = true
1601                             break
1602                         end
1603                         begin
1604                             Dir.mkdir(destination)
1605                         rescue
1606                         end
1607                         begin
1608                             st = File.stat(destination)
1609                         rescue
1610                             show_popup(dialog, utf8(_("Directory %s, where to move/copy %s, is not valid or not createable.") % [destination, key.name]))
1611                             problem = true
1612                             break
1613                         end
1614                         if ! st.directory? || ! writable(destination)
1615                             show_popup(dialog, utf8(_("Directory %s, where to move/copy %s, is not valid or not writable.") % [destination, key.name]))
1616                             problem = true
1617                             break
1618                         end
1619                         label2entries[key].each { |entry|
1620                             begin
1621                                 File.stat(File.join(destination, File.basename(entry.path)))
1622                                 show_popup(dialog, utf8(_("Sorry, a file '%s' already exists in directory '%s'.") % [ File.basename(entry.path), destination ]))
1623                                 problem = true
1624                                 break
1625                             rescue
1626                             end
1627                         }
1628                         if stuff[key][:combo].active == 1
1629                             label2entries[key].each { |entry|
1630                                 if ! FileTest.writable?(entry.path)
1631                                     show_popup(dialog, utf8(_("Sorry, permission denied to move '%s'.") % [ entry.path ]))
1632                                     problem = true
1633                                     break
1634                                 end
1635                             }
1636                         end
1637                         if problem
1638                             break
1639                         end
1640                     end
1641                 }
1642                 if ! problem
1643                     begin
1644                         moved = 0
1645                         copied = 0
1646                         ignored_errors = []
1647                         stuff.keys.each { |key|
1648                             if key.is_a?(Label) && stuff[key][:combo] && stuff[key][:combo].active <= 1
1649                                 destination = stuff[key][:pathlabel].text
1650                                 label2entries[key].each { |entry|
1651                                     if stuff[key][:combo].active == 0
1652                                         result = `cp -dp '#{entry.path}' '#{destination}' 2>&1`
1653                                     elsif stuff[key][:combo].active == 1
1654                                         result = `mv '#{entry.path}' '#{destination}' 2>&1`
1655                                     end
1656                                     if $?.exitstatus > 0
1657                                         simplified_error = result.sub(/#{Regexp.quote(destination + '/' + File.basename(entry.path))}/, '').  #'
1658                                                                   sub(/#{Regexp.quote(entry.path)}/, '').
1659                                                                   sub(/#{Regexp.quote(File.basename(entry.path))}/, '')
1660                                         if ! ignored_errors.include?(simplified_error)
1661                                             response = show_popup($main_window,
1662                                                                   utf8(_("Failure:\n\n%s\nDo you wish to continue?" % result)),
1663                                                                   { :yestoall => true })
1664                                             if response == 'no'
1665                                                 raise "failure on '#{entry.path}'"
1666                                             elsif response == 'yestoall'
1667                                                 ignored_errors << simplified_error
1668                                             end
1669                                         end
1670                                     else
1671                                         if stuff[key][:combo].active == 0
1672                                             copied += 1
1673                                         else
1674                                             moved += 1
1675                                         end
1676                                     end
1677                                 }
1678                             end
1679                         }
1680                         removed = 0
1681                         if stuff['toremove'][:combo] && stuff['toremove'][:combo].active == 0
1682                             $allentries.each { |entry|
1683                                 if entry.removed
1684                                     File.delete(entry.path)
1685                                     removed += 1
1686                                 end
1687                             }
1688                         end
1689                     rescue
1690                         msg 1, "woops: #{$!}\n" + $@.join("\n")
1691                     end
1692                     show_popup(dialog, utf8(_("Successfully moved %d files, copied %d file, and removed %d files.") % [ moved, copied, removed ]))
1693                     dialog.destroy
1694                     reset_all
1695                     return
1696                 end
1697
1698             else
1699                 dialog.destroy
1700                 return
1701             end
1702         }
1703     end
1704 end
1705
1706 def visible(entry)
1707     if ! entry
1708         #- just "executed"
1709         return
1710     end
1711     if ! entry.button
1712         #- not yet loaded
1713         return
1714     end
1715     if entry.labeled
1716         if entry.labeled.button.active?
1717             return true
1718         else
1719             return false
1720         end
1721     elsif entry.removed
1722         if $toremove_button.active?
1723             return true
1724         else
1725             return false
1726         end
1727     else
1728         if $unlabelled_button.active?
1729             return true
1730         else
1731             return false
1732         end
1733     end
1734 end
1735
1736 def update_visibility(entry)
1737     v = visible(entry)
1738     if v.nil?
1739         return
1740     end
1741     if v
1742         entry.button.show
1743     else
1744         entry.button.hide
1745     end
1746 end
1747         
1748 def update_all_visibilities_aux
1749     $allentries.each { |entry|
1750         update_visibility(entry)
1751     }
1752     shown = $mainview.get_shown_entry
1753     shown or return
1754     while shown.button && ! shown.button.visible? && shown != $allentries.last
1755         shown = $allentries[$allentries.index(shown) + 1]
1756     end 
1757     if shown.button && shown.button.visible?
1758         shown.button.grab_focus
1759         return
1760     end
1761     $allentries.reverse.each { |entry|
1762         if entry.button && entry.button.visible?
1763             entry.button.grab_focus
1764             return
1765         end
1766     }
1767 end
1768
1769 def update_all_visibilities
1770     update_all_visibilities_aux
1771     Gtk.main_iteration while Gtk.events_pending?
1772     shown = $mainview.get_shown_entry
1773     shown and autoscroll_if_needed(shown.button, false)
1774 end
1775
1776
1777 def preferences
1778     dialog = Gtk::Dialog.new(utf8(_("Edit preferences")),
1779                              $main_window,
1780                              Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1781                              [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
1782                              [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1783
1784     tooltips = Gtk::Tooltips.new
1785
1786     table_y = 0
1787
1788     dialog.vbox.add(tbl = Gtk::Table.new(0, 0, false))
1789     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Command for watching videos: ")))),
1790                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1791     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(video_viewer_entry = Gtk::Entry.new.set_text($config['video-viewer']).set_size_request(250, -1)),
1792                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1793     tooltips.set_tip(video_viewer_entry, utf8(_("Use %f to specify the filename;\nfor example: /usr/bin/mplayer %f")), nil)
1794
1795     table_y += 1
1796     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Browser's command: ")))),
1797                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1798     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(browser_entry = Gtk::Entry.new.set_text($config['browser'])),
1799                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1800     tooltips.set_tip(browser_entry, utf8(_("Use %f to specify the filename;\nfor example: /usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f")), nil)
1801
1802     table_y += 1
1803     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Thumbnails height: ")))),
1804                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1805     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(thumbnails_height = Gtk::SpinButton.new(32, 256, 16).set_value($config['thumbnails-height'].to_i)),
1806                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1807     tooltips.set_tip(thumbnails_height, utf8(_("The desired height of the thumbnails in the thumbnails line of the bottom")), nil)
1808
1809     table_y += 1
1810     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Preloading distance: ")))),
1811                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1812     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(preload_distance = Gtk::SpinButton.new(0, 50, 1).set_value($config['preload-distance'].to_i)),
1813                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1814     tooltips.set_tip(preload_distance, utf8(_("Amount of pictures preloaded left and right to the currently shown")), nil)
1815
1816     table_y += 1
1817     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Cache memory use: ")))),
1818                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1819     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(cache_vbox = Gtk::VBox.new(false, 0)),
1820                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1821     cache_vbox.pack_start(Gtk::HBox.new(false, 0).pack_start(cache_memfree_radio = Gtk::RadioButton.new(''), false, false).
1822                                                   pack_start(cache_memfree_spin = Gtk::SpinButton.new(0, 100, 10), false, false).
1823                                                   pack_start(cache_memfree_label = Gtk::Label.new(utf8(_("% of free memory"))), false, false), false, false)
1824     cache_memfree_spin.signal_connect('value-changed') { cache_memfree_radio.active = true }
1825     tooltips.set_tip(cache_memfree_spin, utf8(_("Percentage of free memory (+ buffers/cache) measured at startup")), nil)
1826     cache_vbox.pack_start(Gtk::HBox.new(false, 0).pack_start(cache_specify_radio = Gtk::RadioButton.new(cache_memfree_radio, ''), false, false).
1827                                                   pack_start(cache_specify_spin = Gtk::SpinButton.new(0, 4000, 50), false, false).
1828                                                   pack_start(cache_specify_label = Gtk::Label.new(utf8(_("MB"))).set_sensitive(false), false, false), false, false)
1829     cache_specify_spin.signal_connect('value-changed') { cache_specify_radio.active = true }
1830     cache_memfree_radio.signal_connect('toggled') {
1831         if cache_memfree_radio.active?
1832             cache_memfree_label.sensitive = true
1833             cache_specify_label.sensitive = false
1834         else
1835             cache_specify_label.sensitive = true
1836             cache_memfree_label.sensitive = false
1837         end
1838     }
1839     tooltips.set_tip(cache_specify_spin, utf8(_("Amount of memory in megabytes")), nil)
1840     if $config['cache-memory-use'] =~ /memfree_(\d+)/
1841         cache_memfree_spin.value = $1.to_i
1842     else
1843         cache_specify_spin.value = $config['cache-memory-use'].to_i / 1024
1844     end
1845
1846     table_y += 1
1847     tbl.attach(update_exif_orientation_check = Gtk::CheckButton.new(utf8(_("Update file's EXIF orientation when rotating a picture"))),
1848                0, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1849     tooltips.set_tip(update_exif_orientation_check, utf8(_("When rotating a picture (Alt-Right/Left), also update EXIF orientation in the file itself")), nil)
1850     update_exif_orientation_check.active = $config['rotate-set-exif'] == 'true'
1851
1852     dialog.vbox.show_all
1853     dialog.run { |response|
1854         if response == Gtk::Dialog::RESPONSE_OK
1855             $config['video-viewer'] = from_utf8(video_viewer_entry.text)
1856             $config['browser'] = from_utf8(browser_entry.text)
1857             $config['thumbnails-height'] = thumbnails_height.value
1858             $config['preload-distance'] = preload_distance.value
1859             $config['rotate-set-exif'] = update_exif_orientation_check.active?.to_s
1860             if cache_memfree_radio.active?
1861                 $config['cache-memory-use'] = "memfree_#{cache_memfree_spin.value}%"
1862             else
1863                 $config['cache-memory-use'] = cache_specify_spin.value.to_i * 1024
1864             end
1865             set_cache_memory_use_figure
1866         end
1867     }
1868     dialog.destroy
1869 end
1870
1871 def perform_undo
1872     if $undo_mb.sensitive?
1873         $redo_mb.sensitive = true
1874         if not more_undoes = UndoHandler.undo($statusbar)
1875             $undo_mb.sensitive = false
1876         end
1877     end
1878 end
1879
1880 def perform_redo
1881     if $redo_mb.sensitive?
1882         $undo_mb.sensitive = true
1883         if not more_redoes = UndoHandler.redo($statusbar)
1884             $redo_mb.sensitive = false
1885         end
1886     end
1887 end
1888
1889 def create_menubar    
1890     #- menu
1891     mb = Gtk::MenuBar.new
1892
1893     filemenu = Gtk::MenuItem.new(utf8(_("_File")))
1894     filesubmenu = Gtk::Menu.new
1895     filesubmenu.append(open      = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
1896     filesubmenu.append(            Gtk::SeparatorMenuItem.new)
1897     filesubmenu.append($execute  = Gtk::ImageMenuItem.new(Gtk::Stock::EXECUTE).set_sensitive(false))
1898     filesubmenu.append(            Gtk::SeparatorMenuItem.new)
1899     filesubmenu.append(quit      = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
1900     filemenu.set_submenu(filesubmenu)
1901     mb.append(filemenu)
1902
1903     open.signal_connect('activate') { open_dir_popup }
1904     $execute.signal_connect('activate') { execute }
1905     quit.signal_connect('activate') { try_quit }
1906
1907     editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
1908     editsubmenu = Gtk::Menu.new
1909     editsubmenu.append($undo_mb    = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
1910     editsubmenu.append($redo_mb    = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
1911     editsubmenu.append(              Gtk::SeparatorMenuItem.new)
1912     editsubmenu.append(prefs       = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
1913     editmenu.set_submenu(editsubmenu)
1914     mb.append(editmenu)
1915
1916     $undo_mb.signal_connect('activate') { perform_undo }
1917     $redo_mb.signal_connect('activate') { perform_redo }
1918     prefs.signal_connect('activate') { preferences }
1919     
1920     helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
1921     helpsubmenu = Gtk::Menu.new
1922     helpsubmenu.append(howto = Gtk::ImageMenuItem.new(Gtk::Stock::HELP))
1923     helpsubmenu.append(speed = Gtk::ImageMenuItem.new(utf8(_("Speedup: key shortcuts"))))
1924     speed.image = Gtk::Image.new("#{$FPATH}/images/stock-info-16.png")
1925     helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
1926     tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
1927     helpsubmenu.append(Gtk::SeparatorMenuItem.new)
1928     helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
1929     helpmenu.set_submenu(helpsubmenu)
1930     mb.append(helpmenu)
1931
1932     howto.signal_connect('activate') {
1933         show_popup($main_window, utf8(_("<span size='large' weight='bold'>Help</span>
1934
1935 1. Open a directory with <span foreground='darkblue'>File/Open</span>; the classifier will scan it (including subdirectories) and
1936 show thumbnails for all photos and videos at the bottom.
1937
1938 2. You can then navigate through images with the <span foreground='darkblue'>Left/Right</span> keyboard keys, or by <span foreground='darkblue'>clicking</span>
1939 on thumbnails.
1940
1941 3. You may associate a <span foreground='darkblue'>label</span> to each thumbnail. Either hit the <span foreground='darkblue'>Delete</span> key to associate
1942 the built-in <i>to remove</i> label, or hit any alphabetical key to associate a label you define.
1943 The first time you hit a key without any label associated, a popup will ask for the full
1944 name of this label, and what color you want. To clear the current label, hit the <span foreground='darkblue'>Space</span> key.
1945
1946 4. To help you better view what thumbnails are associated to your labels, you may <span foreground='darkblue'>hide</span>
1947 some of them by unchecking the labels checkboxes on the left.
1948
1949 5. Once you're finished reviewing all thumbnails, use <span foreground='darkblue'>File/Execute</span> to execute the desired
1950 actions according to associated labels. You can permanently remove (or not) images with
1951 the <i>to remove</i> label, and copy or move images with the labels you defined.
1952 ")), { :pos_centered => true, :not_transient => true })
1953     }
1954     speed.signal_connect('activate') {
1955         show_popup($main_window, utf8(_("<span size='large' weight='bold'>Key shortcuts</span>
1956
1957 <span foreground='darkblue'>Left/Right</span>: move left and right in images
1958 <span foreground='darkblue'>Enter</span>: 'view' current image: for images, display EXIF data; for videos, play it
1959 <span foreground='darkblue'>Alt-Left/Right</span>: rotate current image clockwise/counter-clockwise
1960 <span foreground='darkblue'>Delete</span>: assign the 'to remove' label on current image
1961 <span foreground='darkblue'>Space</span>: clear any label on current image
1962 <span foreground='darkblue'>Control-z</span>: undo
1963 <span foreground='darkblue'>Control-r</span>: redo
1964 <span foreground='darkblue'>Control-Space</span>: recenter thumbnails on current item
1965
1966 Any alphabetical key will assign (or popup for) the associated label on current image.
1967 ")), { :pos_centered => true, :not_transient => true })
1968     }
1969     tutos.signal_connect('activate') { open_url('http://booh.org/tutorial') }
1970     about.signal_connect('activate') { call_about }
1971
1972
1973     #- no toolbar, to save height
1974
1975     return mb
1976 end
1977
1978 def reset_labels
1979     for child in $labels_vbox.children
1980         $labels_vbox.remove(child)
1981     end
1982     $labels_vbox.pack_start(Gtk::Label.new(utf8(_("Labels list:"))).set_justify(Gtk::Justification::CENTER), false, false).show_all
1983     $labels = {}
1984     $ordered_labels = []
1985     lbl = Gtk::Label.new.set_markup(utf8(_("<i>unlabelled</i>")))
1986     $labels_vbox.pack_start(Gtk::HBox.new(false, 5).pack_start($unlabelled_button = Gtk::CheckButton.new.add(Gtk::EventBox.new.add(lbl)), false, false).
1987                                                     pack_start(Gtk::Label.new, true, true).  #- I suck
1988                                                     pack_start($unlabelled_counter = Gtk::Label.new.set_markup('<tt>0</tt>'), false, false).show_all)
1989     $unlabelled_button.active = true
1990     $unlabelled_button.signal_connect('toggled') { update_all_visibilities }
1991     lbl = Gtk::Label.new.set_markup(utf8(_("<i>to remove</i>")))
1992     $labels_vbox.pack_start(Gtk::HBox.new(false, 5).pack_start($toremove_button = Gtk::CheckButton.new.add(evt = Gtk::EventBox.new.add(lbl)), false, false).
1993                                                     pack_start(Gtk::Label.new, true, true).
1994                                                     pack_start($toremove_counter = Gtk::Label.new.set_markup('<tt>0</tt>'), false, false).show_all)
1995     $toremove_button.active = true
1996     $toremove_button.signal_connect('toggled') { update_all_visibilities }
1997     evt.modify_bg(Gtk::StateType::NORMAL, $color_red)
1998     evt.modify_bg(Gtk::StateType::PRELIGHT, $color_red.lighter.lighter)
1999     evt.modify_bg(Gtk::StateType::ACTIVE, $color_red.lighter)
2000 end
2001
2002 def cleanup_loaders
2003     $allentries.each { |e| 
2004         e.cancel_loader
2005     }
2006 end
2007
2008 def reset_thumbnails
2009     cleanup_loaders
2010     $allentries = []
2011     if $preloader_running
2012         $preloader_force_exit = true
2013     end
2014     for child in $imagesline.children
2015         $imagesline.remove(child)
2016     end
2017     set_imagesline_size_request
2018 end
2019
2020 def set_imagesline_size_request
2021     $imagesline.set_size_request(-1, Gtk::Button.new.size_request[1] + Entry.thumbnails_height + Entry.thumbnails_height/4)
2022 end
2023
2024 def create_main_window
2025
2026     $videoborder_pixbuf = Gdk::Pixbuf.new("#{$FPATH}/images/video_border.png")
2027     $videoborder_pixmap, = $videoborder_pixbuf.render_pixmap_and_mask(0)
2028
2029     mb = create_menubar
2030
2031     main_vbox = Gtk::VBox.new(false, 0)
2032     main_vbox.pack_start(mb, false, false)
2033     mainview_hbox = Gtk::HBox.new
2034     mainview_hbox.pack_start(Gtk::Alignment.new(0.5, 0, 1, 1).add(left_vbox = Gtk::VBox.new(false, 5)), false, true)
2035     left_vbox.pack_start(($labels_vbox = Gtk::VBox.new(false, 5)), false, true)
2036     left_vbox.pack_end($loading_progressbar = Gtk::ProgressBar.new.set_text(utf8(_("Loading... %d%") % 0)), false, true)
2037     mainview_hbox.pack_start($mainview = MainView.new, true, true)
2038     main_vbox.pack_start(mainview_hbox, true, true)
2039     $imagesline_sw = Gtk::ScrolledWindow.new(nil, nil)
2040     $imagesline_sw.set_policy(Gtk::POLICY_ALWAYS, Gtk::POLICY_NEVER)
2041     $imagesline_sw.add_with_viewport($imagesline = Gtk::HBox.new(false, 0).show)
2042     main_vbox.pack_start($imagesline_sw, false, false)
2043     main_vbox.pack_end($statusbar = Gtk::Statusbar.new, false, false)
2044
2045     set_imagesline_size_request
2046
2047     $main_window = create_window
2048     $main_window.add(main_vbox)
2049     $main_window.signal_connect('delete-event') {
2050         try_quit
2051     }
2052
2053     #- read/save size and position of window
2054     if $config['pos-x'] && $config['pos-y']
2055         $main_window.move($config['pos-x'].to_i, $config['pos-y'].to_i)
2056     else
2057         $main_window.window_position = Gtk::Window::POS_CENTER
2058     end
2059     msg 3, "size: #{$config['width']}x#{$config['height']}"
2060     $main_window.set_default_size(($config['width'] || 800).to_i, ($config['height'] || 600).to_i)
2061     $main_window.signal_connect('configure-event') {
2062         msg 3, "configure: pos: #{$main_window.window.root_origin.inspect} size: #{$main_window.window.size.inspect}"
2063         x, y = $main_window.window.root_origin
2064         width, height = $main_window.window.size
2065         $config['pos-x'] = x
2066         $config['pos-y'] = y
2067         $config['width'] = width
2068         $config['height'] = height
2069         false
2070     }
2071
2072     $main_window.show_all
2073     $loading_progressbar.hide
2074 end
2075
2076
2077 handle_options
2078 read_config
2079 Gtk.init
2080
2081
2082 create_main_window
2083 check_config
2084
2085 if ARGV[0]
2086     if msg = open_dir(*ARGV)
2087         puts msg
2088     else
2089         Gtk.idle_add {
2090             show_entries($allentries)
2091             false
2092         }
2093     end
2094 end
2095 Gtk.main
2096
2097 cleanup_loaders
2098
2099 write_config