aa34ed6172d88b9ca5ad9f64bdc00143c8913892
[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-2011 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-2011 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                 end
1379             }
1380             entries = smartsort(entries, dates)
1381         else
1382             entries.sort!
1383         end
1384         entries.each { |file|
1385             type = entry2type(file)
1386             if type
1387                 if File.directory?(path)
1388                     $allentries << Entry.new(file, type, file[path.length + 1 .. -1])
1389                 else
1390                     $allentries << Entry.new(file, type, file)
1391                 end
1392             end
1393         }
1394     end
1395     return nil
1396 end
1397
1398 def open_dir_popup
1399     fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory to work with")),
1400                                     nil,
1401                                     Gtk::FileChooser::ACTION_SELECT_FOLDER,
1402                                     nil,
1403                                     [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1404     fc.transient_for = $main_window
1405     if $workingdir
1406         fc.current_folder = $workingdir
1407     end
1408     ok = false
1409     load = false
1410     while !ok
1411         if fc.run == Gtk::Dialog::RESPONSE_ACCEPT && fc.filename
1412             msg = open_dir(fc.filename)
1413             if msg
1414                 show_popup(fc, msg)
1415                 ok = false
1416             else
1417                 ok = true
1418                 load = true
1419             end
1420         else
1421             ok = true
1422         end
1423     end
1424     fc.destroy
1425     if load
1426         show_entries($allentries)
1427     end
1428 end
1429
1430 def try_quit(*options)
1431     if ! $allentries.detect { |e| e.removed || e.labeled } || show_popup($main_window,
1432                                                                          utf8(_("You have not executed the classification. Are you sure you want to quit?")),
1433                                                                          { :okcancel => true })
1434         Gtk.main_quit
1435         $quit = true
1436         return false
1437     else
1438         return true
1439     end
1440 end
1441
1442 def execute
1443     dialog = Gtk::Dialog.new
1444     dialog.title = utf8(_("Booh message"))
1445
1446     vb1 = Gtk::VBox.new(false, 5)
1447     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!")))
1448     vb1.pack_start(label, false, false)
1449
1450     lastpath = $workingdir
1451
1452     table = Gtk::Table.new(0, 0, false)
1453     table.set_row_spacings(5)
1454     table.set_column_spacings(5)
1455     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)
1456     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)
1457     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)
1458     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)
1459     add_row = proc { |row, name, color, truthproc, normal|
1460         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)),
1461                      0, 1, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1462         counter = 0
1463         examples = Gtk::HBox.new(false, 5)
1464         $allentries.each { |entry|
1465             if truthproc.call(entry)
1466                 counter += 1
1467                 if counter < 4
1468                     thumbnail = Gtk::Image.new(entry.pixbuf_thumbnail)
1469                     if entry.type == 'video'
1470                         thumbnail = Gtk::HBox.new.pack_start(da1 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false).
1471                                                   pack_start(thumbnail).
1472                                                   pack_start(da2 = Gtk::DrawingArea.new.set_size_request($videoborder_pixbuf.width, -1), false, false)
1473                         da1.signal_connect('realize') { da1.window.set_back_pixmap($videoborder_pixmap, false) }
1474                         da2.signal_connect('realize') { da2.window.set_back_pixmap($videoborder_pixmap, false) }
1475                     end
1476                     examples.pack_start(thumbnail, false, false)
1477                 elsif counter == 4
1478                     examples.pack_start(Gtk::Label.new.set_markup("<b>...</b>"), false, false)
1479                 end
1480             end
1481         }
1482         table.attach(Gtk::Label.new(counter.to_s).set_justify(Gtk::Justification::CENTER), 1, 2, row, row + 1, 0, 0, 5, 5)
1483         table.attach(examples, 2, 3, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1484
1485         if counter == 0
1486             return {}
1487         end
1488
1489         combostore = Gtk::ListStore.new(Gdk::Pixbuf, String)
1490         iter = combostore.append
1491         if normal
1492             iter[0] = $main_window.render_icon(Gtk::Stock::PASTE, Gtk::IconSize::MENU)
1493             iter[1] = utf8(_("Copy to:"))
1494             iter = combostore.append
1495             iter[0] = $main_window.render_icon(Gtk::Stock::GO_FORWARD, Gtk::IconSize::MENU)
1496             iter[1] = utf8(_("Move to:"))
1497         else
1498             iter[0] = $main_window.render_icon(Gtk::Stock::DELETE, Gtk::IconSize::MENU)
1499             iter[1] = utf8(_("Permanently remove"))
1500         end
1501         iter = combostore.append
1502         iter[0] = $main_window.render_icon(Gtk::Stock::MEDIA_STOP, Gtk::IconSize::MENU)
1503         iter[1] = utf8(_("Do nothing"))
1504         combo = Gtk::ComboBox.new(combostore)
1505         combo.active = 0
1506         renderer = Gtk::CellRendererPixbuf.new
1507         combo.pack_start(renderer, false)
1508         combo.set_attributes(renderer, :pixbuf => 0)
1509         renderer = Gtk::CellRendererText.new
1510         combo.pack_start(renderer, true)
1511         combo.set_attributes(renderer, :text => 1)
1512
1513         if normal
1514             pathbutton = Gtk::Button.new.add(pathlabel = Gtk::Label.new.set_markup(utf8(_("<i>(unset)</i>"))))
1515             pathbutton.signal_connect('clicked') {
1516                 fc = Gtk::FileChooserDialog.new(utf8(_("Specify the directory where to move the pictures to")),
1517                                                 nil,
1518                                                 Gtk::FileChooser::ACTION_SELECT_FOLDER,
1519                                                 nil,
1520                                                 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1521                 fc.transient_for = dialog
1522                 if lastpath
1523                     fc.current_folder = lastpath
1524                 end
1525                 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT && fc.filename
1526                     pathlabel.text = fc.filename
1527                     pathlabel.set_alignment(0, 0.5)
1528                 end
1529                 lastpath = fc.filename
1530                 fc.destroy
1531             }
1532             combo.signal_connect('changed') {
1533                 pathbutton.sensitive = combo.active <= 1
1534             }
1535             vb = Gtk::VBox.new(false, 5)
1536             vb.pack_start(combo, false, false)
1537             vb.pack_start(pathbutton, false, false)
1538             table.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(vb), 3, 4, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1539             { :combo => combo, :pathlabel => pathlabel }
1540         else
1541             table.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(combo), 3, 4, row, row + 1, Gtk::FILL, Gtk::FILL, 5, 5)
1542             { :combo => combo }
1543         end
1544     }
1545     stuff = {}
1546     stuff['toremove'] = add_row.call(1, utf8(_("<i>to remove</i>")), $color_red, proc { |entry| entry.removed }, false)
1547     $ordered_labels.each_with_index { |label, row| stuff[label] = add_row.call(row + 2, label.name, label.color, proc { |entry| entry.labeled == label }, true) }
1548     vb1.pack_start(sw = Gtk::ScrolledWindow.new(nil, nil).add_with_viewport(table).set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC), true, true)
1549
1550     toremove_amount = $allentries.find_all { |entry| entry.removed }.size
1551     toremove_size = commify($allentries.find_all { |entry| entry.removed }.collect { |entry| file_size(entry.path) }.sum / 1024)
1552     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 ]))
1553     if toremove_amount > 0
1554         vb1.pack_start(check_removal, false, false)
1555         stuff['toremove'][:combo].signal_connect('changed') { |widget|
1556             check_removal.sensitive = widget.active == 0
1557         }
1558     end
1559
1560     dialog.vbox.add(vb1)
1561
1562     dialog.set_default_size(800, 600)
1563     dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL)
1564     dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
1565     dialog.window_position = Gtk::Window::POS_MOUSE
1566     dialog.transient_for = $main_window
1567
1568     dialog.show_all
1569
1570     while true
1571         dialog.run { |response|        
1572             if response == Gtk::Dialog::RESPONSE_OK
1573                 problem = false
1574                 if toremove_amount > 0 && stuff['toremove'][:combo].active == 0
1575                     if ! check_removal.active?
1576                         show_popup(dialog, utf8(_("You have not confirmed that you noticed the permanent removal of the pictures marked for deletion.")))
1577                         problem = true
1578                         break
1579                     end
1580                     $allentries.each { |entry|
1581                         if entry.removed
1582                             if ! FileTest.writable?(entry.path)
1583                                 show_popup(dialog, utf8(_("Sorry, permission denied to remove '%s'.") % [ entry.path ]))
1584                                 problem = true
1585                                 break
1586                             end
1587                         end
1588                     }
1589                 end
1590                 label2entries = {}
1591                 $labels.values.each { |label| label2entries[label] = [] }
1592                 $allentries.each { |entry| entry.labeled and label2entries[entry.labeled] << entry }
1593                 stuff.keys.each { |key|
1594                     if key.is_a?(Label) && stuff[key][:combo] && stuff[key][:combo].active <= 1
1595                         destination = stuff[key][:pathlabel].text
1596                         if destination[0] != ?/
1597                             show_popup(dialog, utf8(_("You have not selected a directory where to move/copy %s.") % key.name))
1598                             problem = true
1599                             break
1600                         end
1601                         begin
1602                             Dir.mkdir(destination)
1603                         rescue
1604                         end
1605                         begin
1606                             st = File.stat(destination)
1607                         rescue
1608                             show_popup(dialog, utf8(_("Directory %s, where to move/copy %s, is not valid or not createable.") % [destination, key.name]))
1609                             problem = true
1610                             break
1611                         end
1612                         if ! st.directory? || ! writable(destination)
1613                             show_popup(dialog, utf8(_("Directory %s, where to move/copy %s, is not valid or not writable.") % [destination, key.name]))
1614                             problem = true
1615                             break
1616                         end
1617                         label2entries[key].each { |entry|
1618                             begin
1619                                 File.stat(File.join(destination, File.basename(entry.path)))
1620                                 show_popup(dialog, utf8(_("Sorry, a file '%s' already exists in directory '%s'.") % [ File.basename(entry.path), destination ]))
1621                                 problem = true
1622                                 break
1623                             rescue
1624                             end
1625                         }
1626                         if stuff[key][:combo].active == 1
1627                             label2entries[key].each { |entry|
1628                                 if ! FileTest.writable?(entry.path)
1629                                     show_popup(dialog, utf8(_("Sorry, permission denied to move '%s'.") % [ entry.path ]))
1630                                     problem = true
1631                                     break
1632                                 end
1633                             }
1634                         end
1635                         if problem
1636                             break
1637                         end
1638                     end
1639                 }
1640                 if ! problem
1641                     begin
1642                         moved = 0
1643                         copied = 0
1644                         ignored_errors = []
1645                         stuff.keys.each { |key|
1646                             if key.is_a?(Label) && stuff[key][:combo] && stuff[key][:combo].active <= 1
1647                                 destination = stuff[key][:pathlabel].text
1648                                 label2entries[key].each { |entry|
1649                                     if stuff[key][:combo].active == 0
1650                                         result = `cp -dp '#{entry.path}' '#{destination}' 2>&1`
1651                                     elsif stuff[key][:combo].active == 1
1652                                         result = `mv '#{entry.path}' '#{destination}' 2>&1`
1653                                     end
1654                                     if $?.exitstatus > 0
1655                                         simplified_error = result.sub(/#{Regexp.quote(destination + '/' + File.basename(entry.path))}/, '').  #'
1656                                                                   sub(/#{Regexp.quote(entry.path)}/, '').
1657                                                                   sub(/#{Regexp.quote(File.basename(entry.path))}/, '')
1658                                         if ! ignored_errors.include?(simplified_error)
1659                                             response = show_popup($main_window,
1660                                                                   utf8(_("Failure:\n\n%s\nDo you wish to continue?" % result)),
1661                                                                   { :yestoall => true })
1662                                             if response == 'no'
1663                                                 raise "failure on '#{entry.path}'"
1664                                             elsif response == 'yestoall'
1665                                                 ignored_errors << simplified_error
1666                                             end
1667                                         end
1668                                     else
1669                                         if stuff[key][:combo].active == 0
1670                                             copied += 1
1671                                         else
1672                                             moved += 1
1673                                         end
1674                                     end
1675                                 }
1676                             end
1677                         }
1678                         removed = 0
1679                         if stuff['toremove'][:combo] && stuff['toremove'][:combo].active == 0
1680                             $allentries.each { |entry|
1681                                 if entry.removed
1682                                     File.delete(entry.path)
1683                                     removed += 1
1684                                 end
1685                             }
1686                         end
1687                     rescue
1688                         msg 1, "woops: #{$!}\n" + $@.join("\n")
1689                     end
1690                     show_popup(dialog, utf8(_("Successfully moved %d files, copied %d file, and removed %d files.") % [ moved, copied, removed ]))
1691                     dialog.destroy
1692                     reset_all
1693                     return
1694                 end
1695
1696             else
1697                 dialog.destroy
1698                 return
1699             end
1700         }
1701     end
1702 end
1703
1704 def visible(entry)
1705     if ! entry
1706         #- just "executed"
1707         return
1708     end
1709     if ! entry.button
1710         #- not yet loaded
1711         return
1712     end
1713     if entry.labeled
1714         if entry.labeled.button.active?
1715             return true
1716         else
1717             return false
1718         end
1719     elsif entry.removed
1720         if $toremove_button.active?
1721             return true
1722         else
1723             return false
1724         end
1725     else
1726         if $unlabelled_button.active?
1727             return true
1728         else
1729             return false
1730         end
1731     end
1732 end
1733
1734 def update_visibility(entry)
1735     v = visible(entry)
1736     if v.nil?
1737         return
1738     end
1739     if v
1740         entry.button.show
1741     else
1742         entry.button.hide
1743     end
1744 end
1745         
1746 def update_all_visibilities_aux
1747     $allentries.each { |entry|
1748         update_visibility(entry)
1749     }
1750     shown = $mainview.get_shown_entry
1751     shown or return
1752     while shown.button && ! shown.button.visible? && shown != $allentries.last
1753         shown = $allentries[$allentries.index(shown) + 1]
1754     end 
1755     if shown.button && shown.button.visible?
1756         shown.button.grab_focus
1757         return
1758     end
1759     $allentries.reverse.each { |entry|
1760         if entry.button && entry.button.visible?
1761             entry.button.grab_focus
1762             return
1763         end
1764     }
1765 end
1766
1767 def update_all_visibilities
1768     update_all_visibilities_aux
1769     Gtk.main_iteration while Gtk.events_pending?
1770     shown = $mainview.get_shown_entry
1771     shown and autoscroll_if_needed(shown.button, false)
1772 end
1773
1774
1775 def preferences
1776     dialog = Gtk::Dialog.new(utf8(_("Edit preferences")),
1777                              $main_window,
1778                              Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1779                              [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
1780                              [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1781
1782     tooltips = Gtk::Tooltips.new
1783
1784     table_y = 0
1785
1786     dialog.vbox.add(tbl = Gtk::Table.new(0, 0, false))
1787     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Command for watching videos: ")))),
1788                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1789     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)),
1790                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1791     tooltips.set_tip(video_viewer_entry, utf8(_("Use %f to specify the filename;\nfor example: /usr/bin/mplayer %f")), nil)
1792
1793     table_y += 1
1794     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Browser's command: ")))),
1795                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1796     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(browser_entry = Gtk::Entry.new.set_text($config['browser'])),
1797                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1798     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)
1799
1800     table_y += 1
1801     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Thumbnails height: ")))),
1802                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1803     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)),
1804                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1805     tooltips.set_tip(thumbnails_height, utf8(_("The desired height of the thumbnails in the thumbnails line of the bottom")), nil)
1806
1807     table_y += 1
1808     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Preloading distance: ")))),
1809                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1810     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)),
1811                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1812     tooltips.set_tip(preload_distance, utf8(_("Amount of pictures preloaded left and right to the currently shown")), nil)
1813
1814     table_y += 1
1815     tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Cache memory use: ")))),
1816                0, 1, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1817     tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(cache_vbox = Gtk::VBox.new(false, 0)),
1818                1, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1819     cache_vbox.pack_start(Gtk::HBox.new(false, 0).pack_start(cache_memfree_radio = Gtk::RadioButton.new(''), false, false).
1820                                                   pack_start(cache_memfree_spin = Gtk::SpinButton.new(0, 100, 10), false, false).
1821                                                   pack_start(cache_memfree_label = Gtk::Label.new(utf8(_("% of free memory"))), false, false), false, false)
1822     cache_memfree_spin.signal_connect('value-changed') { cache_memfree_radio.active = true }
1823     tooltips.set_tip(cache_memfree_spin, utf8(_("Percentage of free memory (+ buffers/cache) measured at startup")), nil)
1824     cache_vbox.pack_start(Gtk::HBox.new(false, 0).pack_start(cache_specify_radio = Gtk::RadioButton.new(cache_memfree_radio, ''), false, false).
1825                                                   pack_start(cache_specify_spin = Gtk::SpinButton.new(0, 4000, 50), false, false).
1826                                                   pack_start(cache_specify_label = Gtk::Label.new(utf8(_("MB"))).set_sensitive(false), false, false), false, false)
1827     cache_specify_spin.signal_connect('value-changed') { cache_specify_radio.active = true }
1828     cache_memfree_radio.signal_connect('toggled') {
1829         if cache_memfree_radio.active?
1830             cache_memfree_label.sensitive = true
1831             cache_specify_label.sensitive = false
1832         else
1833             cache_specify_label.sensitive = true
1834             cache_memfree_label.sensitive = false
1835         end
1836     }
1837     tooltips.set_tip(cache_specify_spin, utf8(_("Amount of memory in megabytes")), nil)
1838     if $config['cache-memory-use'] =~ /memfree_(\d+)/
1839         cache_memfree_spin.value = $1.to_i
1840     else
1841         cache_specify_spin.value = $config['cache-memory-use'].to_i / 1024
1842     end
1843
1844     table_y += 1
1845     tbl.attach(update_exif_orientation_check = Gtk::CheckButton.new(utf8(_("Update file's EXIF orientation when rotating a picture"))),
1846                0, 2, table_y, table_y + 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
1847     tooltips.set_tip(update_exif_orientation_check, utf8(_("When rotating a picture (Alt-Right/Left), also update EXIF orientation in the file itself")), nil)
1848     update_exif_orientation_check.active = $config['rotate-set-exif'] == 'true'
1849
1850     dialog.vbox.show_all
1851     dialog.run { |response|
1852         if response == Gtk::Dialog::RESPONSE_OK
1853             $config['video-viewer'] = from_utf8(video_viewer_entry.text)
1854             $config['browser'] = from_utf8(browser_entry.text)
1855             $config['thumbnails-height'] = thumbnails_height.value
1856             $config['preload-distance'] = preload_distance.value
1857             $config['rotate-set-exif'] = update_exif_orientation_check.active?.to_s
1858             if cache_memfree_radio.active?
1859                 $config['cache-memory-use'] = "memfree_#{cache_memfree_spin.value}%"
1860             else
1861                 $config['cache-memory-use'] = cache_specify_spin.value.to_i * 1024
1862             end
1863             set_cache_memory_use_figure
1864         end
1865     }
1866     dialog.destroy
1867 end
1868
1869 def perform_undo
1870     if $undo_mb.sensitive?
1871         $redo_mb.sensitive = true
1872         if not more_undoes = UndoHandler.undo($statusbar)
1873             $undo_mb.sensitive = false
1874         end
1875     end
1876 end
1877
1878 def perform_redo
1879     if $redo_mb.sensitive?
1880         $undo_mb.sensitive = true
1881         if not more_redoes = UndoHandler.redo($statusbar)
1882             $redo_mb.sensitive = false
1883         end
1884     end
1885 end
1886
1887 def create_menubar    
1888     #- menu
1889     mb = Gtk::MenuBar.new
1890
1891     filemenu = Gtk::MenuItem.new(utf8(_("_File")))
1892     filesubmenu = Gtk::Menu.new
1893     filesubmenu.append(open      = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
1894     filesubmenu.append(            Gtk::SeparatorMenuItem.new)
1895     filesubmenu.append($execute  = Gtk::ImageMenuItem.new(Gtk::Stock::EXECUTE).set_sensitive(false))
1896     filesubmenu.append(            Gtk::SeparatorMenuItem.new)
1897     filesubmenu.append(quit      = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
1898     filemenu.set_submenu(filesubmenu)
1899     mb.append(filemenu)
1900
1901     open.signal_connect('activate') { open_dir_popup }
1902     $execute.signal_connect('activate') { execute }
1903     quit.signal_connect('activate') { try_quit }
1904
1905     editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
1906     editsubmenu = Gtk::Menu.new
1907     editsubmenu.append($undo_mb    = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
1908     editsubmenu.append($redo_mb    = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
1909     editsubmenu.append(              Gtk::SeparatorMenuItem.new)
1910     editsubmenu.append(prefs       = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
1911     editmenu.set_submenu(editsubmenu)
1912     mb.append(editmenu)
1913
1914     $undo_mb.signal_connect('activate') { perform_undo }
1915     $redo_mb.signal_connect('activate') { perform_redo }
1916     prefs.signal_connect('activate') { preferences }
1917     
1918     helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
1919     helpsubmenu = Gtk::Menu.new
1920     helpsubmenu.append(howto = Gtk::ImageMenuItem.new(Gtk::Stock::HELP))
1921     helpsubmenu.append(speed = Gtk::ImageMenuItem.new(utf8(_("Speedup: key shortcuts"))))
1922     speed.image = Gtk::Image.new("#{$FPATH}/images/stock-info-16.png")
1923     helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
1924     tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
1925     helpsubmenu.append(Gtk::SeparatorMenuItem.new)
1926     helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
1927     helpmenu.set_submenu(helpsubmenu)
1928     mb.append(helpmenu)
1929
1930     howto.signal_connect('activate') {
1931         show_popup($main_window, utf8(_("<span size='large' weight='bold'>Help</span>
1932
1933 1. Open a directory with <span foreground='darkblue'>File/Open</span>; the classifier will scan it (including subdirectories) and
1934 show thumbnails for all photos and videos at the bottom.
1935
1936 2. You can then navigate through images with the <span foreground='darkblue'>Left/Right</span> keyboard keys, or by <span foreground='darkblue'>clicking</span>
1937 on thumbnails.
1938
1939 3. You may associate a <span foreground='darkblue'>label</span> to each thumbnail. Either hit the <span foreground='darkblue'>Delete</span> key to associate
1940 the built-in <i>to remove</i> label, or hit any alphabetical key to associate a label you define.
1941 The first time you hit a key without any label associated, a popup will ask for the full
1942 name of this label, and what color you want. To clear the current label, hit the <span foreground='darkblue'>Space</span> key.
1943
1944 4. To help you better view what thumbnails are associated to your labels, you may <span foreground='darkblue'>hide</span>
1945 some of them by unchecking the labels checkboxes on the left.
1946
1947 5. Once you're finished reviewing all thumbnails, use <span foreground='darkblue'>File/Execute</span> to execute the desired
1948 actions according to associated labels. You can permanently remove (or not) images with
1949 the <i>to remove</i> label, and copy or move images with the labels you defined.
1950 ")), { :pos_centered => true, :not_transient => true })
1951     }
1952     speed.signal_connect('activate') {
1953         show_popup($main_window, utf8(_("<span size='large' weight='bold'>Key shortcuts</span>
1954
1955 <span foreground='darkblue'>Left/Right</span>: move left and right in images
1956 <span foreground='darkblue'>Enter</span>: 'view' current image: for images, display EXIF data; for videos, play it
1957 <span foreground='darkblue'>Alt-Left/Right</span>: rotate current image clockwise/counter-clockwise
1958 <span foreground='darkblue'>Delete</span>: assign the 'to remove' label on current image
1959 <span foreground='darkblue'>Space</span>: clear any label on current image
1960 <span foreground='darkblue'>Control-z</span>: undo
1961 <span foreground='darkblue'>Control-r</span>: redo
1962 <span foreground='darkblue'>Control-Space</span>: recenter thumbnails on current item
1963
1964 Any alphabetical key will assign (or popup for) the associated label on current image.
1965 ")), { :pos_centered => true, :not_transient => true })
1966     }
1967     tutos.signal_connect('activate') { open_url('http://booh.org/tutorial') }
1968     about.signal_connect('activate') { call_about }
1969
1970
1971     #- no toolbar, to save height
1972
1973     return mb
1974 end
1975
1976 def reset_labels
1977     for child in $labels_vbox.children
1978         $labels_vbox.remove(child)
1979     end
1980     $labels_vbox.pack_start(Gtk::Label.new(utf8(_("Labels list:"))).set_justify(Gtk::Justification::CENTER), false, false).show_all
1981     $labels = {}
1982     $ordered_labels = []
1983     lbl = Gtk::Label.new.set_markup(utf8(_("<i>unlabelled</i>")))
1984     $labels_vbox.pack_start(Gtk::HBox.new(false, 5).pack_start($unlabelled_button = Gtk::CheckButton.new.add(Gtk::EventBox.new.add(lbl)), false, false).
1985                                                     pack_start(Gtk::Label.new, true, true).  #- I suck
1986                                                     pack_start($unlabelled_counter = Gtk::Label.new.set_markup('<tt>0</tt>'), false, false).show_all)
1987     $unlabelled_button.active = true
1988     $unlabelled_button.signal_connect('toggled') { update_all_visibilities }
1989     lbl = Gtk::Label.new.set_markup(utf8(_("<i>to remove</i>")))
1990     $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).
1991                                                     pack_start(Gtk::Label.new, true, true).
1992                                                     pack_start($toremove_counter = Gtk::Label.new.set_markup('<tt>0</tt>'), false, false).show_all)
1993     $toremove_button.active = true
1994     $toremove_button.signal_connect('toggled') { update_all_visibilities }
1995     evt.modify_bg(Gtk::StateType::NORMAL, $color_red)
1996     evt.modify_bg(Gtk::StateType::PRELIGHT, $color_red.lighter.lighter)
1997     evt.modify_bg(Gtk::StateType::ACTIVE, $color_red.lighter)
1998 end
1999
2000 def cleanup_loaders
2001     $allentries.each { |e| 
2002         e.cancel_loader
2003     }
2004 end
2005
2006 def reset_thumbnails
2007     cleanup_loaders
2008     $allentries = []
2009     if $preloader_running
2010         $preloader_force_exit = true
2011     end
2012     for child in $imagesline.children
2013         $imagesline.remove(child)
2014     end
2015     set_imagesline_size_request
2016 end
2017
2018 def set_imagesline_size_request
2019     $imagesline.set_size_request(-1, Gtk::Button.new.size_request[1] + Entry.thumbnails_height + Entry.thumbnails_height/4)
2020 end
2021
2022 def create_main_window
2023
2024     $videoborder_pixbuf = Gdk::Pixbuf.new("#{$FPATH}/images/video_border.png")
2025     $videoborder_pixmap, = $videoborder_pixbuf.render_pixmap_and_mask(0)
2026
2027     mb = create_menubar
2028
2029     main_vbox = Gtk::VBox.new(false, 0)
2030     main_vbox.pack_start(mb, false, false)
2031     mainview_hbox = Gtk::HBox.new
2032     mainview_hbox.pack_start(Gtk::Alignment.new(0.5, 0, 1, 1).add(left_vbox = Gtk::VBox.new(false, 5)), false, true)
2033     left_vbox.pack_start(($labels_vbox = Gtk::VBox.new(false, 5)), false, true)
2034     left_vbox.pack_end($loading_progressbar = Gtk::ProgressBar.new.set_text(utf8(_("Loading... %d%") % 0)), false, true)
2035     mainview_hbox.pack_start($mainview = MainView.new, true, true)
2036     main_vbox.pack_start(mainview_hbox, true, true)
2037     $imagesline_sw = Gtk::ScrolledWindow.new(nil, nil)
2038     $imagesline_sw.set_policy(Gtk::POLICY_ALWAYS, Gtk::POLICY_NEVER)
2039     $imagesline_sw.add_with_viewport($imagesline = Gtk::HBox.new(false, 0).show)
2040     main_vbox.pack_start($imagesline_sw, false, false)
2041     main_vbox.pack_end($statusbar = Gtk::Statusbar.new, false, false)
2042
2043     set_imagesline_size_request
2044
2045     $main_window = create_window
2046     $main_window.add(main_vbox)
2047     $main_window.signal_connect('delete-event') {
2048         try_quit
2049     }
2050
2051     #- read/save size and position of window
2052     if $config['pos-x'] && $config['pos-y']
2053         $main_window.move($config['pos-x'].to_i, $config['pos-y'].to_i)
2054     else
2055         $main_window.window_position = Gtk::Window::POS_CENTER
2056     end
2057     msg 3, "size: #{$config['width']}x#{$config['height']}"
2058     $main_window.set_default_size(($config['width'] || 800).to_i, ($config['height'] || 600).to_i)
2059     $main_window.signal_connect('configure-event') {
2060         msg 3, "configure: pos: #{$main_window.window.root_origin.inspect} size: #{$main_window.window.size.inspect}"
2061         x, y = $main_window.window.root_origin
2062         width, height = $main_window.window.size
2063         $config['pos-x'] = x
2064         $config['pos-y'] = y
2065         $config['width'] = width
2066         $config['height'] = height
2067         false
2068     }
2069
2070     $main_window.show_all
2071     $loading_progressbar.hide
2072 end
2073
2074
2075 handle_options
2076 read_config
2077 Gtk.init
2078
2079
2080 create_main_window
2081 check_config
2082
2083 if ARGV[0]
2084     if msg = open_dir(*ARGV)
2085         puts msg
2086     else
2087         Gtk.idle_add {
2088             show_entries($allentries)
2089             false
2090         }
2091     end
2092 end
2093 Gtk.main
2094
2095 cleanup_loaders
2096
2097 write_config