better rotations
[booh] / bin / booh-gui
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 Guillaume Cottenceau <gc3 at bluewin.ch>
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., 675 Mass Ave, Cambridge, MA 02139, USA.
21
22 require 'getoptlong'
23 require 'tempfile'
24
25 require 'gtk2'
26 require 'booh/GtkAutoTable'
27
28 require 'gettext'
29 include GetText
30 bindtextdomain("booh")
31
32 require 'rexml/document'
33 include REXML
34
35 require 'booh/booh-lib'
36 include Booh
37
38
39 #- options
40 $options = [
41     [ '--help',          '-h', GetoptLong::NO_ARGUMENT,       _("Get help message") ],
42
43     [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
44 ]
45
46 def usage
47     puts __("Usage: %s [OPTION]...", File.basename($0))
48     $options.each { |ary|
49         printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
50     }
51 end
52
53 def handle_options
54     parser = GetoptLong.new
55     parser.set_options(*$options.collect { |ary| ary[0..2] })
56     begin
57         parser.each_option do |name, arg|
58             case name
59             when '--help'
60                 usage
61                 exit(0)
62
63             when '--verbose-level'
64                 $verbose_level = arg.to_i
65
66             end
67         end
68     rescue
69         puts $!
70         usage
71         exit(1)
72     end
73 end
74
75 def read_config
76     $config = {}
77     $config_file = File.expand_path('~/.booh-gui-rc')
78     if File.readable?($config_file)
79         $xmldoc = REXML::Document.new(File.new($config_file))
80         $xmldoc.root.elements.each { |element|
81             txt = element.get_text
82             $config[element.name] = txt ? txt.value : nil
83         }
84     end
85     if !FileTest.directory?(File.expand_path('~/.booh-gui-files'))
86         system("mkdir ~/.booh-gui-files")
87     end
88 end
89
90 def write_config
91     ios = File.open($config_file, "w")
92     $xmldoc = Document.new "<booh-gui-rc version='#{$VERSION}'/>"
93     $xmldoc << XMLDecl.new( XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET )
94     $config.each_pair { |key, value|
95     elem = $xmldoc.root.add_element key
96         elem.add_text value.to_s
97     }
98     $xmldoc.write(ios, 0)
99     ios.close
100 end
101
102 def set_mousecursor_wait(window)
103     window.set_cursor(Gdk::Cursor.new(Gdk::Cursor::WATCH))
104     Gtk.main_iteration while Gtk.events_pending?
105 end
106 def set_mousecursor_normal(window)
107     window.set_cursor(Gdk::Cursor.new(Gdk::Cursor::LEFT_PTR))
108 end
109
110 def build_full_dest_filename(filename)
111     source = $xmldoc.root.attributes['source']
112     dest = $xmldoc.root.attributes['destination']
113     dest_dir = make_dest_filename(from_utf8($current_path).sub(/^#{Regexp.quote(source)}/, dest))
114     return dest_dir + '/' + make_dest_filename(from_utf8(filename))
115 end
116
117 def view_element(filename)
118     if entry2type(filename) == 'video'
119         system("mplayer #{$current_path + '/' + filename}")
120         return
121     end
122
123     w = Gtk::Window.new
124
125     msg 3, "filename: #{filename}"
126     dest_img = build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + "-#{$default_size['fullscreen']}.jpg"
127     #- typically this file won't exist in case of videos; try with the largest thumbnail around
128     if !File.exists?(dest_img)
129         alternatives = Dir[build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + '-*'].sort
130         if alternatives
131             dest_img = alternatives[-1]
132         else
133             return
134         end
135     end
136     i = Gtk::Image.new(dest_img)
137     f = Gtk::Frame.new
138     f.add(i)
139     f.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)
140     hb = Gtk::HBox.new
141     hb.pack_start(Gtk::Label.new, true, true)
142     hb.pack_start(f, false, false)
143     hb.pack_end(Gtk::Label.new, true, true)
144     evt = Gtk::EventBox.new
145     evt.add(hb)
146
147     tooltips = Gtk::Tooltips.new
148     tooltips.set_tip(evt, utf8(File.basename(filename).gsub(/\.jpg/, '')), nil)
149     
150     hb2 = Gtk::HBox.new
151     hb2.pack_start(Gtk::Label.new, true, true)
152     hb2.pack_start(b = Gtk::Button.new(Gtk::Stock::CLOSE), false, false)
153     b.signal_connect('clicked') { w.destroy }
154     hb2.pack_end(Gtk::Label.new, true, true)
155
156     vb = Gtk::VBox.new
157     vb.pack_start(evt, false, false)
158     vb.pack_end(hb2, false, false)
159
160     w.add(vb)
161     w.signal_connect('delete-event') { w.destroy }
162     w.window_position = Gtk::Window::POS_CENTER
163     w.show_all
164 end
165
166 def add_thumbnail(autotable, name, type, filename, caption)
167
168     frame1 = Gtk::Frame.new
169     frame1.add(img = Gtk::Image.new($rotated_pixbufs[filename] ? $rotated_pixbufs[filename][:pixbuf] : filename))
170     frame1.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)
171     hbox1 = Gtk::HBox.new
172     hbox1.pack_start(Gtk::Label.new, true, true)
173     hbox1.pack_start(frame1, false, false)
174     hbox1.pack_end(Gtk::Label.new, true, true)
175     evtbox = Gtk::EventBox.new
176     evtbox.add(hbox1)
177
178     tooltips = Gtk::Tooltips.new
179     tipname = File.basename(filename).gsub(/-\d+x\d+\.jpg/, '')
180     tooltips.set_tip(evtbox, utf8(type == 'video' ? __("%s (video)", tipname) : tipname), nil)
181
182     frame2 = Gtk::Frame.new
183     frame2.add(textview = Gtk::TextView.new.set_wrap_mode(Gtk::TextTag::WRAP_WORD))
184     frame2.set_shadow_type(Gtk::SHADOW_IN)
185
186     textview.buffer.text = utf8(caption)
187     textview.set_justification(Gtk::Justification::CENTER)
188
189     vbox = Gtk::VBox.new(false, 5)
190     vbox.pack_start(evtbox, false, false)
191     vbox.pack_start(frame2, false, false)
192     autotable.append(vbox, name)
193
194     #- to be able to grab focus of textview when retrieving vbox's position from AutoTable
195     $vbox2textview[vbox] = textview
196
197     #- to be able to find widgets by name
198     $name2widgets[name] = { :textview => textview }
199
200     rotate = Proc.new { |angle|
201         #- update rotate attribute
202         xmldir = $xmldoc.elements["//dir[@path='#{$current_path}']"]
203         felem = xmldir.elements["[@filename='#{name}']"]
204         felem.add_attribute('rotate', current_angle = ( felem.attributes['rotate'].to_i + angle ) % 360)
205
206         #- remove out of sync images
207         if !$rotated_pixbufs[filename]
208             $rotated_pixbufs[filename] = { :orig => img.pixbuf, :angle_to_orig => angle % 360 }
209             dest_img_base = build_full_dest_filename(name).sub(/\.[^\.]+$/, '')
210             for sizeobj in $images_size
211                 system("rm -f #{dest_img_base}-#{sizeobj['fullscreen']}.jpg #{dest_img_base}-#{sizeobj['thumbnails']}.jpg")
212             end
213         else
214             $rotated_pixbufs[filename][:angle_to_orig] = ( $rotated_pixbufs[filename][:angle_to_orig] + angle ) % 360
215         end
216         msg 3, "angle: #{angle}, angle to orig: #{$rotated_pixbufs[filename][:angle_to_orig]}"
217
218         #- rotate shown thumbnail
219         pixbuf = $rotated_pixbufs[filename][:orig].rotate($rotated_pixbufs[filename][:angle_to_orig] == 90 ? Gdk::Pixbuf::ROTATE_CLOCKWISE :
220                                                               $rotated_pixbufs[filename][:angle_to_orig] == 180 ? Gdk::Pixbuf::ROTATE_UPSIDEDOWN :
221                                                               $rotated_pixbufs[filename][:angle_to_orig] == 270 ? Gdk::Pixbuf::ROTATE_COUNTERCLOCKWISE :
222                                                               Gdk::Pixbuf::ROTATE_NONE )
223         msg 3, "sizes: #{pixbuf.width} #{pixbuf.height} - desired #{$default_thumbnails[:x]}x#{$default_thumbnails[:y]}"
224         if pixbuf.height > $default_thumbnails[:y]
225             img.pixbuf = $rotated_pixbufs[filename][:pixbuf] = pixbuf.scale(pixbuf.width * ($default_thumbnails[:y].to_f/pixbuf.height), $default_thumbnails[:y],
226                                                                             Gdk::Pixbuf::INTERP_BILINEAR)
227         elsif pixbuf.width < $default_thumbnails[:x] && pixbuf.height < $default_thumbnails[:y]
228             img.pixbuf = $rotated_pixbufs[filename][:pixbuf] = pixbuf.scale($default_thumbnails[:x], pixbuf.height * ($default_thumbnails[:x].to_f/pixbuf.width),
229                                                                             Gdk::Pixbuf::INTERP_BILINEAR)
230         else
231             img.pixbuf = $rotated_pixbufs[filename][:pixbuf] = pixbuf
232         end
233     }
234
235     textview.signal_connect('key-press-event') { |w, event|
236         propagate = true
237         textview.set_editable(event.keyval != Gdk::Keyval::GDK_Tab)
238         if event.keyval == Gdk::Keyval::GDK_Page_Up || event.keyval == Gdk::Keyval::GDK_Page_Down
239             $autotable_sw.signal_emit('key-press-event', event)
240         end
241         if event.state != 0
242             x, y = autotable.get_current_pos(vbox)
243             control_pressed = event.state & Gdk::Window::CONTROL_MASK != 0
244             shift_pressed = event.state & Gdk::Window::SHIFT_MASK != 0
245             alt_pressed = event.state & Gdk::Window::MOD1_MASK != 0
246             if event.keyval == Gdk::Keyval::GDK_Up && y > 0
247                 if control_pressed
248                     $vbox2textview[autotable.get_widget_at_pos(x, y - 1)].grab_focus
249                 end
250                 if shift_pressed
251                     autotable.move_up(vbox)
252                     textview.grab_focus  #- because if moving, focus is stolen
253                 end
254             end
255             if event.keyval == Gdk::Keyval::GDK_Down && y < autotable.get_max_y
256                 if control_pressed
257                     $vbox2textview[autotable.get_widget_at_pos(x, y + 1)].grab_focus
258                 end
259                 if shift_pressed
260                     autotable.move_down(vbox)
261                     textview.grab_focus  #- because if moving, focus is stolen
262                 end
263             end
264             if event.keyval == Gdk::Keyval::GDK_Left
265                 previous = autotable.get_previous_widget(vbox)
266                 if previous && autotable.get_current_pos(previous)[0] < x
267                     if control_pressed
268                         $vbox2textview[previous].grab_focus
269                     end
270                     if shift_pressed
271                         autotable.move_left(vbox)
272                         textview.grab_focus  #- because if moving, focus is stolen
273                     end
274                 end
275                 if alt_pressed
276                     rotate.call(-90)
277                 end
278             end
279             if event.keyval == Gdk::Keyval::GDK_Right
280                 next_ = autotable.get_next_widget(vbox)
281                 if next_ && autotable.get_current_pos(next_)[0] > x
282                     if control_pressed
283                         $vbox2textview[next_].grab_focus
284                     end
285                     if shift_pressed
286                         autotable.move_right(vbox)
287                         textview.grab_focus  #- because if moving, focus is stolen
288                     end
289                 end
290                 if alt_pressed
291                     rotate.call(90)
292                 end
293             end
294             if event.keyval == Gdk::Keyval::GDK_Delete && control_pressed
295                 after = autotable.get_next_widget(vbox)
296                 if !after
297                     after = autotable.get_previous_widget(vbox)
298                 end
299                 autotable.remove(vbox)
300                 if after
301                     $vbox2textview[after].grab_focus
302                 end
303             end
304             if event.keyval == Gdk::Keyval::GDK_Return && control_pressed
305                 view_element(File.basename(name))
306                 propagate = false
307             end
308         end
309         !propagate  #- propagate if needed
310     }
311
312     textview.signal_connect('focus-in-event') { |w, event|
313         textview.buffer.select_range(textview.buffer.get_iter_at_offset(0), textview.buffer.get_iter_at_offset(-1))
314         false  #- propagate
315     }
316
317     evtbox.signal_connect('button-press-event') { |w, event|
318         textview.grab_focus
319         if event.event_type == Gdk::Event::BUTTON2_PRESS
320             view_element(File.basename(name))
321             true
322         else
323             false  #- propagate
324         end
325     }
326
327     vbox.signal_connect('button-press-event') { |w, event|
328         $gesture_press = { :name => name, :x => event.x, :y => event.y }
329         false
330     }
331
332     vbox.signal_connect('drag-data-received') { |w, ctxt, x, y, selection_data, info, time|
333         if $gesture_press && $gesture_press[:name] == name
334             if (($gesture_press[:x]-x)/($gesture_press[:y]-y)).abs > 2 && ($gesture_press[:x]-x).abs > 5
335                 angle = x-$gesture_press[:x] > 0 ? 90 : -90
336                 msg 3, "gesture rotate: #{angle}"
337                 rotate.call(angle)
338             end
339         end
340         $gesture_press = nil
341     }
342
343
344     vbox.show_all
345 end
346
347 def create_auto_table
348
349     $autotable = Gtk::AutoTable.new(5)
350
351     $autotable_sw = Gtk::ScrolledWindow.new(nil, nil)
352     $autotable_sw.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_ALWAYS)
353     $autotable_sw.add_with_viewport($autotable)
354
355     return $autotable_sw
356 end
357
358 def try_quit
359     save_changes
360     if $filename
361         ios = File.open($filename, "w")
362         $xmldoc.write(ios, 0)
363         ios.close
364     end
365     Gtk.main_quit
366 end
367
368 def show_popup(parent, msg)
369     dialog = Gtk::Dialog.new
370     dialog.title = utf8(_("Booh message"))
371     dialog.transient_for = parent
372     dialog.set_default_size(200, 120)
373     dialog.vbox.add(Gtk::Label.new(msg))
374
375     dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
376     dialog.show_all
377     dialog.run
378     dialog.destroy
379 end
380
381 def save_changes
382     if !$current_path
383         return
384     end
385     #- remove and reinsert elements to reflect new ordering
386     save_attributes = {}
387     save_types = {}
388     xmldir = $xmldoc.elements["//dir[@path='#{$current_path}']"]
389     xmldir.elements.each { |element|
390         if element.name == 'image' || element.name == 'video'
391             save_types[element.attributes['filename']] = element.name
392             save_attributes[element.attributes['filename']] = element.attributes
393             element.remove
394         end
395     }
396     $autotable.current_order.each { |path|
397         chld = xmldir.add_element save_types[path], save_attributes[path]
398         chld.add_attribute('caption', $name2widgets[File.basename(path)][:textview].buffer.text)
399     }
400 end
401
402 def show_thumbnails
403     $autotable.clear
404     $vbox2textview = {}
405     $name2widgets = {}
406
407     xmldir = $xmldoc.elements["//dir[@path='#{$current_path}']"]
408     xmldir.elements.each { |element|
409         if element.name == 'image' || element.name == 'video'
410             dest_img = build_full_dest_filename(element.attributes['filename']).sub(/\.[^\.]+$/, '') + "-#{$default_size['thumbnails']}.jpg"
411             msg 3, "dest_img: #{dest_img}"
412             add_thumbnail($autotable, element.attributes['filename'], element.name, dest_img, from_utf8(element.attributes['caption']))
413         end
414     }
415 end
416
417 def open_file(filename)
418     $filename = nil
419     $current_path = nil   #- invalidate
420     $rotated_pixbufs = {}
421
422     begin
423         $xmldoc = REXML::Document.new File.new(filename)
424     rescue
425         $xmldoc = nil
426     end
427
428     if !$xmldoc || !$xmldoc.root || $xmldoc.root.name != 'booh'
429         return utf8(_("Not a booh file!"))
430     end
431
432     if !source = $xmldoc.root.attributes['source']
433         return utf8(_("Corrupted booh file..."))
434     end
435
436     if !dest = $xmldoc.root.attributes['destination']
437         return utf8(_("Corrupted booh file..."))
438     end
439
440     if !theme = $xmldoc.root.attributes['theme']
441         return utf8(_("Corrupted booh file..."))
442     end
443
444     $filename = filename
445     select_theme(theme)
446     $default_size = $images_size.detect { |sizeobj| sizeobj['default'] }
447     $default_size['thumbnails'] =~ /(.*)x(.*)/
448     $default_thumbnails = { :x => $1.to_i, :y => $2.to_i }
449
450     msg 3, "source: #{source}"
451
452     xmldir = $xmldoc.elements["//dir[@path='#{source}']"]
453     if !xmldir
454         return utf8(_("Corrupted booh file..."))
455     end
456
457     $albums_ts.clear
458
459     append_dir_elem = Proc.new { |parent_iter, location|
460         child_iter = $albums_ts.append(parent_iter)
461         child_iter[0] = File.basename(location)
462         child_iter[1] = location
463         msg 3, "puttin location: #{location}"
464         $xmldoc.elements.each("//dir[@path='#{location}']/dir") { |elem|
465             append_dir_elem.call(child_iter, elem.attributes['path'])
466         }
467     }
468     append_dir_elem.call(nil, source)
469
470     $albums_tv.expand_all
471     $albums_tv.selection.select_iter($albums_ts.iter_first)
472
473     return nil
474 end
475
476 def create_menu
477     mb = Gtk::MenuBar.new
478
479     filemenu = Gtk::MenuItem.new(utf8(_("_File")))
480     filesubmenu = Gtk::Menu.new
481     filesubmenu.append(new     = Gtk::MenuItem.new(utf8(_("_New (do nothing)"))))
482     filesubmenu.append(open    = Gtk::MenuItem.new(utf8(_("_Open"))))
483     filesubmenu.append(          Gtk::SeparatorMenuItem.new)
484     filesubmenu.append(save    = Gtk::MenuItem.new(utf8(_("_Save (do nothing - autosave on quit)"))))
485     filesubmenu.append(save_as = Gtk::MenuItem.new(utf8(_("Save _as... (do nothing)"))))
486     filesubmenu.append(          Gtk::SeparatorMenuItem.new)
487     filesubmenu.append(quit    = Gtk::MenuItem.new(utf8(_("_Quit"))))
488     filemenu.set_submenu(filesubmenu)
489     mb.append(filemenu)
490
491     open.signal_connect('activate') { |w|
492         fc = Gtk::FileChooserDialog.new(utf8(_("Open file")),
493                                         nil,
494                                         Gtk::FileChooser::ACTION_OPEN,
495                                         nil,
496                                         [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
497         fc.add_shortcut_folder(File.expand_path("~/.booh-gui-files"))
498         fc.set_current_folder(File.expand_path("~/.booh-gui-files"))
499         fc.transient_for = $main_window
500         ok = false
501         while !ok
502             if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
503                 set_mousecursor_wait(fc.window)
504                 msg = open_file(fc.filename)
505                 set_mousecursor_normal(fc.window)
506                 if msg
507                     show_popup(fc, msg)
508                     ok = false
509                 else
510                     ok = true
511                 end
512             else
513                 ok = true
514             end
515         end
516         fc.destroy
517     }
518
519     quit.signal_connect('activate') { |w|
520         try_quit
521     }
522
523     return mb
524 end
525
526 def create_main_window
527
528     mb = create_menu
529
530     tbl = create_auto_table
531 #    open_file('/home/gc/booh/foo')
532
533     $albums_tv = Gtk::TreeView.new
534     $albums_tv.set_size_request(120, -1)
535     renderer = Gtk::CellRendererText.new
536     column = Gtk::TreeViewColumn.new('', renderer, { :text => 0 })
537     $albums_tv.append_column(column)
538     $albums_tv.set_headers_visible(false)
539     $albums_tv.selection.signal_connect('changed') { |w|
540         save_changes
541         iter = w.selected
542         if !iter
543             msg 3, "no selection"
544         else
545             $current_path = $albums_ts.get_value(iter, 1)
546             show_thumbnails
547         end
548     }
549     $albums_ts = Gtk::TreeStore.new(String, String)
550     $albums_tv.set_model($albums_ts)
551
552     paned = Gtk::HPaned.new
553     paned.pack1($albums_tv, false, false)
554     paned.pack2(tbl, true, true)
555
556     main_vbox = Gtk::VBox.new(false, 5)
557     main_vbox.pack_start(mb, false, false)
558     main_vbox.pack_start(paned, true, true)
559     main_vbox.pack_end($statusbar = Gtk::Statusbar.new, false, false)
560
561     $main_window = Gtk::Window.new
562     $main_window.add(main_vbox)
563     $main_window.signal_connect('delete-event') {
564         try_quit
565     }
566
567     #- read/save size and position of window
568     if $config['pos-x'] && $config['pos-y']
569         $main_window.move($config['pos-x'].to_i, $config['pos-y'].to_i)
570     else
571         $main_window.window_position = Gtk::Window::POS_CENTER
572     end
573     msg 3, "width: #{$config['width']}\n\n"
574     $main_window.set_default_size(($config['width'] || 600).to_i, ($config['height'] || 400).to_i)
575     $main_window.signal_connect('configure-event') {
576         msg 3, "configure: pos: #{$main_window.window.root_origin.inspect} size: #{$main_window.window.size.inspect}"
577         x, y = $main_window.window.root_origin
578         width, height = $main_window.window.size
579         $config['pos-x'] = x
580         $config['pos-y'] = y
581         $config['width'] = width
582         $config['height'] = height
583         false
584     }
585
586     $statusbar.push(0, utf8(_("Ready.")))
587     $main_window.show_all
588 end
589
590
591 handle_options
592 read_config
593
594 Gtk.init
595 create_main_window
596 if ARGV[0]
597     open_file(ARGV[0])
598 end
599 Gtk.main
600
601 write_config