5 # A.k.a 'Best web-album Of the world, Or your money back, Humerus'.
7 # The acronyn sucks, however this is a tribute to Dragon Ball by
8 # Akira Toriyama, where the last enemy beaten by heroes of Dragon
9 # Ball is named "Boo". But there was already a free software project
10 # called Boo, so this one will be it "Booh". Or whatever.
13 # Copyright (c) 2004 Guillaume Cottenceau <http://zarb.org/~gc/resource/gc_mail.png>
15 # This software may be freely redistributed under the terms of the GNU
16 # public license version 2.
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
27 require 'booh/gtkadds'
28 require 'booh/GtkAutoTable'
32 bindtextdomain("booh")
34 require 'rexml/document'
37 require 'booh/booh-lib'
39 require 'booh/UndoHandler'
44 [ '--help', '-h', GetoptLong::NO_ARGUMENT, _("Get help message") ],
46 [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
49 #- default values for some globals
53 $ignore_videos = false
54 $button1_pressed_autotable = false
55 $generated_outofline = false
58 puts _("Usage: %s [OPTION]...") % File.basename($0)
60 printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
65 parser = GetoptLong.new
66 parser.set_options(*$options.collect { |ary| ary[0..2] })
68 parser.each_option do |name, arg|
74 when '--verbose-level'
75 $verbose_level = arg.to_i
88 $config_file = File.expand_path('~/.booh-gui-rc')
89 if File.readable?($config_file)
90 $xmldoc = REXML::Document.new(File.new($config_file))
91 $xmldoc.root.elements.each { |element|
92 txt = element.get_text
94 if txt.value =~ /~~~/ || element.name == 'last-opens'
95 $config[element.name] = txt.value.split(/~~~/)
97 $config[element.name] = txt.value
99 elsif element.elements.size == 0
100 $config[element.name] = ''
102 $config[element.name] = {}
103 element.each { |chld|
105 $config[element.name][chld.name] = txt ? txt.value : nil
110 $config['video-viewer'] ||= '/usr/bin/mplayer %f'
111 $config['browser'] ||= "/usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f"
112 if !FileTest.directory?(File.expand_path('~/.booh'))
113 system("mkdir ~/.booh")
121 if !system("which convert >/dev/null 2>/dev/null")
122 show_popup($main_window, utf8(_("The program 'convert' is needed. Please install it.
123 It is generally available with the 'ImageMagick' software package.")), { :pos_centered => true })
126 if !system("which identify >/dev/null 2>/dev/null")
127 show_popup($main_window, utf8(_("The program 'identify' is needed to get images sizes and EXIF data. Please install it.
128 It is generally available with the 'ImageMagick' software package.")), { :pos_centered => true })
130 missing = %w(transcode mencoder).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
132 show_popup($main_window, utf8(_("The following program(s) are needed to handle videos: '%s'. Videos will be ignored.") % missing.join(', ')), { :pos_centered => true })
135 viewer_binary = $config['video-viewer'].split.first
136 if viewer_binary && !File.executable?(viewer_binary)
137 show_popup($main_window, utf8(_("The configured video viewer seems to be unavailable.
138 You should fix this in Edit/Preferences so that you can view videos.
140 Problem was: '%s' is not an executable file.
141 Hint: don't forget to specify the full path to the executable,
142 e.g. '/usr/bin/mplayer' is correct but 'mplayer' only is not.") % viewer_binary), { :pos_centered => true, :not_transient => true })
144 browser_binary = $config['browser'].split.first
145 if browser_binary && !File.executable?(browser_binary)
146 show_popup($main_window, utf8(_("The configured browser seems to be unavailable.
147 You should fix this in Edit/Preferences so that you can open URLs.
149 Problem was: '%s' is not an executable file.") % browser_binary), { :pos_centered => true, :not_transient => true })
154 if $config['last-opens'] && $config['last-opens'].size > 5
155 $config['last-opens'] = $config['last-opens'][-5, 5]
158 ios = File.open($config_file, "w")
159 $xmldoc = Document.new "<booh-gui-rc version='#{$VERSION}'/>"
160 $xmldoc << XMLDecl.new(XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET)
161 $config.each_pair { |key, value|
162 elem = $xmldoc.root.add_element key
164 $config[key].each_pair { |subkey, subvalue|
165 subelem = elem.add_element subkey
166 subelem.add_text subvalue.to_s
168 elsif value.is_a? Array
169 elem.add_text value.join('~~~')
174 elem.add_text value.to_s
178 $xmldoc.write(ios, 0)
181 $tempfiles.each { |f|
186 def set_mousecursor(what, *widget)
187 cursor = what.nil? ? nil : Gdk::Cursor.new(what)
188 if widget[0] && widget[0].window
189 widget[0].window.cursor = cursor
191 if $main_window && $main_window.window
192 $main_window.window.cursor = cursor
194 $current_cursor = what
196 def set_mousecursor_wait(*widget)
197 gtk_thread_protect { set_mousecursor(Gdk::Cursor::WATCH, *widget) }
198 if Thread.current == Thread.main
199 Gtk.main_iteration while Gtk.events_pending?
202 def set_mousecursor_normal(*widget)
203 gtk_thread_protect { set_mousecursor($save_cursor = nil, *widget) }
205 def push_mousecursor_wait(*widget)
206 if $current_cursor != Gdk::Cursor::WATCH
207 $save_cursor = $current_cursor
208 gtk_thread_protect { set_mousecursor_wait(*widget) }
211 def pop_mousecursor(*widget)
212 gtk_thread_protect { set_mousecursor($save_cursor || nil, *widget) }
216 source = $xmldoc.root.attributes['source']
217 dest = $xmldoc.root.attributes['destination']
218 return make_dest_filename(from_utf8($current_path.sub(/^#{Regexp.quote(source)}/, dest)))
221 def full_src_dir_to_rel(path, source)
222 return path.sub(/^#{Regexp.quote(from_utf8(source))}/, '')
225 def build_full_dest_filename(filename)
226 return current_dest_dir + '/' + make_dest_filename(from_utf8(filename))
229 def save_undo(name, closure, *params)
230 UndoHandler.save_undo(name, closure, [ *params ])
231 $undo_tb.sensitive = $undo_mb.sensitive = true
232 $redo_tb.sensitive = $redo_mb.sensitive = false
235 def view_element(filename, closures)
236 if entry2type(filename) == 'video'
237 cmd = from_utf8($config['video-viewer']).gsub('%f', "'#{from_utf8($current_path + '/' + filename)}'") + ' &'
243 w = Gtk::Window.new.set_title(filename)
245 msg 3, "filename: #{filename}"
246 dest_img = build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + "-#{$default_size['fullscreen']}.jpg"
247 #- typically this file won't exist in case of videos; try with the largest thumbnail around
248 if !File.exists?(dest_img)
249 if entry2type(filename) == 'video'
250 alternatives = Dir[build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + '-*'].sort
251 if not alternatives.empty?
252 dest_img = alternatives[-1]
255 push_mousecursor_wait
256 gen_thumbnails_element(from_utf8("#{$current_path}/#{filename}"), $xmldir, false, [ { 'filename' => dest_img, 'size' => $default_size['fullscreen'] } ])
258 if !File.exists?(dest_img)
259 msg 2, _("Could not generate fullscreen thumbnail!")
264 evt = Gtk::EventBox.new.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(Gtk::Frame.new.add(Gtk::Image.new(dest_img)).set_shadow_type(Gtk::SHADOW_ETCHED_OUT)))
265 evt.signal_connect('button-press-event') { |this, event|
266 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
267 $config['nogestures'] or $gesture_press = { :x => event.x, :y => event.y }
269 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
271 menu.append(delete_item = Gtk::ImageMenuItem.new(Gtk::Stock::DELETE))
272 delete_item.signal_connect('activate') {
274 closures[:delete].call
277 menu.popup(nil, nil, event.button, event.time)
280 evt.signal_connect('button-release-event') { |this, event|
282 if (($gesture_press[:y]-event.y)/($gesture_press[:x]-event.x)).abs > 2 && event.y-$gesture_press[:y] > 5
283 msg 3, "gesture delete: click-drag right button to the bottom"
285 closures[:delete].call
286 $statusbar.push(0, utf8(_("Mouse gesture: delete.")))
290 tooltips = Gtk::Tooltips.new
291 tooltips.set_tip(evt, File.basename(filename).gsub(/\.jpg/, ''), nil)
293 w.signal_connect('key-press-event') { |w,event|
294 if event.state & Gdk::Window::CONTROL_MASK != 0 && event.keyval == Gdk::Keyval::GDK_Delete
296 closures[:delete].call
300 bottom = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(b = Gtk::Button.new(Gtk::Stock::CLOSE))
301 b.signal_connect('clicked') { w.destroy }
304 vb.pack_start(evt, false, false)
305 vb.pack_end(bottom, false, false)
308 w.signal_connect('delete-event') { w.destroy }
309 w.window_position = Gtk::Window::POS_CENTER
313 def scroll_upper(scrolledwindow, ypos_top)
314 newval = scrolledwindow.vadjustment.value -
315 ((scrolledwindow.vadjustment.value - ypos_top - 1) / scrolledwindow.vadjustment.step_increment + 1) * scrolledwindow.vadjustment.step_increment
316 if newval < scrolledwindow.vadjustment.lower
317 newval = scrolledwindow.vadjustment.lower
319 scrolledwindow.vadjustment.value = newval
322 def scroll_lower(scrolledwindow, ypos_bottom)
323 newval = scrolledwindow.vadjustment.value +
324 ((ypos_bottom - (scrolledwindow.vadjustment.value + scrolledwindow.vadjustment.page_size) - 1) / scrolledwindow.vadjustment.step_increment + 1) * scrolledwindow.vadjustment.step_increment
325 if newval > scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
326 newval = scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
328 scrolledwindow.vadjustment.value = newval
331 def autoscroll_if_needed(scrolledwindow, image, textview)
332 #- autoscroll if cursor or image is not visible, if possible
333 if image && image.window || textview.window
334 ypos_top = (image && image.window) ? image.window.position[1] : textview.window.position[1]
335 ypos_bottom = max(textview.window.position[1] + textview.window.size[1], image && image.window ? image.window.position[1] + image.window.size[1] : -1)
336 current_miny_visible = scrolledwindow.vadjustment.value
337 current_maxy_visible = scrolledwindow.vadjustment.value + scrolledwindow.vadjustment.page_size
338 if ypos_top < current_miny_visible
339 scroll_upper(scrolledwindow, ypos_top)
340 elsif ypos_bottom > current_maxy_visible
341 scroll_lower(scrolledwindow, ypos_bottom)
346 def create_editzone(scrolledwindow, pagenum, image)
347 frame = Gtk::Frame.new
348 frame.add(textview = Gtk::TextView.new.set_wrap_mode(Gtk::TextTag::WRAP_WORD))
349 frame.set_shadow_type(Gtk::SHADOW_IN)
350 textview.signal_connect('key-press-event') { |w, event|
351 textview.set_editable(event.keyval != Gdk::Keyval::GDK_Tab)
352 if event.keyval == Gdk::Keyval::GDK_Page_Up || event.keyval == Gdk::Keyval::GDK_Page_Down
353 scrolledwindow.signal_emit('key-press-event', event)
355 if (event.keyval == Gdk::Keyval::GDK_Up || event.keyval == Gdk::Keyval::GDK_Down) &&
356 event.state & (Gdk::Window::CONTROL_MASK | Gdk::Window::SHIFT_MASK | Gdk::Window::MOD1_MASK) == 0
357 if event.keyval == Gdk::Keyval::GDK_Up
358 if scrolledwindow.vadjustment.value >= scrolledwindow.vadjustment.lower + scrolledwindow.vadjustment.step_increment
359 scrolledwindow.vadjustment.value -= scrolledwindow.vadjustment.step_increment
361 scrolledwindow.vadjustment.value = scrolledwindow.vadjustment.lower
364 if scrolledwindow.vadjustment.value <= scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.step_increment - scrolledwindow.vadjustment.page_size
365 scrolledwindow.vadjustment.value += scrolledwindow.vadjustment.step_increment
367 scrolledwindow.vadjustment.value = scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
373 textview.signal_connect('focus-in-event') { |w, event|
374 textview.buffer.select_range(textview.buffer.get_iter_at_offset(0), textview.buffer.get_iter_at_offset(-1))
378 candidate_undo_text = nil
379 textview.signal_connect('focus-in-event') { |w, event|
380 candidate_undo_text = textview.buffer.text
383 textview.signal_connect('key-release-event') { |w, event|
384 if candidate_undo_text && candidate_undo_text != textview.buffer.text
386 save_undo(_("text edit"),
388 save_text = textview.buffer.text
389 textview.buffer.text = text
391 $notebook.set_page(pagenum)
393 textview.buffer.text = save_text
395 $notebook.set_page(pagenum)
397 }, candidate_undo_text)
398 candidate_undo_text = nil
401 if event.state != 0 || ![Gdk::Keyval::GDK_Page_Up, Gdk::Keyval::GDK_Page_Down, Gdk::Keyval::GDK_Up, Gdk::Keyval::GDK_Down].include?(event.keyval)
402 autoscroll_if_needed(scrolledwindow, image, textview)
407 return [ frame, textview ]
410 def update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
412 if !$modified_pixbufs[thumbnail_img]
413 $modified_pixbufs[thumbnail_img] = { :orig => img.pixbuf }
414 elsif !$modified_pixbufs[thumbnail_img][:orig]
415 $modified_pixbufs[thumbnail_img][:orig] = img.pixbuf
418 pixbuf = $modified_pixbufs[thumbnail_img][:orig].dup
421 if $modified_pixbufs[thumbnail_img][:angle_to_orig] && $modified_pixbufs[thumbnail_img][:angle_to_orig] != 0
422 pixbuf = rotate_pixbuf(pixbuf, $modified_pixbufs[thumbnail_img][:angle_to_orig])
423 msg 3, "sizes: #{pixbuf.width} #{pixbuf.height} - desired #{desired_x}x#{desired_x}"
424 if pixbuf.height > desired_y
425 pixbuf = pixbuf.scale(pixbuf.width * (desired_y.to_f/pixbuf.height), desired_y, Gdk::Pixbuf::INTERP_BILINEAR)
426 elsif pixbuf.width < desired_x && pixbuf.height < desired_y
427 pixbuf = pixbuf.scale(desired_x, pixbuf.height * (desired_x.to_f/pixbuf.width), Gdk::Pixbuf::INTERP_BILINEAR)
432 if $modified_pixbufs[thumbnail_img][:whitebalance]
433 pixbuf.whitebalance!($modified_pixbufs[thumbnail_img][:whitebalance])
436 img.pixbuf = $modified_pixbufs[thumbnail_img][:pixbuf] = pixbuf
439 def rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
442 #- update rotate attribute
443 xmlelem.add_attribute("#{attributes_prefix}rotate", (xmlelem.attributes["#{attributes_prefix}rotate"].to_i + angle) % 360)
445 $modified_pixbufs[thumbnail_img] ||= {}
446 $modified_pixbufs[thumbnail_img][:angle_to_orig] = (($modified_pixbufs[thumbnail_img][:angle_to_orig] || 0) + angle) % 360
447 msg 3, "angle: #{angle}, angle to orig: #{$modified_pixbufs[thumbnail_img][:angle_to_orig]}"
449 update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
452 def rotate(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
455 rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
457 save_undo(angle == 90 ? _("rotate clockwise") : angle == -90 ? _("rotate counter-clockwise") : _("flip upside-down"),
459 rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
460 $notebook.set_page(attributes_prefix != '' ? 0 : 1)
462 rotate_real(-angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
463 $notebook.set_page(0)
464 $notebook.set_page(attributes_prefix != '' ? 0 : 1)
469 def color_swap(xmldir, attributes_prefix)
471 if xmldir.attributes["#{attributes_prefix}color-swap"]
472 xmldir.delete_attribute("#{attributes_prefix}color-swap")
474 xmldir.add_attribute("#{attributes_prefix}color-swap", '1')
478 def enhance(xmldir, attributes_prefix)
480 if xmldir.attributes["#{attributes_prefix}enhance"]
481 xmldir.delete_attribute("#{attributes_prefix}enhance")
483 xmldir.add_attribute("#{attributes_prefix}enhance", '1')
487 def change_frame_offset(xmldir, attributes_prefix, value)
489 xmldir.add_attribute("#{attributes_prefix}frame-offset", value)
492 def ask_new_frame_offset(xmldir, attributes_prefix)
494 value = xmldir.attributes["#{attributes_prefix}frame-offset"]
499 dialog = Gtk::Dialog.new(utf8(_("Change frame offset")),
501 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
502 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
503 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
507 _("Please specify the <b>frame offset</b> of the video, to take the thumbnail
508 from. There are approximately 25 frames per second in a video.
511 dialog.vbox.add(entry = Gtk::Entry.new.set_text(value))
512 entry.signal_connect('key-press-event') { |w, event|
513 if event.keyval == Gdk::Keyval::GDK_Return
514 dialog.response(Gtk::Dialog::RESPONSE_OK)
516 elsif event.keyval == Gdk::Keyval::GDK_Escape
517 dialog.response(Gtk::Dialog::RESPONSE_CANCEL)
520 false #- propagate if needed
524 dialog.window_position = Gtk::Window::POS_MOUSE
527 dialog.run { |response|
530 if response == Gtk::Dialog::RESPONSE_OK
532 msg 3, "changing frame offset to #{newval}"
533 return { :old => value, :new => newval }
540 def change_pano_amount(xmldir, attributes_prefix, value)
543 xmldir.delete_attribute("#{attributes_prefix}pano-amount")
545 xmldir.add_attribute("#{attributes_prefix}pano-amount", value)
549 def ask_new_pano_amount(xmldir, attributes_prefix)
551 value = xmldir.attributes["#{attributes_prefix}pano-amount"]
556 dialog = Gtk::Dialog.new(utf8(_("Specify panorama amount")),
558 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
559 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
560 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
564 _("Please specify the <b>panorama 'amount'</b> of the image, which indicates the width
565 of this panorama image compared to other regular images. For example, if the panorama
566 was taken out of four photos on one row, counting the necessary overlap, the width of
567 this panorama image should probably be roughly three times the width of regular images.
569 With this information, booh will be able to generate panorama thumbnails looking
573 dialog.vbox.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(Gtk::HBox.new.add(rb_no = Gtk::RadioButton.new(utf8(_("none (not a panorama image)")))).
574 add(rb_yes = Gtk::RadioButton.new(rb_no, utf8(_("amount of: ")))).
575 add(spin = Gtk::SpinButton.new(1, 8, 0.1)).
576 add(Gtk::Label.new(utf8(_("times the width of other images"))))))
577 dialog.window_position = Gtk::Window::POS_MOUSE
580 spin.value = value.to_f
587 dialog.run { |response|
591 newval = spin.value.to_f
594 if response == Gtk::Dialog::RESPONSE_OK
596 msg 3, "changing panorama amount to #{newval}"
597 return { :old => value, :new => newval }
604 def change_whitebalance(xmlelem, attributes_prefix, value)
606 xmlelem.add_attribute("#{attributes_prefix}white-balance", value)
609 def recalc_whitebalance(level, orig, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
611 #- in case the white balance was already modified in the config file and thumbnail, we cannot just revert, we need to use original file
612 if (!$modified_pixbufs[thumbnail_img] || !$modified_pixbufs[thumbnail_img][:whitebalance]) && xmlelem.attributes["#{attributes_prefix}white-balance"]
613 save_whitebalance = xmlelem.attributes["#{attributes_prefix}white-balance"]
614 xmlelem.delete_attribute("#{attributes_prefix}white-balance")
615 destfile = "#{thumbnail_img}-orig-whitebalance.jpg"
616 gen_real_thumbnail_core(attributes_prefix == '' ? 'element' : 'subdir', orig, destfile,
617 xmlelem, attributes_prefix == '' ? $default_size['thumbnails'] : $albums_thumbnail_size, infotype)
618 $modified_pixbufs[thumbnail_img] ||= {}
619 $modified_pixbufs[thumbnail_img][:orig] = pixbuf_or_nil(destfile)
620 xmlelem.add_attribute("#{attributes_prefix}white-balance", save_whitebalance)
621 $modified_pixbufs[thumbnail_img][:angle_to_orig] = 0
624 $modified_pixbufs[thumbnail_img] ||= {}
625 $modified_pixbufs[thumbnail_img][:whitebalance] = level.to_f
627 update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
630 def ask_whitebalance(orig, thumbnail_img, img_, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
631 #- init $modified_pixbufs correctly
632 # update_shown_pixbuf(thumbnail_img, img_, desired_x, desired_y)
634 value = xmlelem ? (xmlelem.attributes["#{attributes_prefix}white-balance"] || "0") : "0"
636 dialog = Gtk::Dialog.new(utf8(_("Fix white balance")),
638 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
639 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
640 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
644 _("You can fix the <b>white balance</b> of the image, if your image is too blue
645 or too yellow because your camera didn't detect the light correctly. Drag the
646 slider below the image to the left for more blue, to the right for more yellow.
650 dialog.vbox.add(evt = Gtk::EventBox.new.add(img = Gtk::Image.new(img_.pixbuf)))
652 dialog.vbox.add(hs = Gtk::HScale.new(-200, 200, 1).set_value(value.to_i))
654 dialog.window_position = Gtk::Window::POS_MOUSE
658 timeout = Gtk.timeout_add(100) {
659 if hs.value != lastval
662 recalc_whitebalance(lastval, orig, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
668 dialog.run { |response|
669 Gtk.timeout_remove(timeout)
670 if response == Gtk::Dialog::RESPONSE_OK
672 newval = hs.value.to_s
673 msg 3, "changing white balance to #{newval}"
675 return { :old => value, :new => newval }
678 $modified_pixbufs[thumbnail_img] ||= {}
679 $modified_pixbufs[thumbnail_img][:whitebalance] = value.to_f
680 $modified_pixbufs[thumbnail_img][:pixbuf] = img_.pixbuf
688 def gen_real_thumbnail_core(type, origfile, destfile, xmldir, size, infotype)
689 system("rm -f '#{destfile}'")
690 #- type can be 'element' or 'subdir'
692 gen_thumbnails_element(origfile, xmldir, false, [ { 'filename' => destfile, 'size' => size } ])
694 gen_thumbnails_subdir(origfile, xmldir, false, [ { 'filename' => destfile, 'size' => size } ], infotype)
698 def gen_real_thumbnail(type, origfile, destfile, xmldir, size, img, infotype)
700 push_mousecursor_wait
701 gen_real_thumbnail_core(type, origfile, destfile, xmldir, size, infotype)
704 $modified_pixbufs[destfile] = { :orig => img.pixbuf, :pixbuf => img.pixbuf, :angle_to_orig => 0 }
710 def popup_thumbnail_menu(event, optionals, fullpath, type, xmldir, attributes_prefix, possible_actions, closures)
711 distribute_multiple_call = Proc.new { |action, arg|
712 $selected_elements.each_key { |path|
713 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
715 if possible_actions[:can_multiple] && $selected_elements.length > 0
716 UndoHandler.begin_batch
717 $selected_elements.each_key { |k| $name2closures[k][action].call(arg) }
718 UndoHandler.end_batch
720 closures[action].call(arg)
722 $selected_elements = {}
725 if optionals.include?('change_image')
726 menu.append(changeimg = Gtk::ImageMenuItem.new(utf8(_("Change image"))))
727 changeimg.image = Gtk::Image.new("#{$FPATH}/images/stock-images-16.png")
728 changeimg.signal_connect('activate') { closures[:change].call }
729 menu.append(Gtk::SeparatorMenuItem.new)
731 if !possible_actions[:can_multiple] || $selected_elements.length == 0
734 menu.append(view = Gtk::ImageMenuItem.new(utf8(_("View larger"))))
735 view.image = Gtk::Image.new("#{$FPATH}/images/stock-view-16.png")
736 view.signal_connect('activate') { closures[:view].call }
738 menu.append(view = Gtk::ImageMenuItem.new(utf8(_("Play video"))))
739 view.image = Gtk::Image.new("#{$FPATH}/images/stock-video-16.png")
740 view.signal_connect('activate') { closures[:view].call }
741 menu.append(Gtk::SeparatorMenuItem.new)
744 if type == 'image' && (!possible_actions[:can_multiple] || $selected_elements.length == 0)
745 menu.append(exif = Gtk::ImageMenuItem.new(utf8(_("View EXIF data"))))
746 exif.image = Gtk::Image.new("#{$FPATH}/images/stock-list-16.png")
747 exif.signal_connect('activate') { show_popup($main_window,
748 utf8(`identify -format "%[EXIF:*]" #{fullpath}`.sub(/MakerNote.*\n/, '')),
749 { :title => utf8(_("EXIF data of %s") % File.basename(fullpath)), :nomarkup => true, :scrolled => true }) }
750 menu.append(Gtk::SeparatorMenuItem.new)
753 menu.append(r90 = Gtk::ImageMenuItem.new(utf8(_("Rotate clockwise"))))
754 r90.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-90-16.png")
755 r90.signal_connect('activate') { distribute_multiple_call.call(:rotate, 90) }
756 menu.append(r270 = Gtk::ImageMenuItem.new(utf8(_("Rotate counter-clockwise"))))
757 r270.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-270-16.png")
758 r270.signal_connect('activate') { distribute_multiple_call.call(:rotate, -90) }
759 if !possible_actions[:can_multiple] || $selected_elements.length == 0
760 menu.append(Gtk::SeparatorMenuItem.new)
761 if !possible_actions[:forbid_left]
762 menu.append(moveleft = Gtk::ImageMenuItem.new(utf8(_("Move left"))))
763 moveleft.image = Gtk::Image.new("#{$FPATH}/images/stock-move-left.png")
764 moveleft.signal_connect('activate') { closures[:move].call('left') }
765 if !possible_actions[:can_left]
766 moveleft.sensitive = false
769 if !possible_actions[:forbid_right]
770 menu.append(moveright = Gtk::ImageMenuItem.new(utf8(_("Move right"))))
771 moveright.image = Gtk::Image.new("#{$FPATH}/images/stock-move-right.png")
772 moveright.signal_connect('activate') { closures[:move].call('right') }
773 if !possible_actions[:can_right]
774 moveright.sensitive = false
777 menu.append(moveup = Gtk::ImageMenuItem.new(utf8(_("Move up"))))
778 moveup.image = Gtk::Image.new("#{$FPATH}/images/stock-move-up.png")
779 moveup.signal_connect('activate') { closures[:move].call('up') }
780 if !possible_actions[:can_up]
781 moveup.sensitive = false
783 menu.append(movedown = Gtk::ImageMenuItem.new(utf8(_("Move down"))))
784 movedown.image = Gtk::Image.new("#{$FPATH}/images/stock-move-down.png")
785 movedown.signal_connect('activate') { closures[:move].call('down') }
786 if !possible_actions[:can_down]
787 movedown.sensitive = false
791 if !possible_actions[:can_multiple] || $selected_elements.length == 0 || $selected_elements.reject { |k,v| $name2widgets[k][:type] == 'video' }.empty?
792 menu.append(Gtk::SeparatorMenuItem.new)
793 menu.append(color_swap = Gtk::ImageMenuItem.new(utf8(_("Red/blue color swap"))))
794 color_swap.image = Gtk::Image.new("#{$FPATH}/images/stock-color-triangle-16.png")
795 color_swap.signal_connect('activate') { distribute_multiple_call.call(:color_swap) }
796 menu.append(flip = Gtk::ImageMenuItem.new(utf8(_("Flip upside-down"))))
797 flip.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-180-16.png")
798 flip.signal_connect('activate') { distribute_multiple_call.call(:rotate, 180) }
799 menu.append(frame_offset = Gtk::ImageMenuItem.new(utf8(_("Specify frame offset"))))
800 frame_offset.image = Gtk::Image.new("#{$FPATH}/images/stock-video-16.png")
801 frame_offset.signal_connect('activate') {
802 if possible_actions[:can_multiple] && $selected_elements.length > 0
803 if values = ask_new_frame_offset(nil, '')
804 distribute_multiple_call.call(:frame_offset, values)
807 closures[:frame_offset].call
812 menu.append( Gtk::SeparatorMenuItem.new)
813 menu.append(whitebalance = Gtk::ImageMenuItem.new(utf8(_("Fix white-balance"))))
814 whitebalance.image = Gtk::Image.new("#{$FPATH}/images/stock-tool-color-balance-16.png")
815 whitebalance.signal_connect('activate') {
816 if possible_actions[:can_multiple] && $selected_elements.length > 0
817 if values = ask_whitebalance(nil, nil, nil, nil, '', nil, nil, '')
818 distribute_multiple_call.call(:whitebalance, values)
821 closures[:whitebalance].call
824 if !possible_actions[:can_multiple] || $selected_elements.length == 0
825 menu.append(enhance = Gtk::ImageMenuItem.new(utf8(xmldir.attributes["#{attributes_prefix}enhance"] ? _("Original contrast") :
826 _("Enhance constrast"))))
828 menu.append(enhance = Gtk::ImageMenuItem.new(utf8(_("Toggle contrast enhancement"))))
830 enhance.image = Gtk::Image.new("#{$FPATH}/images/stock-channels-16.png")
831 enhance.signal_connect('activate') { distribute_multiple_call.call(:enhance) }
832 if type == 'image' && possible_actions[:can_panorama]
833 menu.append(panorama = Gtk::ImageMenuItem.new(utf8(_("Set as panorama"))))
834 panorama.image = Gtk::Image.new("#{$FPATH}/images/stock-images-16.png")
835 panorama.signal_connect('activate') {
836 if possible_actions[:can_multiple] && $selected_elements.length > 0
837 if values = ask_new_pano_amount(nil, '')
838 distribute_multiple_call.call(:pano, values)
841 distribute_multiple_call.call(:pano)
845 if optionals.include?('delete')
846 menu.append( Gtk::SeparatorMenuItem.new)
847 menu.append(cut_item = Gtk::ImageMenuItem.new(Gtk::Stock::CUT))
848 cut_item.signal_connect('activate') { distribute_multiple_call.call(:cut) }
849 if !possible_actions[:can_multiple] || $selected_elements.length == 0
850 menu.append(paste_item = Gtk::ImageMenuItem.new(Gtk::Stock::PASTE))
851 paste_item.signal_connect('activate') { closures[:paste].call }
852 menu.append(clear_item = Gtk::ImageMenuItem.new(Gtk::Stock::CLEAR))
853 clear_item.signal_connect('activate') { $cuts = [] }
855 paste_item.sensitive = clear_item.sensitive = false
858 menu.append( Gtk::SeparatorMenuItem.new)
859 menu.append(delete_item = Gtk::ImageMenuItem.new(Gtk::Stock::DELETE))
860 delete_item.signal_connect('activate') { distribute_multiple_call.call(:delete) }
863 menu.popup(nil, nil, event.button, event.time)
866 def delete_current_subalbum
868 sel = $albums_tv.selection.selected_rows
869 $xmldir.elements.each { |e|
870 if e.name == 'image' || e.name == 'video'
871 e.add_attribute('deleted', 'true')
874 #- branch if we have a non deleted subalbum
875 if $xmldir.child_byname_notattr('dir', 'deleted')
876 $xmldir.delete_attribute('thumbnails-caption')
877 $xmldir.delete_attribute('thumbnails-captionfile')
879 $xmldir.add_attribute('deleted', 'true')
881 while moveup.parent.name == 'dir'
882 moveup = moveup.parent
883 if !moveup.child_byname_notattr('dir', 'deleted') && !moveup.child_byname_notattr('image', 'deleted') && !moveup.child_byname_notattr('video', 'deleted')
884 moveup.add_attribute('deleted', 'true')
891 save_changes('forced')
892 populate_subalbums_treeview(false)
893 $albums_tv.selection.select_path(sel[0])
899 $current_path = nil #- prevent save_changes from being rerun again
900 sel = $albums_tv.selection.selected_rows
901 restore_one = proc { |xmldir|
902 xmldir.elements.each { |e|
903 if e.name == 'dir' && e.attributes['deleted']
906 e.delete_attribute('deleted')
909 restore_one.call($xmldir)
910 populate_subalbums_treeview(false)
911 $albums_tv.selection.select_path(sel[0])
914 def add_thumbnail(autotable, filename, type, thumbnail_img, caption)
917 frame1 = Gtk::Frame.new
918 fullpath = from_utf8("#{$current_path}/#{filename}")
920 my_gen_real_thumbnail = proc {
921 gen_real_thumbnail('element', fullpath, thumbnail_img, $xmldir, $default_size['thumbnails'], img, '')
924 #- generate the thumbnail if missing (if image was rotated but booh was not relaunched)
925 if !$modified_pixbufs[thumbnail_img] && !File.exists?(thumbnail_img)
926 frame1.add(img = Gtk::Image.new)
927 my_gen_real_thumbnail.call
929 frame1.add(img = Gtk::Image.new($modified_pixbufs[thumbnail_img] ? $modified_pixbufs[thumbnail_img][:pixbuf] : thumbnail_img))
931 evtbox = Gtk::EventBox.new.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(frame1.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)))
933 tooltips = Gtk::Tooltips.new
934 tipname = from_utf8(File.basename(filename).gsub(/\.[^\.]+$/, ''))
935 tooltips.set_tip(evtbox, utf8(type == 'video' ? (_("%s (video - %s KB)") % [tipname, commify(file_size(fullpath)/1024)]) : tipname), nil)
937 frame2, textview = create_editzone($autotable_sw, 1, img)
938 textview.buffer.text = utf8(caption)
939 textview.set_justification(Gtk::Justification::CENTER)
941 vbox = Gtk::VBox.new(false, 5)
942 vbox.pack_start(evtbox, false, false)
943 vbox.pack_start(frame2, false, false)
944 autotable.append(vbox, filename)
946 #- to be able to grab focus of textview when retrieving vbox's position from AutoTable
947 $vbox2widgets[vbox] = { :textview => textview, :image => img }
949 #- to be able to find widgets by name
950 $name2widgets[filename] = { :textview => textview, :evtbox => evtbox, :vbox => vbox, :img => img, :type => type }
952 cleanup_all_thumbnails = Proc.new {
953 #- remove out of sync images
954 dest_img_base = build_full_dest_filename(filename).sub(/\.[^\.]+$/, '')
955 for sizeobj in $images_size
956 system("rm -f #{dest_img_base}-#{sizeobj['fullscreen']}.jpg #{dest_img_base}-#{sizeobj['thumbnails']}.jpg")
961 rotate_and_cleanup = Proc.new { |angle|
962 rotate(angle, thumbnail_img, img, $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y])
963 cleanup_all_thumbnails.call
966 move = Proc.new { |direction|
967 do_method = "move_#{direction}"
968 undo_method = "move_" + case direction; when 'left'; 'right'; when 'right'; 'left'; when 'up'; 'down'; when 'down'; 'up' end
970 done = autotable.method(do_method).call(vbox)
971 textview.grab_focus #- because if moving, focus is stolen
975 save_undo(_("move %s") % direction,
977 autotable.method(undo_method).call(vbox)
978 textview.grab_focus #- because if moving, focus is stolen
979 autoscroll_if_needed($autotable_sw, img, textview)
980 $notebook.set_page(1)
982 autotable.method(do_method).call(vbox)
983 textview.grab_focus #- because if moving, focus is stolen
984 autoscroll_if_needed($autotable_sw, img, textview)
985 $notebook.set_page(1)
991 color_swap_and_cleanup = Proc.new {
992 perform_color_swap_and_cleanup = Proc.new {
993 color_swap($xmldir.elements["*[@filename='#{filename}']"], '')
994 my_gen_real_thumbnail.call
997 cleanup_all_thumbnails.call
998 perform_color_swap_and_cleanup.call
1000 save_undo(_("color swap"),
1002 perform_color_swap_and_cleanup.call
1004 autoscroll_if_needed($autotable_sw, img, textview)
1005 $notebook.set_page(1)
1007 perform_color_swap_and_cleanup.call
1009 autoscroll_if_needed($autotable_sw, img, textview)
1010 $notebook.set_page(1)
1015 change_frame_offset_and_cleanup_real = Proc.new { |values|
1016 perform_change_frame_offset_and_cleanup = Proc.new { |val|
1017 change_frame_offset($xmldir.elements["*[@filename='#{filename}']"], '', val)
1018 my_gen_real_thumbnail.call
1020 perform_change_frame_offset_and_cleanup.call(values[:new])
1022 save_undo(_("specify frame offset"),
1024 perform_change_frame_offset_and_cleanup.call(values[:old])
1026 autoscroll_if_needed($autotable_sw, img, textview)
1027 $notebook.set_page(1)
1029 perform_change_frame_offset_and_cleanup.call(values[:new])
1031 autoscroll_if_needed($autotable_sw, img, textview)
1032 $notebook.set_page(1)
1037 change_frame_offset_and_cleanup = Proc.new {
1038 if values = ask_new_frame_offset($xmldir.elements["*[@filename='#{filename}']"], '')
1039 change_frame_offset_and_cleanup_real.call(values)
1043 change_pano_amount_and_cleanup_real = Proc.new { |values|
1044 perform_change_pano_amount_and_cleanup = Proc.new { |val|
1045 change_pano_amount($xmldir.elements["*[@filename='#{filename}']"], '', val)
1047 perform_change_pano_amount_and_cleanup.call(values[:new])
1049 save_undo(_("change panorama amount"),
1051 perform_change_pano_amount_and_cleanup.call(values[:old])
1053 autoscroll_if_needed($autotable_sw, img, textview)
1054 $notebook.set_page(1)
1056 perform_change_pano_amount_and_cleanup.call(values[:new])
1058 autoscroll_if_needed($autotable_sw, img, textview)
1059 $notebook.set_page(1)
1064 change_pano_amount_and_cleanup = Proc.new {
1065 if values = ask_new_pano_amount($xmldir.elements["*[@filename='#{filename}']"], '')
1066 change_pano_amount_and_cleanup_real.call(values)
1070 whitebalance_and_cleanup_real = Proc.new { |values|
1071 perform_change_whitebalance_and_cleanup = Proc.new { |val|
1072 change_whitebalance($xmldir.elements["*[@filename='#{filename}']"], '', val)
1073 recalc_whitebalance(val, fullpath, thumbnail_img, img,
1074 $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y], '')
1075 cleanup_all_thumbnails.call
1077 perform_change_whitebalance_and_cleanup.call(values[:new])
1079 save_undo(_("fix white balance"),
1081 perform_change_whitebalance_and_cleanup.call(values[:old])
1083 autoscroll_if_needed($autotable_sw, img, textview)
1084 $notebook.set_page(1)
1086 perform_change_whitebalance_and_cleanup.call(values[:new])
1088 autoscroll_if_needed($autotable_sw, img, textview)
1089 $notebook.set_page(1)
1094 whitebalance_and_cleanup = Proc.new {
1095 if values = ask_whitebalance(fullpath, thumbnail_img, img,
1096 $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y], '')
1097 whitebalance_and_cleanup_real.call(values)
1101 enhance_and_cleanup = Proc.new {
1102 perform_enhance_and_cleanup = Proc.new {
1103 enhance($xmldir.elements["*[@filename='#{filename}']"], '')
1104 my_gen_real_thumbnail.call
1107 cleanup_all_thumbnails.call
1108 perform_enhance_and_cleanup.call
1110 save_undo(_("enhance"),
1112 perform_enhance_and_cleanup.call
1114 autoscroll_if_needed($autotable_sw, img, textview)
1115 $notebook.set_page(1)
1117 perform_enhance_and_cleanup.call
1119 autoscroll_if_needed($autotable_sw, img, textview)
1120 $notebook.set_page(1)
1125 delete = Proc.new { |isacut|
1126 if autotable.current_order.size > 1 || show_popup($main_window, utf8(_("Do you confirm this subalbum needs to be completely removed? This operation cannot be undone.")), { :okcancel => true })
1129 perform_delete = Proc.new {
1130 after = autotable.get_next_widget(vbox)
1132 after = autotable.get_previous_widget(vbox)
1134 if $config['deleteondisk'] && !isacut
1135 msg 3, "scheduling for delete: #{fullpath}"
1136 $todelete << fullpath
1138 autotable.remove(vbox)
1140 $vbox2widgets[after][:textview].grab_focus
1141 autoscroll_if_needed($autotable_sw, $vbox2widgets[after][:image], $vbox2widgets[after][:textview])
1145 previous_pos = autotable.get_current_number(vbox)
1149 delete_current_subalbum
1151 save_undo(_("delete"),
1153 autotable.reinsert(pos, vbox, filename)
1154 $notebook.set_page(1)
1155 autotable.queue_draws << proc { textview.grab_focus; autoscroll_if_needed($autotable_sw, img, textview) }
1157 msg 3, "removing deletion schedule of: #{fullpath}"
1158 $todelete.delete(fullpath) #- unconditional because deleteondisk option could have been modified
1161 $notebook.set_page(1)
1170 $cuts << { :vbox => vbox, :filename => filename }
1171 $statusbar.push(0, utf8(_("%s elements in the clipboard.") % $cuts.size ))
1176 autotable.reinsert(autotable.get_current_number(vbox), elem[:vbox], elem[:filename])
1179 autotable.queue_draws << proc {
1180 $vbox2widgets[last[:vbox]][:textview].grab_focus
1181 autoscroll_if_needed($autotable_sw, $vbox2widgets[last[:vbox]][:image], $vbox2widgets[last[:vbox]][:textview])
1183 save_undo(_("paste"),
1185 cuts.each { |elem| autotable.remove(elem[:vbox]) }
1186 $notebook.set_page(1)
1189 autotable.reinsert(autotable.get_current_number(vbox), elem[:vbox], elem[:filename])
1191 $notebook.set_page(1)
1194 $statusbar.push(0, utf8(_("Pasted %s elements.") % $cuts.size ))
1199 $name2closures[filename] = { :rotate => rotate_and_cleanup, :enhance => enhance_and_cleanup, :delete => delete, :cut => cut,
1200 :color_swap => color_swap_and_cleanup, :frame_offset => change_frame_offset_and_cleanup_real,
1201 :whitebalance => whitebalance_and_cleanup_real, :pano => change_pano_amount_and_cleanup_real }
1203 textview.signal_connect('key-press-event') { |w, event|
1206 x, y = autotable.get_current_pos(vbox)
1207 control_pressed = event.state & Gdk::Window::CONTROL_MASK != 0
1208 shift_pressed = event.state & Gdk::Window::SHIFT_MASK != 0
1209 alt_pressed = event.state & Gdk::Window::MOD1_MASK != 0
1210 if event.keyval == Gdk::Keyval::GDK_Up && y > 0
1212 if widget_up = autotable.get_widget_at_pos(x, y - 1)
1213 $vbox2widgets[widget_up][:textview].grab_focus
1220 if event.keyval == Gdk::Keyval::GDK_Down && y < autotable.get_max_y
1222 if widget_down = autotable.get_widget_at_pos(x, y + 1)
1223 $vbox2widgets[widget_down][:textview].grab_focus
1230 if event.keyval == Gdk::Keyval::GDK_Left
1233 $vbox2widgets[autotable.get_previous_widget(vbox)][:textview].grab_focus
1240 rotate_and_cleanup.call(-90)
1243 if event.keyval == Gdk::Keyval::GDK_Right
1244 next_ = autotable.get_next_widget(vbox)
1245 if next_ && autotable.get_current_pos(next_)[0] > x
1247 $vbox2widgets[next_][:textview].grab_focus
1254 rotate_and_cleanup.call(90)
1257 if event.keyval == Gdk::Keyval::GDK_Delete && control_pressed
1260 if event.keyval == Gdk::Keyval::GDK_Return && control_pressed
1261 view_element(filename, { :delete => delete })
1264 if event.keyval == Gdk::Keyval::GDK_z && control_pressed
1267 if event.keyval == Gdk::Keyval::GDK_r && control_pressed
1271 !propagate #- propagate if needed
1274 $ignore_next_release = false
1275 evtbox.signal_connect('button-press-event') { |w, event|
1276 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
1277 if event.state & Gdk::Window::BUTTON3_MASK != 0
1278 #- gesture redo: hold right mouse button then click left mouse button
1279 $config['nogestures'] or perform_redo
1280 $ignore_next_release = true
1282 shift_or_control = event.state & Gdk::Window::SHIFT_MASK != 0 || event.state & Gdk::Window::CONTROL_MASK != 0
1284 rotate_and_cleanup.call(shift_or_control ? -90 : 90)
1286 rotate_and_cleanup.call(shift_or_control ? 90 : -90)
1287 elsif $enhance.active?
1288 enhance_and_cleanup.call
1289 elsif $delete.active?
1293 $config['nogestures'] or $gesture_press = { :filename => filename, :x => event.x, :y => event.y }
1296 $button1_pressed_autotable = true
1297 elsif event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
1298 if event.state & Gdk::Window::BUTTON1_MASK != 0
1299 #- gesture undo: hold left mouse button then click right mouse button
1300 $config['nogestures'] or perform_undo
1301 $ignore_next_release = true
1303 elsif event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
1304 view_element(filename, { :delete => delete })
1309 evtbox.signal_connect('button-release-event') { |w, event|
1310 if event.event_type == Gdk::Event::BUTTON_RELEASE && event.button == 3
1311 if !$ignore_next_release
1312 x, y = autotable.get_current_pos(vbox)
1313 next_ = autotable.get_next_widget(vbox)
1314 popup_thumbnail_menu(event, ['delete'], fullpath, type, $xmldir.elements["*[@filename='#{filename}']"], '',
1315 { :can_left => x > 0, :can_right => next_ && autotable.get_current_pos(next_)[0] > x,
1316 :can_up => y > 0, :can_down => y < autotable.get_max_y, :can_multiple => true, :can_panorama => true },
1317 { :rotate => rotate_and_cleanup, :move => move, :color_swap => color_swap_and_cleanup, :enhance => enhance_and_cleanup,
1318 :frame_offset => change_frame_offset_and_cleanup, :delete => delete, :whitebalance => whitebalance_and_cleanup,
1319 :cut => cut, :paste => paste, :view => proc { view_element(filename, { :delete => delete }) },
1320 :pano => change_pano_amount_and_cleanup })
1322 $ignore_next_release = false
1323 $gesture_press = nil
1328 #- handle reordering with drag and drop
1329 Gtk::Drag.source_set(vbox, Gdk::Window::BUTTON1_MASK, [['reorder-elements', Gtk::Drag::TARGET_SAME_APP, 1]], Gdk::DragContext::ACTION_MOVE)
1330 Gtk::Drag.dest_set(vbox, Gtk::Drag::DEST_DEFAULT_ALL, [['reorder-elements', Gtk::Drag::TARGET_SAME_APP, 1]], Gdk::DragContext::ACTION_MOVE)
1331 vbox.signal_connect('drag-data-get') { |w, ctxt, selection_data, info, time|
1332 selection_data.set(Gdk::Selection::TYPE_STRING, autotable.get_current_number(vbox).to_s)
1335 vbox.signal_connect('drag-data-received') { |w, ctxt, x, y, selection_data, info, time|
1337 #- mouse gesture first (dnd disables button-release-event)
1338 if $gesture_press && $gesture_press[:filename] == filename
1339 if (($gesture_press[:x]-x)/($gesture_press[:y]-y)).abs > 2 && ($gesture_press[:x]-x).abs > 5
1340 angle = x-$gesture_press[:x] > 0 ? 90 : -90
1341 msg 3, "gesture rotate: #{angle}: click-drag right button to the left/right"
1342 rotate_and_cleanup.call(angle)
1343 $statusbar.push(0, utf8(_("Mouse gesture: rotate.")))
1345 elsif (($gesture_press[:y]-y)/($gesture_press[:x]-x)).abs > 2 && y-$gesture_press[:y] > 5
1346 msg 3, "gesture delete: click-drag right button to the bottom"
1348 $statusbar.push(0, utf8(_("Mouse gesture: delete.")))
1353 ctxt.targets.each { |target|
1354 if target.name == 'reorder-elements'
1355 move_dnd = Proc.new { |from,to|
1358 autotable.move(from, to)
1359 save_undo(_("reorder"),
1360 Proc.new { |from, to|
1362 autotable.move(to - 1, from)
1364 autotable.move(to, from + 1)
1366 $notebook.set_page(1)
1368 autotable.move(from, to)
1369 $notebook.set_page(1)
1374 if $multiple_dnd.size == 0
1375 move_dnd.call(selection_data.data.to_i,
1376 autotable.get_current_number(vbox))
1378 UndoHandler.begin_batch
1379 $multiple_dnd.sort { |a,b| autotable.get_current_number($name2widgets[a][:vbox]) <=> autotable.get_current_number($name2widgets[b][:vbox]) }.
1381 #- need to update current position between each call
1382 move_dnd.call(autotable.get_current_number($name2widgets[path][:vbox]),
1383 autotable.get_current_number(vbox))
1385 UndoHandler.end_batch
1396 def create_auto_table
1398 $autotable = Gtk::AutoTable.new(5)
1400 $autotable_sw = Gtk::ScrolledWindow.new(nil, nil)
1401 thumbnails_vb = Gtk::VBox.new(false, 5)
1403 frame, $thumbnails_title = create_editzone($autotable_sw, 0, nil)
1404 $thumbnails_title.set_justification(Gtk::Justification::CENTER)
1405 thumbnails_vb.pack_start(Gtk::Alignment.new(0.5, 0.5, 0.5, 0).add(frame), false, false)
1406 thumbnails_vb.add($autotable)
1408 $autotable_sw.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_ALWAYS)
1409 $autotable_sw.add_with_viewport(thumbnails_vb)
1411 #- follows stuff for handling multiple elements selection
1412 press_x = nil; press_y = nil; pos_x = nil; pos_y = nil; $selected_elements = {}
1414 update_selected = Proc.new {
1415 $autotable.current_order.each { |path|
1416 w = $name2widgets[path][:evtbox].window
1417 xm = w.position[0] + w.size[0]/2
1418 ym = w.position[1] + w.size[1]/2
1419 if ym < press_y && ym > pos_y || ym < pos_y && ym > press_y
1420 if (xm < press_x && xm > pos_x || xm < pos_x && xm > press_x) && ! $selected_elements[path]
1421 $selected_elements[path] = { :pixbuf => $name2widgets[path][:img].pixbuf }
1422 $name2widgets[path][:img].pixbuf = $name2widgets[path][:img].pixbuf.saturate_and_pixelate(1, true)
1425 if $selected_elements[path] && ! $selected_elements[path][:keep]
1426 if ((xm < press_x && xm < pos_x || xm > pos_x && xm > press_x) || (ym < press_y && ym < pos_y || ym > pos_y && ym > press_y))
1427 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
1428 $selected_elements.delete(path)
1433 $autotable.signal_connect('realize') { |w,e|
1434 gc = Gdk::GC.new($autotable.window)
1435 gc.set_line_attributes(1, Gdk::GC::LINE_ON_OFF_DASH, Gdk::GC::CAP_PROJECTING, Gdk::GC::JOIN_ROUND)
1436 gc.function = Gdk::GC::INVERT
1437 #- autoscroll handling for DND and multiple selections
1438 Gtk.timeout_add(100) {
1439 w, x, y, mask = $autotable.window.pointer
1440 if mask & Gdk::Window::BUTTON1_MASK != 0
1441 if y < $autotable_sw.vadjustment.value
1443 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1445 if $button1_pressed_autotable || press_x
1446 scroll_upper($autotable_sw, y)
1449 w, pos_x, pos_y = $autotable.window.pointer
1450 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1451 update_selected.call
1454 if y > $autotable_sw.vadjustment.value + $autotable_sw.vadjustment.page_size
1456 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1458 if $button1_pressed_autotable || press_x
1459 scroll_lower($autotable_sw, y)
1462 w, pos_x, pos_y = $autotable.window.pointer
1463 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1464 update_selected.call
1472 $autotable.signal_connect('button-press-event') { |w,e|
1474 if !$button1_pressed_autotable
1477 if e.state & Gdk::Window::SHIFT_MASK == 0
1478 $selected_elements.each_key { |path| $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf] }
1479 $selected_elements = {}
1480 $statusbar.push(0, utf8(_("Nothing selected.")))
1482 $selected_elements.each_key { |path| $selected_elements[path][:keep] = true }
1484 set_mousecursor(Gdk::Cursor::TCROSS)
1488 $autotable.signal_connect('button-release-event') { |w,e|
1490 if $button1_pressed_autotable
1491 #- unselect all only now
1492 $multiple_dnd = $selected_elements.keys
1493 $selected_elements.each_key { |path| $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf] }
1494 $selected_elements = {}
1495 $button1_pressed_autotable = false
1498 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1499 if $selected_elements.length > 0
1500 $statusbar.push(0, utf8(_("%s elements selected.") % $selected_elements.length))
1503 press_x = press_y = pos_x = pos_y = nil
1504 set_mousecursor(Gdk::Cursor::LEFT_PTR)
1508 $autotable.signal_connect('motion-notify-event') { |w,e|
1511 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1515 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1516 update_selected.call
1522 def create_subalbums_page
1524 subalbums_hb = Gtk::HBox.new
1525 $subalbums_vb = Gtk::VBox.new(false, 5)
1526 subalbums_hb.pack_start($subalbums_vb, false, false)
1527 $subalbums_sw = Gtk::ScrolledWindow.new(nil, nil)
1528 $subalbums_sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
1529 $subalbums_sw.add_with_viewport(subalbums_hb)
1532 def save_current_file
1536 ios = File.open($filename, "w")
1537 $xmldoc.write(ios, 0)
1542 def save_current_file_user
1543 save_tempfilename = $filename
1544 $filename = $orig_filename
1547 $generated_outofline = false
1548 $filename = save_tempfilename
1550 msg 3, "performing actual deletion of: " + $todelete.join(', ')
1551 $todelete.each { |f|
1552 system("rm -f #{f}")
1556 def mark_document_as_dirty
1557 $xmldoc.elements.each('//dir') { |elem|
1558 elem.delete_attribute('already-generated')
1562 #- ret: true => ok false => cancel
1563 def ask_save_modifications(msg1, msg2, *options)
1565 options = options.size > 0 ? options[0] : {}
1567 if options[:disallow_cancel]
1568 dialog = Gtk::Dialog.new(msg1,
1570 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1571 [options[:no] || Gtk::Stock::CLOSE, Gtk::Dialog::RESPONSE_NO],
1572 [options[:yes] || Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_YES])
1574 dialog = Gtk::Dialog.new(msg1,
1576 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1577 [options[:cancel] || Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL],
1578 [options[:no] || Gtk::Stock::CLOSE, Gtk::Dialog::RESPONSE_NO],
1579 [options[:yes] || Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_YES])
1581 dialog.default_response = Gtk::Dialog::RESPONSE_YES
1582 dialog.vbox.add(Gtk::Label.new(msg2))
1583 dialog.window_position = Gtk::Window::POS_CENTER
1586 dialog.run { |response|
1588 if response == Gtk::Dialog::RESPONSE_YES
1589 save_current_file_user
1591 #- if we have generated an album but won't save modifications, we must remove
1592 #- already-generated markers in original file
1593 if $generated_outofline
1595 $xmldoc = REXML::Document.new File.new($orig_filename)
1596 mark_document_as_dirty
1597 ios = File.open($orig_filename, "w")
1598 $xmldoc.write(ios, 0)
1601 puts "exception: #{$!}"
1605 if response == Gtk::Dialog::RESPONSE_CANCEL
1608 $todelete = [] #- unconditionally clear the list of images/videos to delete
1614 def try_quit(*options)
1615 if ask_save_modifications(utf8(_("Save before quitting?")),
1616 utf8(_("Do you want to save your changes before quitting?")),
1622 def show_popup(parent, msg, *options)
1623 dialog = Gtk::Dialog.new
1624 if options[0] && options[0][:title]
1625 dialog.title = options[0][:title]
1627 dialog.title = utf8(_("Booh message"))
1629 lbl = Gtk::Label.new
1630 if options[0] && options[0][:nomarkup]
1635 if options[0] && options[0][:centered]
1636 lbl.set_justify(Gtk::Justification::CENTER)
1638 if options[0] && options[0][:selectable]
1639 lbl.selectable = true
1641 if options[0] && options[0][:topwidget]
1642 dialog.vbox.add(options[0][:topwidget])
1644 if options[0] && options[0][:scrolled]
1645 sw = Gtk::ScrolledWindow.new(nil, nil)
1646 sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
1647 sw.add_with_viewport(lbl)
1649 dialog.set_default_size(400, 500)
1651 dialog.vbox.add(lbl)
1652 dialog.set_default_size(200, 120)
1654 if options[0] && options[0][:okcancel]
1655 dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL)
1657 dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
1659 if options[0] && options[0][:pos_centered]
1660 dialog.window_position = Gtk::Window::POS_CENTER
1662 dialog.window_position = Gtk::Window::POS_MOUSE
1665 if options[0] && options[0][:linkurl]
1666 linkbut = Gtk::Button.new('')
1667 linkbut.child.markup = "<span foreground=\"#00000000FFFF\" underline=\"single\">#{options[0][:linkurl]}</span>"
1668 linkbut.signal_connect('clicked') { open_url(options[0][:linkurl] + '/index.html' ) }
1669 linkbut.relief = Gtk::RELIEF_NONE
1670 linkbut.signal_connect('enter-notify-event') { set_mousecursor(Gdk::Cursor::HAND2, linkbut); false }
1671 linkbut.signal_connect('leave-notify-event') { set_mousecursor(nil, linkbut); false }
1672 dialog.vbox.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(linkbut))
1677 if !options[0] || !options[0][:not_transient]
1678 dialog.transient_for = parent
1679 dialog.run { |response|
1681 if options[0] && options[0][:okcancel]
1682 return response == Gtk::Dialog::RESPONSE_OK
1686 dialog.signal_connect('response') { dialog.destroy }
1690 def backend_wait_message(parent, msg, infopipe_path, mode)
1692 w.set_transient_for(parent)
1695 vb = Gtk::VBox.new(false, 5).set_border_width(5)
1696 vb.pack_start(Gtk::Label.new(msg), false, false)
1698 vb.pack_start(frame1 = Gtk::Frame.new(utf8(_("Thumbnails"))).add(vb1 = Gtk::VBox.new(false, 5)))
1699 vb1.pack_start(pb1_1 = Gtk::ProgressBar.new.set_text(utf8(_("Scanning images and videos..."))), false, false)
1700 if mode != 'one dir scan'
1701 vb1.pack_start(pb1_2 = Gtk::ProgressBar.new.set_text(utf8(_("not started"))), false, false)
1703 if mode == 'web-album'
1704 vb.pack_start(frame2 = Gtk::Frame.new(utf8(_("HTML pages"))).add(vb1 = Gtk::VBox.new(false, 5)))
1705 vb1.pack_start(pb2 = Gtk::ProgressBar.new.set_text(utf8(_("not started"))), false, false)
1707 vb.pack_start(Gtk::HSeparator.new, false, false)
1709 bottom = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(b = Gtk::Button.new(utf8(_("_Abort"))))
1710 b.image = Gtk::Image.new("#{$FPATH}/images/stock-close-24.png")
1711 vb.pack_end(bottom, false, false)
1713 infopipe = File.open(infopipe_path, File::RDONLY | File::NONBLOCK)
1714 refresh_thread = Thread.new {
1715 directories_counter = 0
1716 while line = infopipe.gets
1717 if line =~ /^directories: (\d+), sizes: (\d+)/
1718 directories = $1.to_f + 1
1720 elsif line =~ /^walking: (.+)\|(.+), (\d+) elements$/
1721 elements = $3.to_f + 1
1722 if mode == 'web-album'
1726 gtk_thread_protect { pb1_1.fraction = 0 }
1727 if mode != 'one dir scan'
1728 newtext = utf8(full_src_dir_to_rel($1, $2))
1729 newtext = '/' if newtext == ''
1730 gtk_thread_protect { pb1_2.text = newtext }
1731 directories_counter += 1
1732 gtk_thread_protect { pb1_2.fraction = directories_counter / directories }
1734 elsif line =~ /^processing element$/
1735 element_counter += 1
1736 gtk_thread_protect { pb1_1.fraction = element_counter / elements }
1737 elsif line =~ /^processing size$/
1738 element_counter += 1
1739 gtk_thread_protect { pb1_1.fraction = element_counter / elements }
1740 elsif line =~ /^finished processing sizes$/
1741 gtk_thread_protect { pb1_1.fraction = 1 }
1742 elsif line =~ /^creating index.html$/
1743 gtk_thread_protect { pb1_2.text = utf8(_("finished")) }
1744 gtk_thread_protect { pb1_1.fraction = pb1_2.fraction = 1 }
1745 directories_counter = 0
1746 elsif line =~ /^index.html: (.+)\|(.+)/
1747 newtext = utf8(full_src_dir_to_rel($1, $2))
1748 newtext = '/' if newtext == ''
1749 gtk_thread_protect { pb2.text = newtext }
1750 directories_counter += 1
1751 gtk_thread_protect { pb2.fraction = directories_counter / directories }
1752 elsif line =~ /^die: (.*)$/
1759 w.signal_connect('delete-event') { w.destroy }
1760 w.signal_connect('destroy') {
1761 Thread.kill(refresh_thread)
1762 gtk_thread_flush #- needed because we're about to destroy widgets in w, for which they may be some pending gtk calls
1765 system("rm -f #{infopipe_path}")
1768 w.window_position = Gtk::Window::POS_CENTER
1774 def call_backend(cmd, waitmsg, mode, params)
1775 pipe = Tempfile.new("boohpipe")
1777 system("mkfifo #{pipe.path}")
1778 cmd += " --info-pipe #{pipe.path}"
1779 button, w8 = backend_wait_message($main_window, waitmsg, pipe.path, mode)
1784 id, exitstatus = Process.waitpid2(pid)
1785 gtk_thread_protect { w8.destroy }
1787 if params[:successmsg]
1788 gtk_thread_protect { show_popup($main_window, params[:successmsg], { :linkurl => params[:successmsg_linkurl] }) }
1790 if params[:closure_after]
1791 gtk_thread_protect(¶ms[:closure_after])
1793 elsif exitstatus == 15
1794 #- say nothing, user aborted
1796 gtk_thread_protect { show_popup($main_window,
1797 utf8(_("There was something wrong, sorry:\n\n%s") % $diemsg)) }
1803 button.signal_connect('clicked') {
1804 Process.kill('SIGTERM', pid)
1808 def save_changes(*forced)
1809 if forced.empty? && (!$current_path || !$undo_tb.sensitive?)
1813 $xmldir.delete_attribute('already-generated')
1815 propagate_children = Proc.new { |xmldir|
1816 if xmldir.attributes['subdirs-caption']
1817 xmldir.delete_attribute('already-generated')
1819 xmldir.elements.each('dir') { |element|
1820 propagate_children.call(element)
1824 if $xmldir.child_byname_notattr('dir', 'deleted')
1825 new_title = $subalbums_title.buffer.text
1826 if new_title != $xmldir.attributes['subdirs-caption']
1827 parent = $xmldir.parent
1828 if parent.name == 'dir'
1829 parent.delete_attribute('already-generated')
1831 propagate_children.call($xmldir)
1833 $xmldir.add_attribute('subdirs-caption', new_title)
1834 $xmldir.elements.each('dir') { |element|
1835 if !element.attributes['deleted']
1836 path = element.attributes['path']
1837 newtext = $subalbums_edits[path][:editzone].buffer.text
1838 if element.attributes['subdirs-caption']
1839 if element.attributes['subdirs-caption'] != newtext
1840 propagate_children.call(element)
1842 element.add_attribute('subdirs-caption', newtext)
1843 element.add_attribute('subdirs-captionfile', utf8($subalbums_edits[path][:captionfile]))
1845 if element.attributes['thumbnails-caption'] != newtext
1846 element.delete_attribute('already-generated')
1848 element.add_attribute('thumbnails-caption', newtext)
1849 element.add_attribute('thumbnails-captionfile', utf8($subalbums_edits[path][:captionfile]))
1855 if $notebook.page == 0 && $xmldir.child_byname_notattr('dir', 'deleted')
1856 if $xmldir.attributes['thumbnails-caption']
1857 path = $xmldir.attributes['path']
1858 $xmldir.add_attribute('thumbnails-caption', $subalbums_edits[path][:editzone].buffer.text)
1860 elsif $xmldir.attributes['thumbnails-caption']
1861 $xmldir.add_attribute('thumbnails-caption', $thumbnails_title.buffer.text)
1864 #- remove and reinsert elements to reflect new ordering
1867 $xmldir.elements.each { |element|
1868 if element.name == 'image' || element.name == 'video'
1869 saves[element.attributes['filename']] = element.remove
1873 $autotable.current_order.each { |path|
1874 chld = $xmldir.add_element(saves[path].name, saves[path].attributes)
1875 chld.add_attribute('caption', $name2widgets[File.basename(path)][:textview].buffer.text)
1878 saves.each_key { |path|
1879 chld = $xmldir.add_element(saves[path].name, saves[path].attributes)
1880 chld.add_attribute('deleted', 'true')
1884 def remove_all_captions
1887 $autotable.current_order.each { |path|
1888 texts[File.basename(path) ] = $name2widgets[File.basename(path)][:textview].buffer.text
1889 $name2widgets[File.basename(path)][:textview].buffer.text = ''
1891 save_undo(_("remove all captions"),
1893 texts.each_key { |key|
1894 $name2widgets[key][:textview].buffer.text = texts[key]
1896 $notebook.set_page(1)
1898 texts.each_key { |key|
1899 $name2widgets[key][:textview].buffer.text = ''
1901 $notebook.set_page(1)
1907 $selected_elements.each_key { |path|
1908 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
1914 $selected_elements = {}
1918 $undo_tb.sensitive = $undo_mb.sensitive = false
1919 $redo_tb.sensitive = $redo_mb.sensitive = false
1925 $subalbums_vb.children.each { |chld|
1926 $subalbums_vb.remove(chld)
1928 $subalbums = Gtk::Table.new(0, 0, true)
1929 current_y_sub_albums = 0
1931 $xmldir = $xmldoc.elements["//dir[@path='#{$current_path}']"]
1932 $subalbums_edits = {}
1933 subalbums_counter = 0
1934 subalbums_edits_bypos = {}
1936 add_subalbum = Proc.new { |xmldir, counter|
1937 $subalbums_edits[xmldir.attributes['path']] = { :position => counter }
1938 subalbums_edits_bypos[counter] = $subalbums_edits[xmldir.attributes['path']]
1939 if xmldir == $xmldir
1940 thumbnail_file = "#{current_dest_dir}/thumbnails-thumbnail.jpg"
1941 caption = xmldir.attributes['thumbnails-caption']
1942 captionfile, dummy = find_subalbum_caption_info(xmldir)
1943 infotype = 'thumbnails'
1945 thumbnail_file = "#{current_dest_dir}/thumbnails-#{make_dest_filename(from_utf8(File.basename(xmldir.attributes['path'])))}.jpg"
1946 captionfile, caption = find_subalbum_caption_info(xmldir)
1947 infotype = find_subalbum_info_type(xmldir)
1949 msg 3, "add subdir: #{xmldir.attributes['path']} with file: #{thumbnail_file}"
1950 hbox = Gtk::HBox.new
1951 hbox.pack_start(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + File.basename(xmldir.attributes['path']) + ':</i>')))
1953 f.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)
1956 my_gen_real_thumbnail = proc {
1957 gen_real_thumbnail('subdir', captionfile, thumbnail_file, xmldir, $albums_thumbnail_size, img, infotype)
1960 if !$modified_pixbufs[thumbnail_file] && !File.exists?(thumbnail_file)
1961 f.add(img = Gtk::Image.new)
1962 my_gen_real_thumbnail.call
1964 f.add(img = Gtk::Image.new($modified_pixbufs[thumbnail_file] ? $modified_pixbufs[thumbnail_file][:pixbuf] : thumbnail_file))
1966 hbox.pack_end(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(evtbox = Gtk::EventBox.new.add(f)), false, false)
1967 $subalbums.attach(hbox,
1968 0, 1, current_y_sub_albums, current_y_sub_albums + 1, Gtk::FILL, Gtk::FILL, 2, 2)
1970 frame, textview = create_editzone($subalbums_sw, 0, img)
1971 textview.buffer.text = caption
1972 $subalbums.attach(Gtk::Alignment.new(0, 0.5, 0.5, 0).add(frame),
1973 1, 2, current_y_sub_albums, current_y_sub_albums + 1, Gtk::FILL, Gtk::FILL, 2, 2)
1975 change_image = Proc.new {
1976 fc = Gtk::FileChooserDialog.new(utf8(_("Select image for caption")),
1978 Gtk::FileChooser::ACTION_OPEN,
1980 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1981 fc.set_current_folder(from_utf8(xmldir.attributes['path']))
1982 fc.transient_for = $main_window
1983 fc.preview_widget = preview = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(f = Gtk::Frame.new.set_shadow_type(Gtk::SHADOW_ETCHED_OUT))
1984 f.add(preview_img = Gtk::Image.new)
1986 fc.signal_connect('update-preview') { |w|
1988 if fc.preview_filename
1989 preview_img.pixbuf = rotate_pixbuf(Gdk::Pixbuf.new(fc.preview_filename, 240, 180), guess_rotate(fc.preview_filename))
1990 fc.preview_widget_active = true
1992 rescue Gdk::PixbufError
1993 fc.preview_widget_active = false
1996 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
1998 old_file = captionfile
1999 old_rotate = xmldir.attributes["#{infotype}-rotate"]
2000 old_color_swap = xmldir.attributes["#{infotype}-color-swap"]
2001 old_enhance = xmldir.attributes["#{infotype}-enhance"]
2002 old_frame_offset = xmldir.attributes["#{infotype}-frame-offset"]
2004 new_file = fc.filename
2005 msg 3, "new captionfile is: #{fc.filename}"
2006 perform_changefile = Proc.new {
2007 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile = new_file
2008 $modified_pixbufs.delete(thumbnail_file)
2009 xmldir.delete_attribute("#{infotype}-rotate")
2010 xmldir.delete_attribute("#{infotype}-color-swap")
2011 xmldir.delete_attribute("#{infotype}-enhance")
2012 xmldir.delete_attribute("#{infotype}-frame-offset")
2013 my_gen_real_thumbnail.call
2015 perform_changefile.call
2017 save_undo(_("change caption file for sub-album"),
2019 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile = old_file
2020 xmldir.add_attribute("#{infotype}-rotate", old_rotate)
2021 xmldir.add_attribute("#{infotype}-color-swap", old_color_swap)
2022 xmldir.add_attribute("#{infotype}-enhance", old_enhance)
2023 xmldir.add_attribute("#{infotype}-frame-offset", old_frame_offset)
2024 my_gen_real_thumbnail.call
2025 $notebook.set_page(0)
2027 perform_changefile.call
2028 $notebook.set_page(0)
2035 rotate_and_cleanup = Proc.new { |angle|
2036 rotate(angle, thumbnail_file, img, xmldir, "#{infotype}-", $default_albums_thumbnails[:x], $default_albums_thumbnails[:y])
2037 system("rm -f '#{thumbnail_file}'")
2040 move = Proc.new { |direction|
2043 save_changes('forced')
2044 if direction == 'up'
2045 oldpos = $subalbums_edits[xmldir.attributes['path']][:position]
2046 $subalbums_edits[xmldir.attributes['path']][:position] -= 1
2047 subalbums_edits_bypos[oldpos - 1][:position] += 1
2049 oldpos = $subalbums_edits[xmldir.attributes['path']][:position]
2050 $subalbums_edits[xmldir.attributes['path']][:position] += 1
2051 subalbums_edits_bypos[oldpos + 1][:position] -= 1
2055 $xmldir.elements.each('dir') { |element|
2056 if (!element.attributes['deleted'])
2057 elems << [ element.attributes['path'], element.remove ]
2060 elems.sort { |a,b| $subalbums_edits[a[0]][:position] <=> $subalbums_edits[b[0]][:position] }.
2061 each { |e| $xmldir.add_element(e[1]) }
2062 #- need to remove the already-generated tag to all subdirs because of "previous/next albums" links completely changed
2063 $xmldir.elements.each('descendant::dir') { |elem|
2064 elem.delete_attribute('already-generated')
2069 color_swap_and_cleanup = Proc.new {
2070 perform_color_swap_and_cleanup = Proc.new {
2071 color_swap(xmldir, "#{infotype}-")
2072 my_gen_real_thumbnail.call
2074 perform_color_swap_and_cleanup.call
2076 save_undo(_("color swap"),
2078 perform_color_swap_and_cleanup.call
2079 $notebook.set_page(0)
2081 perform_color_swap_and_cleanup.call
2082 $notebook.set_page(0)
2087 change_frame_offset_and_cleanup = Proc.new {
2088 if values = ask_new_frame_offset(xmldir, "#{infotype}-")
2089 perform_change_frame_offset_and_cleanup = Proc.new { |val|
2090 change_frame_offset(xmldir, "#{infotype}-", val)
2091 my_gen_real_thumbnail.call
2093 perform_change_frame_offset_and_cleanup.call(values[:new])
2095 save_undo(_("specify frame offset"),
2097 perform_change_frame_offset_and_cleanup.call(values[:old])
2098 $notebook.set_page(0)
2100 perform_change_frame_offset_and_cleanup.call(values[:new])
2101 $notebook.set_page(0)
2107 whitebalance_and_cleanup = Proc.new {
2108 if values = ask_whitebalance(captionfile, thumbnail_file, img, xmldir, "#{infotype}-",
2109 $default_albums_thumbnails[:x], $default_albums_thumbnails[:y], infotype)
2110 perform_change_whitebalance_and_cleanup = Proc.new { |val|
2111 change_whitebalance(xmldir, "#{infotype}-", val)
2112 recalc_whitebalance(val, captionfile, thumbnail_file, img, xmldir, "#{infotype}-",
2113 $default_albums_thumbnails[:x], $default_albums_thumbnails[:y], infotype)
2114 system("rm -f '#{thumbnail_file}'")
2116 perform_change_whitebalance_and_cleanup.call(values[:new])
2118 save_undo(_("fix white balance"),
2120 perform_change_whitebalance_and_cleanup.call(values[:old])
2121 $notebook.set_page(0)
2123 perform_change_whitebalance_and_cleanup.call(values[:new])
2124 $notebook.set_page(0)
2130 enhance_and_cleanup = Proc.new {
2131 perform_enhance_and_cleanup = Proc.new {
2132 enhance(xmldir, "#{infotype}-")
2133 my_gen_real_thumbnail.call
2136 perform_enhance_and_cleanup.call
2138 save_undo(_("enhance"),
2140 perform_enhance_and_cleanup.call
2141 $notebook.set_page(0)
2143 perform_enhance_and_cleanup.call
2144 $notebook.set_page(0)
2149 evtbox.signal_connect('button-press-event') { |w, event|
2150 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
2152 rotate_and_cleanup.call(90)
2154 rotate_and_cleanup.call(-90)
2155 elsif $enhance.active?
2156 enhance_and_cleanup.call
2159 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
2160 popup_thumbnail_menu(event, ['change_image'], captionfile, entry2type(captionfile), xmldir, "#{infotype}-",
2161 { :forbid_left => true, :forbid_right => true,
2162 :can_up => counter > 1, :can_down => counter > 0 && counter < subalbums_counter },
2163 { :change => change_image, :move => move, :rotate => rotate_and_cleanup, :enhance => enhance_and_cleanup,
2164 :color_swap => color_swap_and_cleanup, :frame_offset => change_frame_offset_and_cleanup, :whitebalance => whitebalance_and_cleanup })
2166 if event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
2171 evtbox.signal_connect('button-press-event') { |w, event|
2172 $gesture_press = { :filename => thumbnail_file, :x => event.x, :y => event.y }
2176 evtbox.signal_connect('button-release-event') { |w, event|
2177 if !$r90.active? && !$r270.active? && $gesture_press && $gesture_press[:filename] == thumbnail_file
2178 msg 3, "press: #{$gesture_press[:x]} release: #{event.x}"
2179 if (($gesture_press[:x]-event.x)/($gesture_press[:y]-event.y)).abs > 2 && ($gesture_press[:x]-event.x).abs > 5
2180 angle = event.x-$gesture_press[:x] > 0 ? 90 : -90
2181 msg 3, "gesture rotate: #{angle}"
2182 rotate_and_cleanup.call(angle)
2185 $gesture_press = nil
2188 $subalbums_edits[xmldir.attributes['path']][:editzone] = textview
2189 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile
2190 current_y_sub_albums += 1
2193 if $xmldir.child_byname_notattr('dir', 'deleted')
2195 frame, $subalbums_title = create_editzone($subalbums_sw, 0, nil)
2196 $subalbums_title.buffer.text = $xmldir.attributes['subdirs-caption']
2197 $subalbums_title.set_justification(Gtk::Justification::CENTER)
2198 $subalbums_vb.pack_start(Gtk::Alignment.new(0.5, 0.5, 0.5, 0).add(frame), false, false)
2199 #- this album image/caption
2200 if $xmldir.attributes['thumbnails-caption']
2201 add_subalbum.call($xmldir, 0)
2204 total = { 'image' => 0, 'video' => 0, 'dir' => 0 }
2205 $xmldir.elements.each { |element|
2206 if (element.name == 'image' || element.name == 'video') && !element.attributes['deleted']
2207 #- element (image or video) of this album
2208 dest_img = build_full_dest_filename(element.attributes['filename']).sub(/\.[^\.]+$/, '') + "-#{$default_size['thumbnails']}.jpg"
2209 msg 3, "dest_img: #{dest_img}"
2210 add_thumbnail($autotable, element.attributes['filename'], element.name, dest_img, from_utf8(element.attributes['caption']))
2211 total[element.name] += 1
2213 if element.name == 'dir' && !element.attributes['deleted']
2214 #- sub-album image/caption
2215 add_subalbum.call(element, subalbums_counter += 1)
2216 total[element.name] += 1
2219 $statusbar.push(0, utf8(_("%s: %s images and %s videos, %s sub-albums") % [ File.basename(from_utf8($xmldir.attributes['path'])),
2220 total['image'], total['video'], total['dir'] ]))
2221 $subalbums_vb.add($subalbums)
2222 $subalbums_vb.show_all
2224 if !$xmldir.child_byname_notattr('image', 'deleted') && !$xmldir.child_byname_notattr('video', 'deleted')
2225 $notebook.get_tab_label($autotable_sw).sensitive = false
2226 $notebook.set_page(0)
2227 $thumbnails_title.buffer.text = ''
2229 $notebook.get_tab_label($autotable_sw).sensitive = true
2230 $thumbnails_title.buffer.text = $xmldir.attributes['thumbnails-caption']
2233 if !$xmldir.child_byname_notattr('dir', 'deleted')
2234 $notebook.get_tab_label($subalbums_sw).sensitive = false
2235 $notebook.set_page(1)
2237 $notebook.get_tab_label($subalbums_sw).sensitive = true
2241 def pixbuf_or_nil(filename)
2243 return Gdk::Pixbuf.new(filename)
2249 def theme_choose(current)
2250 dialog = Gtk::Dialog.new(utf8(_("Select your preferred theme")),
2252 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2253 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2254 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2256 model = Gtk::ListStore.new(String, Gdk::Pixbuf, Gdk::Pixbuf, Gdk::Pixbuf)
2257 treeview = Gtk::TreeView.new(model).set_rules_hint(true)
2258 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Theme name")), Gtk::CellRendererText.new, { :text => 0 }).set_alignment(0.5).set_spacing(5))
2259 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Sub-albums page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 1 }).set_alignment(0.5).set_spacing(5))
2260 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Thumbnails page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 2 }).set_alignment(0.5).set_spacing(5))
2261 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Fullscreen page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 3 }).set_alignment(0.5).set_spacing(5))
2262 treeview.append_column(Gtk::TreeViewColumn.new('', Gtk::CellRendererText.new, {}))
2263 treeview.signal_connect('button-press-event') { |w, event|
2264 if event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
2265 dialog.response(Gtk::Dialog::RESPONSE_OK)
2269 dialog.vbox.add(sw = Gtk::ScrolledWindow.new(nil, nil).add(treeview).set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC))
2271 `find '#{$FPATH}/themes' -mindepth 1 -maxdepth 1 -type d`.each { |dir|
2274 iter[0] = File.basename(dir)
2275 iter[1] = pixbuf_or_nil("#{dir}/metadata/screenshot-1.png")
2276 iter[2] = pixbuf_or_nil("#{dir}/metadata/screenshot-2.png")
2277 iter[3] = pixbuf_or_nil("#{dir}/metadata/screenshot-3.png")
2278 if File.basename(dir) == current
2279 treeview.selection.select_iter(iter)
2283 dialog.set_default_size(700, 400)
2284 dialog.vbox.show_all
2285 dialog.run { |response|
2286 iter = treeview.selection.selected
2288 if response == Gtk::Dialog::RESPONSE_OK && iter
2289 return model.get_value(iter, 0)
2295 def show_password_protections
2296 examine_dir_elem = Proc.new { |parent_iter, xmldir, already_protected|
2297 child_iter = $albums_iters[xmldir.attributes['path']]
2298 if xmldir.attributes['password-protect']
2299 child_iter[2] = pixbuf_or_nil("#{$FPATH}/images/galeon-secure.png")
2300 already_protected = true
2301 elsif already_protected
2302 pix = pixbuf_or_nil("#{$FPATH}/images/galeon-secure-shadow.png")
2304 pix = pix.saturate_and_pixelate(1, true)
2310 xmldir.elements.each('dir') { |elem|
2311 if !elem.attributes['deleted']
2312 examine_dir_elem.call(child_iter, elem, already_protected)
2316 examine_dir_elem.call(nil, $xmldoc.elements['//dir'], false)
2319 def populate_subalbums_treeview(select_first)
2323 $subalbums_vb.children.each { |chld|
2324 $subalbums_vb.remove(chld)
2327 source = $xmldoc.root.attributes['source']
2328 msg 3, "source: #{source}"
2330 xmldir = $xmldoc.elements['//dir']
2331 if !xmldir || xmldir.attributes['path'] != source
2332 msg 1, _("Corrupted booh file...")
2336 append_dir_elem = Proc.new { |parent_iter, xmldir|
2337 child_iter = $albums_ts.append(parent_iter)
2338 child_iter[0] = File.basename(xmldir.attributes['path'])
2339 child_iter[1] = xmldir.attributes['path']
2340 $albums_iters[xmldir.attributes['path']] = child_iter
2341 msg 3, "puttin location: #{xmldir.attributes['path']}"
2342 xmldir.elements.each('dir') { |elem|
2343 if !elem.attributes['deleted']
2344 append_dir_elem.call(child_iter, elem)
2348 append_dir_elem.call(nil, xmldir)
2349 show_password_protections
2351 $albums_tv.expand_all
2353 $albums_tv.selection.select_iter($albums_ts.iter_first)
2357 def open_file(filename)
2361 $current_path = nil #- invalidate
2362 $modified_pixbufs = {}
2365 $subalbums_vb.children.each { |chld|
2366 $subalbums_vb.remove(chld)
2369 if !File.exists?(filename)
2370 return utf8(_("File not found."))
2374 $xmldoc = REXML::Document.new File.new(filename)
2379 if !$xmldoc || !$xmldoc.root || $xmldoc.root.name != 'booh'
2380 if entry2type(filename).nil?
2381 return utf8(_("Not a booh file!"))
2383 return utf8(_("Not a booh file!\n\nHint: you cannot import directly an image or video with File/Open.\nUse File/New to create a new album."))
2387 if !source = $xmldoc.root.attributes['source']
2388 return utf8(_("Corrupted booh file..."))
2391 if !dest = $xmldoc.root.attributes['destination']
2392 return utf8(_("Corrupted booh file..."))
2395 if !theme = $xmldoc.root.attributes['theme']
2396 return utf8(_("Corrupted booh file..."))
2399 if $xmldoc.root.attributes['version'] < '0.8.4'
2400 msg 2, _("File's version %s, booh version now %s, marking dirty") % [ $xmldoc.root.attributes['version'], $VERSION ]
2401 mark_document_as_dirty
2402 if $xmldoc.root.attributes['version'] < '0.8.4'
2403 msg 1, _("File's version prior to 0.8.4, migrating directories and filenames in destination directory if needed")
2404 `find '#{source}' -type d -follow`.sort.collect { |v| v.chomp }.each { |dir|
2405 old_dest_dir = make_dest_filename_old(dir.sub(/^#{Regexp.quote(source)}/, dest))
2406 new_dest_dir = make_dest_filename(dir.sub(/^#{Regexp.quote(source)}/, dest))
2407 if old_dest_dir != new_dest_dir
2408 sys("mv '#{old_dest_dir}' '#{new_dest_dir}'")
2410 if xmldir = $xmldoc.elements["//dir[@path='#{utf8(dir)}']"]
2411 xmldir.elements.each { |element|
2412 if %w(image video).include?(element.name) && !element.attributes['deleted']
2413 old_name = new_dest_dir + '/' + make_dest_filename_old(from_utf8(element.attributes['filename'])).sub(/\.[^\.]+$/, '')
2414 new_name = new_dest_dir + '/' + make_dest_filename(from_utf8(element.attributes['filename'])).sub(/\.[^\.]+$/, '')
2415 Dir[old_name + '*'].each { |file|
2416 new_file = file.sub(/^#{Regexp.quote(old_name)}/, new_name)
2417 file != new_file and sys("mv '#{file}' '#{new_file}'")
2420 if element.name == 'dir' && !element.attributes['deleted']
2421 old_name = new_dest_dir + '/thumbnails-' + make_dest_filename_old(from_utf8(File.basename(element.attributes['path']))) + '.jpg'
2422 new_name = new_dest_dir + '/thumbnails-' + make_dest_filename(from_utf8(File.basename(element.attributes['path']))) + '.jpg'
2423 old_name != new_name and sys("mv '#{old_name}' '#{new_name}'")
2427 msg 1, "could not find <dir> element by path '#{utf8(dir)}' in xml file"
2431 $xmldoc.root.add_attribute('version', $VERSION)
2434 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2435 optimizefor32 = !$xmldoc.root.attributes['optimize-for-32'].nil?
2436 nperrow = $xmldoc.root.attributes['thumbnails-per-row']
2438 $filename = filename
2439 select_theme(theme, limit_sizes, optimizefor32, nperrow)
2440 $default_size['thumbnails'] =~ /(.*)x(.*)/
2441 $default_thumbnails = { :x => $1.to_i, :y => $2.to_i }
2442 $albums_thumbnail_size =~ /(.*)x(.*)/
2443 $default_albums_thumbnails = { :x => $1.to_i, :y => $2.to_i }
2445 populate_subalbums_treeview(true)
2447 $save.sensitive = $save_as.sensitive = $merge_current.sensitive = $merge_newsubs.sensitive = $merge.sensitive = $generate.sensitive = $view_wa.sensitive = $properties.sensitive = $remove_all_captions.sensitive = true
2451 def open_file_user(filename)
2452 result = open_file(filename)
2454 $config['last-opens'] ||= []
2455 if $config['last-opens'][-1] != utf8(filename)
2456 $config['last-opens'] << utf8(filename)
2458 $orig_filename = $filename
2459 tmp = Tempfile.new("boohtemp")
2462 ios = File.open($filename = tmp.path, File::RDWR|File::CREAT|File::EXCL)
2464 $tempfiles << $filename << "#{$filename}.backup"
2466 $orig_filename = nil
2472 if !ask_save_modifications(utf8(_("Save this album?")),
2473 utf8(_("Do you want to save the changes to this album?")),
2474 { :yes => Gtk::Stock::YES, :no => Gtk::Stock::NO })
2477 fc = Gtk::FileChooserDialog.new(utf8(_("Open file")),
2479 Gtk::FileChooser::ACTION_OPEN,
2481 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2482 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2483 fc.set_current_folder(File.expand_path("~/.booh"))
2484 fc.transient_for = $main_window
2487 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2488 push_mousecursor_wait(fc)
2489 msg = open_file_user(fc.filename)
2505 cmd = $config['browser'].gsub('%f', "'#{url}'") + ' &'
2510 def additional_booh_options
2513 options += "--mproc #{$config['mproc'].to_i} "
2515 if $config['emptycomments']
2516 options += "--empty-comments "
2522 if !ask_save_modifications(utf8(_("Save this album?")),
2523 utf8(_("Do you want to save the changes to this album?")),
2524 { :yes => Gtk::Stock::YES, :no => Gtk::Stock::NO })
2527 dialog = Gtk::Dialog.new(utf8(_("Create a new album")),
2529 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2530 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2531 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2533 frame1 = Gtk::Frame.new(utf8(_("Locations"))).add(tbl = Gtk::Table.new(0, 0, false))
2534 tbl.attach(Gtk::Label.new(utf8(_("Directory of images/videos: "))),
2535 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2536 tbl.attach(src = Gtk::Entry.new,
2537 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2538 tbl.attach(src_browse = Gtk::Button.new(utf8(_("browse..."))),
2539 2, 3, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2540 tbl.attach(Gtk::Label.new.set_markup(utf8(_("<span size='small'><i>number of images/videos down this directory:</i></span> "))),
2541 0, 1, 1, 2, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2542 tbl.attach(src_nb = Gtk::Label.new.set_markup(utf8(_("<span size='small'><i>N/A</i></span>"))),
2543 1, 2, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
2544 tbl.attach(Gtk::Label.new(utf8(_("Directory where to put the web-album: "))),
2545 0, 1, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2546 tbl.attach(dest = Gtk::Entry.new,
2547 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2548 tbl.attach(dest_browse = Gtk::Button.new(utf8(_("browse..."))),
2549 2, 3, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2550 tbl.attach(Gtk::Label.new(utf8(_("Filename to store this album's properties: "))),
2551 0, 1, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2552 tbl.attach(conf = Gtk::Entry.new.set_size_request(250, -1),
2553 1, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
2554 tbl.attach(conf_browse = Gtk::Button.new(utf8(_("browse..."))),
2555 2, 3, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2557 tooltips = Gtk::Tooltips.new
2558 frame2 = Gtk::Frame.new(utf8(_("Configuration"))).add(vb = Gtk::VBox.new)
2559 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Theme: "))), false, false, 0).
2560 pack_start(theme_button = Gtk::Button.new($config['default-theme'] || 'simple'), false, false, 0))
2561 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Sizes of images to generate: "))), false, false, 0).
2562 pack_start(sizes = Gtk::HBox.new, false, false, 0))
2563 vb.add(optimize432 = Gtk::CheckButton.new(utf8(_("Optimize for 3/2 aspect ratio"))))
2564 tooltips.set_tip(optimize432, utf8(_("Resize images with optimized sizes for 3/2 aspect ratio rather than 4/3 (typical aspect ratio of pictures from non digital cameras are 3/2 when pictures from digital cameras are 4/3)")), nil)
2565 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Number of thumbnails per row: "))), false, false, 0).
2566 pack_start(nperrowradios = Gtk::HBox.new, false, false, 0))
2567 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("'Made with' markup on pages bottom: "))), false, false, 0).
2568 pack_start(madewithentry = Gtk::Entry.new, true, true, 0))
2569 tooltips.set_tip(madewithentry, utf8(_("Optional HTML markup to use on pages bottom for a small 'made with' label; %booh is replaced by the website of booh;\nfor example: made with <a href=%booh>booh</a>!")), nil)
2571 src_nb_calculated_for = ''
2573 process_src_nb = Proc.new {
2574 if src.text != src_nb_calculated_for
2575 src_nb_calculated_for = src.text
2577 Thread.kill(src_nb_thread)
2580 if File.directory?(from_utf8(src_nb_calculated_for)) && src_nb_calculated_for != '/'
2581 if File.readable?(from_utf8(src_nb_calculated_for))
2582 src_nb_thread = Thread.new {
2583 gtk_thread_protect { src_nb.set_markup(utf8(_("<span size='small'><i>processing...</i></span>"))) }
2584 total = { 'image' => 0, 'video' => 0, nil => 0 }
2585 `find '#{from_utf8(src_nb_calculated_for)}' -type d -follow`.each { |dir|
2586 if File.basename(dir) =~ /^\./
2590 Dir.entries(dir.chomp).each { |file|
2591 total[entry2type(file)] += 1
2593 rescue Errno::EACCES, Errno::ENOENT
2597 gtk_thread_protect { src_nb.set_markup(utf8(_("<span size='small'><i>%s images and %s videos</i></span>") % [ total['image'], total['video'] ])) }
2601 src_nb.set_markup(utf8(_("<span size='small'><i>permission denied</i></span>")))
2604 src_nb.set_markup(utf8(_("<span size='small'><i>N/A</i></span>")))
2609 timeout_src_nb = Gtk.timeout_add(100) {
2613 src_browse.signal_connect('clicked') {
2614 fc = Gtk::FileChooserDialog.new(utf8(_("Select the directory of images/videos")),
2616 Gtk::FileChooser::ACTION_SELECT_FOLDER,
2618 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2619 fc.transient_for = $main_window
2620 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2621 src.text = utf8(fc.filename)
2623 conf.text = File.expand_path("~/.booh/#{File.basename(src.text)}")
2628 dest_browse.signal_connect('clicked') {
2629 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new directory where to put the web-album")),
2631 Gtk::FileChooser::ACTION_CREATE_FOLDER,
2633 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2634 fc.transient_for = $main_window
2635 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2636 dest.text = utf8(fc.filename)
2641 conf_browse.signal_connect('clicked') {
2642 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new file to store this album's properties")),
2644 Gtk::FileChooser::ACTION_SAVE,
2646 [Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2647 fc.transient_for = $main_window
2648 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2649 fc.set_current_folder(File.expand_path("~/.booh"))
2650 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2651 conf.text = utf8(fc.filename)
2658 recreate_theme_config = proc {
2659 theme_sizes.each { |e| sizes.remove(e[:widget]) }
2661 select_theme(theme_button.label, 'all', optimize432.active?, nil)
2662 $images_size.each { |s|
2663 sizes.add(cb = Gtk::CheckButton.new(sizename(s['name'])))
2667 tooltips.set_tip(cb, utf8(s['description']), nil)
2668 theme_sizes << { :widget => cb, :value => s['name'] }
2670 sizes.add(cb = Gtk::CheckButton.new(utf8(_('original'))))
2671 tooltips = Gtk::Tooltips.new
2672 tooltips.set_tip(cb, utf8(_("Include original image in web-album")), nil)
2673 theme_sizes << { :widget => cb, :value => 'original' }
2676 nperrows.each { |e| nperrowradios.remove(e[:widget]) }
2679 $allowed_N_values.each { |n|
2681 nperrowradios.add(rb = Gtk::RadioButton.new(nperrow_group, n.to_s, false))
2683 nperrowradios.add(nperrow_group = rb = Gtk::RadioButton.new(n.to_s))
2685 tooltips.set_tip(rb, utf8(_("Set the number of thumbnails per row of the 'thumbnails' pages (if chosen theme uses a row arrangement)")), nil)
2689 nperrows << { :widget => rb, :value => n }
2691 nperrowradios.show_all
2693 recreate_theme_config.call
2695 theme_button.signal_connect('clicked') {
2696 if newtheme = theme_choose(theme_button.label)
2697 theme_button.label = newtheme
2698 recreate_theme_config.call
2702 dialog.vbox.add(frame1)
2703 dialog.vbox.add(frame2)
2704 dialog.window_position = Gtk::Window::POS_MOUSE
2710 dialog.run { |response|
2711 if response == Gtk::Dialog::RESPONSE_OK
2712 srcdir = from_utf8(src.text)
2713 destdir = from_utf8(dest.text)
2714 if !File.directory?(srcdir)
2715 show_popup(dialog, utf8(_("The directory of images/videos doesn't exist. Please check your input.")))
2717 elsif conf.text == ''
2718 show_popup(dialog, utf8(_("Please specify a filename to store the album's properties.")))
2720 elsif File.directory?(from_utf8(conf.text))
2721 show_popup(dialog, utf8(_("Sorry, the filename specified to store the album's properties is an existing directory. Please choose another one.")))
2723 elsif destdir != make_dest_filename(destdir)
2724 show_popup(dialog, utf8(_("Sorry, destination directory can't contain non simple alphanumeric characters.")))
2726 elsif File.directory?(destdir) && Dir.entries(destdir).size > 2
2727 keepon = !show_popup(dialog, utf8(_("The destination directory already exists. Are you sure you want to continue?")), { :okcancel => true })
2729 elsif File.exists?(destdir) && !File.directory?(destdir)
2730 show_popup(dialog, utf8(_("There is already a file by the name of the destination directory. Please choose another one.")))
2732 elsif !theme_sizes.detect { |e| e[:value] != 'original' && e[:widget].active? }
2733 show_popup(dialog, utf8(_("You need to select at least one size (not counting original).")))
2735 system("mkdir '#{destdir}'")
2736 if !File.directory?(destdir)
2737 show_popup(dialog, utf8(_("Could not create destination directory. Permission denied?")))
2748 srcdir = from_utf8(src.text)
2749 destdir = from_utf8(dest.text)
2750 configskel = File.expand_path(from_utf8(conf.text))
2751 theme = theme_button.label
2752 sizes = theme_sizes.find_all { |e| e[:widget].active? }.collect { |e| e[:value] }.join(',')
2753 nperrow = nperrows.find { |e| e[:widget].active? }[:value]
2754 opt432 = optimize432.active?
2755 madewith = madewithentry.text
2757 Thread.kill(src_nb_thread)
2758 gtk_thread_flush #- needed because we're about to destroy widgets in dialog, for which they may be some pending gtk calls
2761 Gtk.timeout_remove(timeout_src_nb)
2764 call_backend("booh-backend --source '#{srcdir}' --destination '#{destdir}' --config-skel '#{configskel}' --for-gui " +
2765 "--verbose-level #{$verbose_level} --theme #{theme} --sizes #{sizes} --thumbnails-per-row #{nperrow} " +
2766 "#{opt432 ? '--optimize-for-32' : ''} --made-with '#{madewith}' #{additional_booh_options}",
2767 utf8(_("Please wait while scanning source directory...")),
2769 { :closure_after => proc { open_file_user(configskel) } })
2774 dialog = Gtk::Dialog.new(utf8(_("Properties of your album")),
2776 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2777 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2778 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2780 source = $xmldoc.root.attributes['source']
2781 dest = $xmldoc.root.attributes['destination']
2782 theme = $xmldoc.root.attributes['theme']
2783 opt432 = !$xmldoc.root.attributes['optimize-for-32'].nil?
2784 nperrow = $xmldoc.root.attributes['thumbnails-per-row']
2785 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2787 limit_sizes = limit_sizes.split(/,/)
2789 madewith = $xmldoc.root.attributes['made-with']
2791 tooltips = Gtk::Tooltips.new
2792 frame1 = Gtk::Frame.new(utf8(_("Locations"))).add(tbl = Gtk::Table.new(0, 0, false))
2793 tbl.attach(Gtk::Label.new(utf8(_("Directory of source images/videos: "))),
2794 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2795 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + source + '</i>')),
2796 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2797 tbl.attach(Gtk::Label.new(utf8(_("Directory where the web-album is created: "))),
2798 0, 1, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2799 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + dest + '</i>').set_selectable(true)),
2800 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2801 tbl.attach(Gtk::Label.new(utf8(_("Filename where this album's properties are stored: "))),
2802 0, 1, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2803 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + $orig_filename + '</i>').set_selectable(true)),
2804 1, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
2806 frame2 = Gtk::Frame.new(utf8(_("Configuration"))).add(vb = Gtk::VBox.new)
2807 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Theme: "))), false, false, 0).
2808 pack_start(theme_button = Gtk::Button.new(theme), false, false, 0))
2809 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Sizes of images to generate: "))), false, false, 0).
2810 pack_start(sizes = Gtk::HBox.new, false, false, 0))
2811 vb.add(optimize432 = Gtk::CheckButton.new(utf8(_("Optimize for 3/2 aspect ratio"))).set_active(opt432))
2812 tooltips.set_tip(optimize432, utf8(_("Resize images with optimized sizes for 3/2 aspect ratio rather than 4/3 (typical aspect ratio of pictures from non digital cameras are 3/2 when pictures from digital cameras are 4/3)")), nil)
2813 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Number of thumbnails per row: "))), false, false, 0).
2814 pack_start(nperrowradios = Gtk::HBox.new, false, false, 0))
2815 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("'Made with' markup on pages bottom: "))), false, false, 0).
2816 pack_start(madewithentry = Gtk::Entry.new, true, true, 0))
2818 madewithentry.text = madewith
2820 tooltips.set_tip(madewithentry, utf8(_("Optional HTML markup to use on pages bottom for a small 'made with' label; %booh is replaced by the website of booh;\nfor example: made with <a href=%booh>booh</a>!")), nil)
2824 recreate_theme_config = proc {
2825 theme_sizes.each { |e| sizes.remove(e[:widget]) }
2827 select_theme(theme_button.label, 'all', optimize432.active?, nperrow)
2829 $images_size.each { |s|
2830 sizes.add(cb = Gtk::CheckButton.new(sizename(s['name'])))
2832 if limit_sizes.include?(s['name'])
2840 tooltips.set_tip(cb, utf8(s['description']), nil)
2841 theme_sizes << { :widget => cb, :value => s['name'] }
2843 sizes.add(cb = Gtk::CheckButton.new(utf8(_('original'))))
2844 tooltips = Gtk::Tooltips.new
2845 tooltips.set_tip(cb, utf8(_("Include original image in web-album")), nil)
2846 if limit_sizes && limit_sizes.include?('original')
2849 theme_sizes << { :widget => cb, :value => 'original' }
2852 nperrows.each { |e| nperrowradios.remove(e[:widget]) }
2855 $allowed_N_values.each { |n|
2857 nperrowradios.add(rb = Gtk::RadioButton.new(nperrow_group, n.to_s, false))
2859 nperrowradios.add(nperrow_group = rb = Gtk::RadioButton.new(n.to_s))
2861 tooltips.set_tip(rb, utf8(_("Set the number of thumbnails per row of the 'thumbnails' pages (if chosen theme uses a row arrangement)")), nil)
2862 nperrowradios.add(Gtk::Label.new(' '))
2863 if nperrow && n.to_s == nperrow || !nperrow && $default_N == n
2866 nperrows << { :widget => rb, :value => n.to_s }
2868 nperrowradios.show_all
2870 recreate_theme_config.call
2872 theme_button.signal_connect('clicked') {
2873 if newtheme = theme_choose(theme_button.label)
2876 theme_button.label = newtheme
2877 recreate_theme_config.call
2881 dialog.vbox.add(frame1)
2882 dialog.vbox.add(frame2)
2883 dialog.window_position = Gtk::Window::POS_MOUSE
2889 dialog.run { |response|
2890 if response == Gtk::Dialog::RESPONSE_OK
2891 if !theme_sizes.detect { |e| e[:value] != 'original' && e[:widget].active? }
2892 show_popup(dialog, utf8(_("You need to select at least one size (not counting original).")))
2901 save_theme = theme_button.label
2902 save_limit_sizes = theme_sizes.find_all { |e| e[:widget].active? }.collect { |e| e[:value] }
2903 save_opt432 = optimize432.active?
2904 save_nperrow = nperrows.find { |e| e[:widget].active? }[:value]
2905 save_madewith = madewithentry.text
2908 if ok && (save_theme != theme || save_limit_sizes != limit_sizes || save_opt432 != opt432 || save_nperrow != nperrow || save_madewith != madewith)
2909 mark_document_as_dirty
2911 call_backend("booh-backend --use-config '#{$filename}' --for-gui --verbose-level #{$verbose_level} " +
2912 "--thumbnails-per-row #{save_nperrow} --theme #{save_theme} --sizes #{save_limit_sizes.join(',')} " +
2913 "#{save_opt432 ? '--optimize-for-32' : ''} --made-with '#{save_madewith}' #{additional_booh_options}",
2914 utf8(_("Please wait while scanning source directory...")),
2916 { :closure_after => proc {
2917 open_file($filename)
2926 sel = $albums_tv.selection.selected_rows
2928 call_backend("booh-backend --merge-config-onedir '#{$filename}' --dir '#{from_utf8($current_path)}' --for-gui " +
2929 "--verbose-level #{$verbose_level} #{additional_booh_options}",
2930 utf8(_("Please wait while scanning source directory...")),
2932 { :closure_after => proc {
2933 open_file($filename)
2934 $albums_tv.selection.select_path(sel[0])
2942 sel = $albums_tv.selection.selected_rows
2944 call_backend("booh-backend --merge-config-subdirs '#{$filename}' --dir '#{from_utf8($current_path)}' --for-gui " +
2945 "--verbose-level #{$verbose_level} #{additional_booh_options}",
2946 utf8(_("Please wait while scanning source directory...")),
2948 { :closure_after => proc {
2949 open_file($filename)
2950 $albums_tv.selection.select_path(sel[0])
2958 theme = $xmldoc.root.attributes['theme']
2959 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2961 limit_sizes = "--sizes #{limit_sizes}"
2963 call_backend("booh-backend --merge-config '#{$filename}' --for-gui " +
2964 "--verbose-level #{$verbose_level} --theme #{theme} #{limit_sizes} #{additional_booh_options}",
2965 utf8(_("Please wait while scanning source directory...")),
2967 { :closure_after => proc {
2968 open_file($filename)
2974 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new filename to store this album's properties")),
2976 Gtk::FileChooser::ACTION_SAVE,
2978 [Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2979 fc.transient_for = $main_window
2980 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2981 fc.set_current_folder(File.expand_path("~/.booh"))
2982 fc.filename = $orig_filename
2983 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2984 $orig_filename = fc.filename
2985 save_current_file_user
2991 dialog = Gtk::Dialog.new(utf8(_("Edit preferences")),
2993 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2994 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2995 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2997 dialog.vbox.add(notebook = Gtk::Notebook.new)
2998 notebook.append_page(tbl = Gtk::Table.new(0, 0, false), Gtk::Label.new(utf8(_("Options"))))
2999 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Command for watching videos: ")))),
3000 0, 1, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
3001 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(video_viewer_entry = Gtk::Entry.new.set_text($config['video-viewer'])),
3002 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
3003 tooltips = Gtk::Tooltips.new
3004 tooltips.set_tip(video_viewer_entry, utf8(_("Use %f to specify the filename;
3005 for example: /usr/bin/mplayer %f")), nil)
3006 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Browser's command: ")))),
3007 0, 1, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
3008 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(browser_entry = Gtk::Entry.new.set_text($config['browser'])),
3009 1, 2, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
3010 tooltips.set_tip(browser_entry, utf8(_("Use %f to specify the filename;
3011 for example: /usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f")), nil)
3012 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(smp_check = Gtk::CheckButton.new(utf8(_("Use symmetric multi-processing")))),
3013 0, 1, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
3014 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(smp_hbox = Gtk::HBox.new.add(smp_spin = Gtk::SpinButton.new(2, 16, 1)).add(Gtk::Label.new(utf8(_("processors")))).set_sensitive(false)),
3015 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
3016 tooltips.set_tip(smp_check, utf8(_("When activated, this option allows the thumbnails creation to run faster. However, if you don't have a multi-processor machine, this will only slow down processing!")), nil)
3017 tbl.attach(nogestures_check = Gtk::CheckButton.new(utf8(_("Disable mouse gestures"))),
3018 0, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
3019 tooltips.set_tip(nogestures_check, utf8(_("Mouse gestures are 'unusual' mouse movements triggering special actions, and are great for speeding up your editions. Get details on available mouse gestures from the Help menu.")), nil)
3020 tbl.attach(emptycomments_check = Gtk::CheckButton.new(utf8(_("Use empty comments for new albums"))),
3021 0, 2, 4, 5, Gtk::FILL, Gtk::SHRINK, 2, 2)
3022 tooltips.set_tip(emptycomments_check, utf8(_("Normally, filenames are used as comments for new albums. Check this if you prefer empty comments.")), nil)
3023 tbl.attach(deleteondisk_check = Gtk::CheckButton.new(utf8(_("Delete original images/videos as well"))),
3024 0, 2, 5, 6, Gtk::FILL, Gtk::SHRINK, 2, 2)
3025 tooltips.set_tip(deleteondisk_check, utf8(_("Normally, deleting an image or video in booh only removes it from the web-album. If you check this option, the original file in source directory will be removed as well. Undo is possible, since actual deletion is performed only when web-album is saved.")), nil)
3026 smp_check.signal_connect('toggled') {
3027 if smp_check.active?
3028 smp_hbox.sensitive = true
3030 smp_hbox.sensitive = false
3034 smp_check.active = true
3035 smp_spin.value = $config['mproc'].to_i
3037 nogestures_check.active = $config['nogestures']
3038 emptycomments_check.active = $config['emptycomments']
3039 deleteondisk_check.active = $config['deleteondisk']
3041 notebook.append_page(tbl = Gtk::Table.new(0, 0, false), Gtk::Label.new(utf8(_("Advanced"))))
3042 tbl.attach(Gtk::Label.new.set_markup(utf8(_("Options to pass to <i>convert</i> when\nperforming 'enhance contrast': "))),
3043 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
3044 tbl.attach(enhance_entry = Gtk::Entry.new.set_text($config['convert-enhance'] || $convert_enhance).set_size_request(250, -1),
3045 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
3047 dialog.vbox.show_all
3048 dialog.run { |response|
3049 if response == Gtk::Dialog::RESPONSE_OK
3050 $config['video-viewer'] = video_viewer_entry.text
3051 $config['browser'] = browser_entry.text
3052 if smp_check.active?
3053 $config['mproc'] = smp_spin.value.to_i
3055 $config.delete('mproc')
3057 $config['nogestures'] = nogestures_check.active?
3058 $config['emptycomments'] = emptycomments_check.active?
3059 $config['deleteondisk'] = deleteondisk_check.active?
3061 $config['convert-enhance'] = enhance_entry.text
3068 if $undo_tb.sensitive?
3069 $redo_tb.sensitive = $redo_mb.sensitive = true
3070 if not more_undoes = UndoHandler.undo($statusbar)
3071 $undo_tb.sensitive = $undo_mb.sensitive = false
3077 if $redo_tb.sensitive?
3078 $undo_tb.sensitive = $undo_mb.sensitive = true
3079 if not more_redoes = UndoHandler.redo($statusbar)
3080 $redo_tb.sensitive = $redo_mb.sensitive = false
3085 def show_one_click_explanation(intro)
3086 show_popup($main_window, utf8(_("<b>One-Click tools.</b>
3088 %s When such a tool is activated
3089 (<span foreground='darkblue'>Rotate clockwise</span>, <span foreground='darkblue'>Rotate counter-clockwise</span>, <span foreground='darkblue'>Enhance</span> or <span foreground='darkblue'>Delete</span>), clicking
3090 on a thumbnail will immediately apply the desired action.
3092 Click the <span foreground='darkblue'>None</span> icon when you're finished with One-Click tools.
3098 GNU GENERAL PUBLIC LICENSE
3099 Version 2, June 1991
3101 Copyright (C) 1989, 1991 Free Software Foundation, Inc.
3102 675 Mass Ave, Cambridge, MA 02139, USA
3103 Everyone is permitted to copy and distribute verbatim copies
3104 of this license document, but changing it is not allowed.
3108 The licenses for most software are designed to take away your
3109 freedom to share and change it. By contrast, the GNU General Public
3110 License is intended to guarantee your freedom to share and change free
3111 software--to make sure the software is free for all its users. This
3112 General Public License applies to most of the Free Software
3113 Foundation's software and to any other program whose authors commit to
3114 using it. (Some other Free Software Foundation software is covered by
3115 the GNU Library General Public License instead.) You can apply it to
3118 When we speak of free software, we are referring to freedom, not
3119 price. Our General Public Licenses are designed to make sure that you
3120 have the freedom to distribute copies of free software (and charge for
3121 this service if you wish), that you receive source code or can get it
3122 if you want it, that you can change the software or use pieces of it
3123 in new free programs; and that you know you can do these things.
3125 To protect your rights, we need to make restrictions that forbid
3126 anyone to deny you these rights or to ask you to surrender the rights.
3127 These restrictions translate to certain responsibilities for you if you
3128 distribute copies of the software, or if you modify it.
3130 For example, if you distribute copies of such a program, whether
3131 gratis or for a fee, you must give the recipients all the rights that
3132 you have. You must make sure that they, too, receive or can get the
3133 source code. And you must show them these terms so they know their
3136 We protect your rights with two steps: (1) copyright the software, and
3137 (2) offer you this license which gives you legal permission to copy,
3138 distribute and/or modify the software.
3140 Also, for each author's protection and ours, we want to make certain
3141 that everyone understands that there is no warranty for this free
3142 software. If the software is modified by someone else and passed on, we
3143 want its recipients to know that what they have is not the original, so
3144 that any problems introduced by others will not reflect on the original
3145 authors' reputations.
3147 Finally, any free program is threatened constantly by software
3148 patents. We wish to avoid the danger that redistributors of a free
3149 program will individually obtain patent licenses, in effect making the
3150 program proprietary. To prevent this, we have made it clear that any
3151 patent must be licensed for everyone's free use or not licensed at all.
3153 The precise terms and conditions for copying, distribution and
3154 modification follow.
3157 GNU GENERAL PUBLIC LICENSE
3158 TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
3160 0. This License applies to any program or other work which contains
3161 a notice placed by the copyright holder saying it may be distributed
3162 under the terms of this General Public License. The "Program", below,
3163 refers to any such program or work, and a "work based on the Program"
3164 means either the Program or any derivative work under copyright law:
3165 that is to say, a work containing the Program or a portion of it,
3166 either verbatim or with modifications and/or translated into another
3167 language. (Hereinafter, translation is included without limitation in
3168 the term "modification".) Each licensee is addressed as "you".
3170 Activities other than copying, distribution and modification are not
3171 covered by this License; they are outside its scope. The act of
3172 running the Program is not restricted, and the output from the Program
3173 is covered only if its contents constitute a work based on the
3174 Program (independent of having been made by running the Program).
3175 Whether that is true depends on what the Program does.
3177 1. You may copy and distribute verbatim copies of the Program's
3178 source code as you receive it, in any medium, provided that you
3179 conspicuously and appropriately publish on each copy an appropriate
3180 copyright notice and disclaimer of warranty; keep intact all the
3181 notices that refer to this License and to the absence of any warranty;
3182 and give any other recipients of the Program a copy of this License
3183 along with the Program.
3185 You may charge a fee for the physical act of transferring a copy, and
3186 you may at your option offer warranty protection in exchange for a fee.
3188 2. You may modify your copy or copies of the Program or any portion
3189 of it, thus forming a work based on the Program, and copy and
3190 distribute such modifications or work under the terms of Section 1
3191 above, provided that you also meet all of these conditions:
3193 a) You must cause the modified files to carry prominent notices
3194 stating that you changed the files and the date of any change.
3196 b) You must cause any work that you distribute or publish, that in
3197 whole or in part contains or is derived from the Program or any
3198 part thereof, to be licensed as a whole at no charge to all third
3199 parties under the terms of this License.
3201 c) If the modified program normally reads commands interactively
3202 when run, you must cause it, when started running for such
3203 interactive use in the most ordinary way, to print or display an
3204 announcement including an appropriate copyright notice and a
3205 notice that there is no warranty (or else, saying that you provide
3206 a warranty) and that users may redistribute the program under
3207 these conditions, and telling the user how to view a copy of this
3208 License. (Exception: if the Program itself is interactive but
3209 does not normally print such an announcement, your work based on
3210 the Program is not required to print an announcement.)
3213 These requirements apply to the modified work as a whole. If
3214 identifiable sections of that work are not derived from the Program,
3215 and can be reasonably considered independent and separate works in
3216 themselves, then this License, and its terms, do not apply to those
3217 sections when you distribute them as separate works. But when you
3218 distribute the same sections as part of a whole which is a work based
3219 on the Program, the distribution of the whole must be on the terms of
3220 this License, whose permissions for other licensees extend to the
3221 entire whole, and thus to each and every part regardless of who wrote it.
3223 Thus, it is not the intent of this section to claim rights or contest
3224 your rights to work written entirely by you; rather, the intent is to
3225 exercise the right to control the distribution of derivative or
3226 collective works based on the Program.
3228 In addition, mere aggregation of another work not based on the Program
3229 with the Program (or with a work based on the Program) on a volume of
3230 a storage or distribution medium does not bring the other work under
3231 the scope of this License.
3233 3. You may copy and distribute the Program (or a work based on it,
3234 under Section 2) in object code or executable form under the terms of
3235 Sections 1 and 2 above provided that you also do one of the following:
3237 a) Accompany it with the complete corresponding machine-readable
3238 source code, which must be distributed under the terms of Sections
3239 1 and 2 above on a medium customarily used for software interchange; or,
3241 b) Accompany it with a written offer, valid for at least three
3242 years, to give any third party, for a charge no more than your
3243 cost of physically performing source distribution, a complete
3244 machine-readable copy of the corresponding source code, to be
3245 distributed under the terms of Sections 1 and 2 above on a medium
3246 customarily used for software interchange; or,
3248 c) Accompany it with the information you received as to the offer
3249 to distribute corresponding source code. (This alternative is
3250 allowed only for noncommercial distribution and only if you
3251 received the program in object code or executable form with such
3252 an offer, in accord with Subsection b above.)
3254 The source code for a work means the preferred form of the work for
3255 making modifications to it. For an executable work, complete source
3256 code means all the source code for all modules it contains, plus any
3257 associated interface definition files, plus the scripts used to
3258 control compilation and installation of the executable. However, as a
3259 special exception, the source code distributed need not include
3260 anything that is normally distributed (in either source or binary
3261 form) with the major components (compiler, kernel, and so on) of the
3262 operating system on which the executable runs, unless that component
3263 itself accompanies the executable.
3265 If distribution of executable or object code is made by offering
3266 access to copy from a designated place, then offering equivalent
3267 access to copy the source code from the same place counts as
3268 distribution of the source code, even though third parties are not
3269 compelled to copy the source along with the object code.
3272 4. You may not copy, modify, sublicense, or distribute the Program
3273 except as expressly provided under this License. Any attempt
3274 otherwise to copy, modify, sublicense or distribute the Program is
3275 void, and will automatically terminate your rights under this License.
3276 However, parties who have received copies, or rights, from you under
3277 this License will not have their licenses terminated so long as such
3278 parties remain in full compliance.
3280 5. You are not required to accept this License, since you have not
3281 signed it. However, nothing else grants you permission to modify or
3282 distribute the Program or its derivative works. These actions are
3283 prohibited by law if you do not accept this License. Therefore, by
3284 modifying or distributing the Program (or any work based on the
3285 Program), you indicate your acceptance of this License to do so, and
3286 all its terms and conditions for copying, distributing or modifying
3287 the Program or works based on it.
3289 6. Each time you redistribute the Program (or any work based on the
3290 Program), the recipient automatically receives a license from the
3291 original licensor to copy, distribute or modify the Program subject to
3292 these terms and conditions. You may not impose any further
3293 restrictions on the recipients' exercise of the rights granted herein.
3294 You are not responsible for enforcing compliance by third parties to
3297 7. If, as a consequence of a court judgment or allegation of patent
3298 infringement or for any other reason (not limited to patent issues),
3299 conditions are imposed on you (whether by court order, agreement or
3300 otherwise) that contradict the conditions of this License, they do not
3301 excuse you from the conditions of this License. If you cannot
3302 distribute so as to satisfy simultaneously your obligations under this
3303 License and any other pertinent obligations, then as a consequence you
3304 may not distribute the Program at all. For example, if a patent
3305 license would not permit royalty-free redistribution of the Program by
3306 all those who receive copies directly or indirectly through you, then
3307 the only way you could satisfy both it and this License would be to
3308 refrain entirely from distribution of the Program.
3310 If any portion of this section is held invalid or unenforceable under
3311 any particular circumstance, the balance of the section is intended to
3312 apply and the section as a whole is intended to apply in other
3315 It is not the purpose of this section to induce you to infringe any
3316 patents or other property right claims or to contest validity of any
3317 such claims; this section has the sole purpose of protecting the
3318 integrity of the free software distribution system, which is
3319 implemented by public license practices. Many people have made
3320 generous contributions to the wide range of software distributed
3321 through that system in reliance on consistent application of that
3322 system; it is up to the author/donor to decide if he or she is willing
3323 to distribute software through any other system and a licensee cannot
3326 This section is intended to make thoroughly clear what is believed to
3327 be a consequence of the rest of this License.
3330 8. If the distribution and/or use of the Program is restricted in
3331 certain countries either by patents or by copyrighted interfaces, the
3332 original copyright holder who places the Program under this License
3333 may add an explicit geographical distribution limitation excluding
3334 those countries, so that distribution is permitted only in or among
3335 countries not thus excluded. In such case, this License incorporates
3336 the limitation as if written in the body of this License.
3338 9. The Free Software Foundation may publish revised and/or new versions
3339 of the General Public License from time to time. Such new versions will
3340 be similar in spirit to the present version, but may differ in detail to
3341 address new problems or concerns.
3343 Each version is given a distinguishing version number. If the Program
3344 specifies a version number of this License which applies to it and "any
3345 later version", you have the option of following the terms and conditions
3346 either of that version or of any later version published by the Free
3347 Software Foundation. If the Program does not specify a version number of
3348 this License, you may choose any version ever published by the Free Software
3351 10. If you wish to incorporate parts of the Program into other free
3352 programs whose distribution conditions are different, write to the author
3353 to ask for permission. For software which is copyrighted by the Free
3354 Software Foundation, write to the Free Software Foundation; we sometimes
3355 make exceptions for this. Our decision will be guided by the two goals
3356 of preserving the free status of all derivatives of our free software and
3357 of promoting the sharing and reuse of software generally.
3361 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
3362 FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
3363 OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
3364 PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
3365 OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
3366 MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
3367 TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
3368 PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
3369 REPAIR OR CORRECTION.
3371 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
3372 WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
3373 REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
3374 INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
3375 OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
3376 TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
3377 YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
3378 PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
3379 POSSIBILITY OF SUCH DAMAGES.
3383 def create_menu_and_toolbar
3386 mb = Gtk::MenuBar.new
3388 filemenu = Gtk::MenuItem.new(utf8(_("_File")))
3389 filesubmenu = Gtk::Menu.new
3390 filesubmenu.append(new = Gtk::ImageMenuItem.new(Gtk::Stock::NEW))
3391 filesubmenu.append(open = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
3392 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3393 filesubmenu.append($save = Gtk::ImageMenuItem.new(Gtk::Stock::SAVE).set_sensitive(false))
3394 filesubmenu.append($save_as = Gtk::ImageMenuItem.new(Gtk::Stock::SAVE_AS).set_sensitive(false))
3395 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3396 tooltips = Gtk::Tooltips.new
3397 filesubmenu.append($merge_current = Gtk::ImageMenuItem.new(utf8(_("Merge new/removed images/videos in current subalbum"))).set_sensitive(false))
3398 $merge_current.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3399 tooltips.set_tip($merge_current, utf8(_("Take into account new/removed images/videos in currently viewed subalbum")), nil)
3400 filesubmenu.append($merge_newsubs = Gtk::ImageMenuItem.new(utf8(_("Merge new subalbums (subdirectories) in current subalbum"))).set_sensitive(false))
3401 $merge_newsubs.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3402 tooltips.set_tip($merge_newsubs, utf8(_("Take into account new subalbums in currently viewed subalbum (and only here)")), nil)
3403 filesubmenu.append($merge = Gtk::ImageMenuItem.new(utf8(_("Scan source directory to merge new subalbums and new/removed images/videos"))).set_sensitive(false))
3404 $merge.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3405 tooltips.set_tip($merge, utf8(_("Take into account new/removed subalbums (subdirectories) and new/removed images/videos in existing subalbums (anywhere)")), nil)
3406 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3407 filesubmenu.append($generate = Gtk::ImageMenuItem.new(utf8(_("Generate web-album"))).set_sensitive(false))
3408 $generate.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
3409 tooltips.set_tip($generate, utf8(_("(Re)generate web-album from latest changes into the destination directory")), nil)
3410 filesubmenu.append($view_wa = Gtk::ImageMenuItem.new(utf8(_("View web-album with browser"))).set_sensitive(false))
3411 $view_wa.image = Gtk::Image.new("#{$FPATH}/images/stock-view-webalbum-16.png")
3412 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3413 filesubmenu.append($properties = Gtk::ImageMenuItem.new(Gtk::Stock::PROPERTIES).set_sensitive(false))
3414 tooltips.set_tip($properties, utf8(_("View and modify properties of the web-album")), nil)
3415 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3416 filesubmenu.append(quit = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
3417 filemenu.set_submenu(filesubmenu)
3420 new.signal_connect('activate') { new_album }
3421 open.signal_connect('activate') { open_file_popup }
3422 $save.signal_connect('activate') { save_current_file_user }
3423 $save_as.signal_connect('activate') { save_as_do }
3424 $merge_current.signal_connect('activate') { merge_current }
3425 $merge_newsubs.signal_connect('activate') { merge_newsubs }
3426 $merge.signal_connect('activate') { merge }
3427 $generate.signal_connect('activate') {
3429 call_backend("booh-backend --config '#{$filename}' --verbose-level #{$verbose_level} #{additional_booh_options}",
3430 utf8(_("Please wait while generating web-album...\nThis may take a while, please be patient.")),
3432 { :successmsg => utf8(_("Your web-album is now ready in directory '%s'.
3433 Click to view it in your browser:") % $xmldoc.root.attributes['destination']),
3434 :successmsg_linkurl => $xmldoc.root.attributes['destination'],
3435 :closure_after => proc {
3436 $xmldoc.elements.each('//dir') { |elem|
3437 $modified ||= elem.attributes['already-generated'].nil?
3438 elem.add_attribute('already-generated', 'true')
3440 UndoHandler.cleanup #- prevent save_changes to mark current dir as not already generated
3441 $undo_tb.sensitive = $undo_mb.sensitive = false
3442 $redo_tb.sensitive = $redo_mb.sensitive = false
3444 $generated_outofline = true
3447 $view_wa.signal_connect('activate') {
3448 indexhtml = $xmldoc.root.attributes['destination'] + '/index.html'
3449 if File.exists?(indexhtml)
3452 show_popup($main_window, utf8(_("Seems like you should generate the web-album first.")))
3455 $properties.signal_connect('activate') { properties }
3457 quit.signal_connect('activate') { try_quit }
3459 editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
3460 editsubmenu = Gtk::Menu.new
3461 editsubmenu.append($undo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
3462 editsubmenu.append($redo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
3463 editsubmenu.append( Gtk::SeparatorMenuItem.new)
3464 editsubmenu.append($remove_all_captions = Gtk::ImageMenuItem.new(utf8(_("Remove all captions in this sub-album"))).set_sensitive(false))
3465 $remove_all_captions.image = Gtk::Image.new("#{$FPATH}/images/stock-tool-eraser-16.png")
3466 tooltips.set_tip($remove_all_captions, utf8(_("Mainly useful when you don't want to type any caption, that will remove default captions made of filenames")), nil)
3467 editsubmenu.append( Gtk::SeparatorMenuItem.new)
3468 editsubmenu.append(prefs = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
3469 editmenu.set_submenu(editsubmenu)
3472 $remove_all_captions.signal_connect('activate') { remove_all_captions }
3474 prefs.signal_connect('activate') { preferences }
3476 helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
3477 helpsubmenu = Gtk::Menu.new
3478 helpsubmenu.append(one_click = Gtk::ImageMenuItem.new(utf8(_("One-click tools"))))
3479 one_click.image = Gtk::Image.new("#{$FPATH}/images/stock-tools-16.png")
3480 helpsubmenu.append(speed = Gtk::ImageMenuItem.new(utf8(_("Speedup: key shortcuts and mouse gestures"))))
3481 speed.image = Gtk::Image.new("#{$FPATH}/images/stock-info-16.png")
3482 helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
3483 tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
3484 helpsubmenu.append( Gtk::SeparatorMenuItem.new)
3485 helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
3486 helpmenu.set_submenu(helpsubmenu)
3489 one_click.signal_connect('activate') {
3490 show_one_click_explanation(_("One-Click tools are available in the toolbar."))
3493 speed.signal_connect('activate') {
3494 show_popup($main_window, utf8(_("<span size='large' weight='bold'>Key shortcuts:</span>
3496 <span foreground='darkblue'>Tab</span>: go to next image caption and select text (begin typing to erase current text!)
3497 <span foreground='darkblue'>Shift-Tab</span>: go to previous image caption
3498 <span foreground='darkblue'>Control-Left/Right/Up/Down</span>: go to specified direction's image caption
3499 <span foreground='darkblue'>Control-Enter</span>: for an image, open larger view; for a video, launch player
3500 <span foreground='darkblue'>Control-Delete</span>: delete image
3501 <span foreground='darkblue'>Shift-Left/Right/Up/Down</span>: move image left/right/up/down
3502 <span foreground='darkblue'>Alt-Left/Right</span>: rotate image clockwise/counter-clockwise
3503 <span foreground='darkblue'>Control-z</span>: undo
3504 <span foreground='darkblue'>Control-r</span>: redo
3506 <span size='large' weight='bold'>Mouse gestures:</span>
3508 Mouse gestures are 'unusual' mouse movements triggering special actions, and are great
3509 for speeding up your editions. If bothered, you can disable them from Edit/Preferences.
3511 <span foreground='darkblue'>Left click, drag to the right, release</span>: rotate image clockwise
3512 <span foreground='darkblue'>Left click, drag to the left, release</span>: rotate image counter-clockwise
3513 <span foreground='darkblue'>Left click, drag to the bottom, release</span>: remove image
3514 <span foreground='darkblue'>Left click, hold left button, right click</span>: undo
3515 <span foreground='darkblue'>Right click, hold right button, left click</span>: redo
3516 ")), { :pos_centered => true, :not_transient => true })
3519 tutos.signal_connect('activate') {
3520 open_url('http://zarb.org/~gc/html/booh/tuto