require 'gtk2' require 'open-uri' require 'ftools' module Grumblr APP_NAME = 'Grumblr' APP_MOTTO = 'a Tumblr companion' APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..', '..')) DATA_ROOT = File.join(APP_ROOT, 'data') VERSION = File.open(File.join(APP_ROOT,'VERSION')) { |f| f.read } class UI < Gtk::Window attr_accessor :logo def initialize super Gtk::Window::TOPLEVEL filename = File.join(Grumblr::DATA_ROOT, 'pixmaps', 'grumblr.svg') self.logo = Gdk::Pixbuf.new(filename, 128, 128) set_size_request 400, 300 set_border_width 0 set_allow_shrink false set_title "#{Grumblr::APP_NAME} #{Grumblr::VERSION}" set_icon self.logo set_default_width $cfg.get(:window_width).to_i set_default_height $cfg.get(:window_height).to_i move $cfg.get(:window_x_pos).to_i, $cfg.get(:window_y_pos).to_i signal_connect(:destroy) { quit } signal_connect(:delete_event) { minimize } signal_connect(:check_resize) do |widget| position_x, position_y = widget.position size_w, size_h = widget.size $cfg.set :window_x_pos, position_x $cfg.set :window_y_pos, position_y $cfg.set :window_width, size_w $cfg.set :window_height, size_h end signal_connect(:window_state_event) do |widget, e| case e.event_type when Gdk::Event::WINDOW_STATE minimize if e.changed_mask.iconified? and e.new_window_state.iconified? else nil end end show end def minimize self.hide end end class Dashboard < Gtk::VBox def initialize super false, 0 ### Statusbar $statusbar = Gtk::Statusbar.new $statusbar.set_border_width 2 ### Notebook @notebook = Gtk::Notebook.new @notebook.set_border_width 0 @notebook.set_homogeneous true @notebook.set_tab_pos Gtk::POS_LEFT # Text page @text_title = Gtk::LabeledEntry.new('Title (optional)') @text_body = Gtk::LabeledTextView.new('Body') page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start @text_title, false page.pack_start scrollable(@text_body), true @notebook.add_page_with_tab page, 'Text' # Link page @link_url = Gtk::LabeledEntry.new('URL') @link_name = Gtk::LabeledEntry.new('Name (optional)') @link_description = Gtk::LabeledTextView.new('Description (optional)') scroll = Gtk::ScrolledWindow.new scroll.set_shadow_type Gtk::SHADOW_IN scroll.set_policy Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC scroll.add @link_description page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start @link_url, false page.pack_start @link_name, false page.pack_start scroll, true @notebook.add_page_with_tab page, 'Link' # Chat page @chat_title = Gtk::LabeledEntry.new('Title (optional)') @chat_conversation = Gtk::LabeledTextView.new('Conversation') page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start @chat_title, false page.pack_start scrollable(@chat_conversation), true @notebook.add_page_with_tab page, 'Chat' # Quote page @quote_source = Gtk::LabeledEntry.new('Source (optional)') @quote_quote = Gtk::LabeledTextView.new('Quote') page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start scrollable(@quote_quote), true page.pack_start @quote_source, false @notebook.add_page_with_tab page, 'Quote' # Photo page filter = Gtk::FileFilter.new filter.set_name "Images" filter.add_mime_type "image/*" photo_data = file_chooser_button(:photo_data, filter) @photo_source = Gtk::LabeledEntry.new('Source') @photo_click_through_url = Gtk::LabeledEntry.new('Link (optional)') @photo_caption = Gtk::LabeledTextView.new('Caption') page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start photo_data, false page.pack_start @photo_source, false page.pack_start scrollable(@photo_caption), true page.pack_start @photo_click_through_url, false @notebook.add_page_with_tab page, 'Photo' # Audio page if $api.user.can_upload_audio == '1' filter = Gtk::FileFilter.new filter.set_name "Audio" filter.add_mime_type "audio/*" audio_data = file_chooser_button(:audio_data, filter) @audio_externally_hosted_url = Gtk::LabeledEntry.new('Externally Hosted MP3 URL') @audio_caption = Gtk::LabeledTextView.new('Caption (optional)') page = Gtk::VBox.new(false, 4) page.set_border_width 4 page.pack_start audio_data, false page.pack_start @audio_externally_hosted_url, false page.pack_start scrollable(@audio_caption), true @notebook.add_page_with_tab page, 'Audio' end # Video page @video_embed = Gtk::LabeledEntry.new('Embed code / YouTube link') @video_caption = Gtk::LabeledTextView.new('Caption (optional)') page = Gtk::VBox.new(false, 4) page.set_border_width 4 if $api.user.can_upload_video == '1' filter = Gtk::FileFilter.new filter.set_name "Video" filter.add_mime_type "video/*" video_data = file_chooser_button(:video_data, filter) @video_title = Gtk::LabeledEntry.new('Title (optional)') page.pack_start video_data, false page.pack_start @video_title, false end page.pack_start @video_embed, false page.pack_start scrollable(@video_caption), true @notebook.add_page_with_tab page, 'Video' if DEBUG # Blog info page page = Gtk::VBox.new(false, 4) page.set_border_width 4 @blog_info = Gtk::LabeledTextView.new('') page.pack_start scrollable(@blog_info), true @notebook.add_page_with_tab page, 'Blog' end ### Buttons @private_button = Gtk::ToggleButton.new('Private') @private_button.signal_connect(:toggled) do |widget| $cfg.set :private, widget.active? end @private_button.set_active $cfg.get(:private) @twitter_button = Gtk::ToggleButton.new('Twitter') @twitter_button.signal_connect(:toggled) do |widget| $cfg.set :twitter, widget.active? end #@twitter_button.set_active $cfg.get(:twitter) @tags = Gtk::LabeledEntry.new('space/comma separated tags') @format_button = Gtk::ToggleButton.new('Markdown') @format_button.signal_connect(:toggled) do |widget| $cfg.set :format_markdown, widget.active? end @format_button.set_active $cfg.get(:format_markdown) @submit_button = Gtk::Button.new('Send') @submit_button.signal_connect(:released) do |widget| post end button_box = Gtk::HBox.new(false, 4) button_box.set_border_width 4 # button_box.pack_start @clear_button, false button_box.pack_start @private_button, false button_box.pack_start @twitter_button, false button_box.pack_start @tags, true button_box.pack_start @format_button, false button_box.pack_start @submit_button, false ### Toolbar toolbar = Gtk::Toolbar.new toolbar.icon_size = Gtk::IconSize::MENU icon = Gtk::Image.new Gtk::Stock::HOME, Gtk::IconSize::MENU item = Gtk::ToolButton.new icon, 'Tumblelog' item.signal_connect(:clicked) do url = $app.blog.url || "http://www.tumblr.com/dashboard" Thread.new { system('xdg-open "%s"' % url) } end toolbar.insert 0, item icon = Gtk::Image.new Gtk::Stock::PREFERENCES, Gtk::IconSize::MENU item = Gtk::ToolButton.new icon, 'Dashboard' item.signal_connect(:clicked) do url = $app.blog.name ? "http://www.tumblr.com/tumblelog/#{$app.blog.name}" : "http://www.tumblr.com/dashboard" Thread.new { system('xdg-open "%s"' % url) } end toolbar.insert 1, item ### Blog selection combo model = Gtk::ListStore.new(Gdk::Pixbuf, String) combo = Gtk::ComboBox.new(model) renderer = Gtk::CellRendererPixbuf.new renderer.width = 24 combo.pack_start(renderer, false) combo.set_attributes(renderer, :pixbuf => 0) renderer = Gtk::CellRendererText.new combo.pack_start(renderer, true) combo.set_attributes(renderer, :text => 1) active_blog = $cfg.get(:active_blog) || nil active_blog_idx = nil $api.blogs.each_with_index do |blog, idx| iter = model.append blog.avatar_url ||= Ppds::Tumblr::DEFAULT_AVATAR iter[0] = pixbuffer_from_url(blog.avatar_url) iter[1] = blog.title active_blog_idx = idx if blog.name.eql?(active_blog) active_blog_idx = idx if active_blog_idx.nil? and blog.is_primary == "yes" end combo.signal_connect(:changed) do |widget| $app.blog = $api.blogs[widget.active] $cfg.set :active_blog, $app.blog.name $statusbar.push 0, $app.blog.title @blog_info.text = $app.blog.pretty_inspect if DEBUG @twitter_button.set_active $app.blog.twitter_enabled == "1" ? true : false end combo.set_active(active_blog_idx) item = Gtk::ToolItem.new item.set_expand true item.add combo toolbar.insert 2, item icon = Gtk::Image.new(Gtk::Stock::CLEAR, Gtk::IconSize::MENU) @clear_button = Gtk::ToolButton.new(icon, 'Clear') @clear_button.signal_connect(:clicked) do |widget| page = @notebook.get_nth_page(@notebook.page) message_type = @notebook.get_menu_label_text(page) reset_form message_type.downcase unless message_type == 'Blog' end toolbar.insert 3, @clear_button icon = Gtk::Image.new(Gtk::Stock::QUIT, Gtk::IconSize::MENU) item = Gtk::ToolButton.new(icon, 'Quit') item.set_homogeneous false item.signal_connect(:clicked) do $app.quit end toolbar.insert 4, item ### Layout pack_start toolbar, false pack_start @notebook pack_start button_box, false pack_start $statusbar, false show_all end def post page = @notebook.get_nth_page @notebook.page message_type = @notebook.get_menu_label_text(page).downcase mandatory_data = collect_data_for(Ppds::Tumblr::MANDATORY_FIELDS, message_type) concurent_data = collect_data_for(Ppds::Tumblr::CONCURENT_FIELDS, message_type) optional_data = collect_data_for(Ppds::Tumblr::OPTIONAL_FIELDS, message_type) mandatory_data.each do |key, value| raise "Mandatory field %s is not set!" % key if not value or value.empty? end unless Ppds::Tumblr::MANDATORY_FIELDS[message_type].empty? unless Ppds::Tumblr::CONCURENT_FIELDS[message_type].empty? concurent_data.delete_if { |x,y| y == '' or y.nil? } raise "None of fields %s is set!" % Ppds::Tumblr::CONCURENT_FIELDS[message_type].join(", ") if concurent_data.empty? end optional_data.delete_if { |x,y| y == '' or y.nil? } tags = @tags.get_value.gsub(/\s+/,',').split(',').uniq.sort - [''] data = { :email => $cfg.get(:email), :password => $cfg.get(:password), :type => message_type, :generator => "#{Grumblr::APP_NAME} #{Grumblr::VERSION}", #:date => '2010-12-01 14:50:02', :private => @private_button.active? ? 1 : 0, :tags => tags.join(','), :format => @format_button.active? ? 'markdown' : 'html', #:slug => '', #:state => @state, # published, draft, submission, queue :channel_id => $app.blog.name, :send_to_twitter => @twitter_button.active? ? 'auto' : 'no' } data.merge!({ :group => ($app.blog.name << '.tumblr.com') }) if $app.blog.type == 'public' and $app.blog.name data.merge!({ :group => ($app.blog.private_id) }) if $app.blog.type = 'private' and $app.blog.private_id # datetime format: 2010-01-01T13:34:00 # data.merge!({:publish_on => @publish_on}) if @state == 'queue' data.merge! mandatory_data data.merge! concurent_data data.merge! optional_data data.update({:data => File.read(data[:data])}) if data.has_key?(:data) and data[:data] != '' dump(data) if DEBUG $api.query 'write', data MessageDialog.new("Message posted", Gtk::Stock::DIALOG_INFO) reset_form message_type rescue Exception MessageDialog.new $! end def collect_data_for(fieldset, message_type) data = {} fieldset[message_type].each do |key| var = instance_variable_get("@#{message_type}_#{key.gsub(/-/,'_')}") data.merge!({ key.to_sym => var.get_value }) if var end data end def file_chooser_button(name, filter = nil) button = Gtk::FileChooserButton.new('Open', Gtk::FileChooser::ACTION_OPEN) if filter button.add_filter(filter) button.set_filter(filter) end button.signal_connect(:selection_changed) do |widget| puts widget.filename end button.show_all instance_variable_set "@#{name}", button end def reset_fields_for(fieldset, message_type) fieldset[message_type].each do |key| var = instance_variable_get("@#{message_type}_#{key.gsub(/-/,'_')}") var.clear if var end end def reset_form(message_type) [ Ppds::Tumblr::MANDATORY_FIELDS, Ppds::Tumblr::CONCURENT_FIELDS, Ppds::Tumblr::OPTIONAL_FIELDS ].each do |fieldset| reset_fields_for(fieldset, message_type) end @tags.clear end def scrollable(widget) scroll = Gtk::ScrolledWindow.new scroll.set_shadow_type Gtk::SHADOW_IN scroll.set_policy Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC scroll.add widget scroll.show_all end def pixbuffer_from_url(url) cache_dir = File.expand_path(File.join('~', '.cache', Grumblr::APP_NAME.downcase)) FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir) file = File.join(cache_dir, File.basename(url)) File.open(file, 'w') { |f| f.write(open(url).read) } unless File.exists?(file) pb = Gdk::PixbufLoader.new pb.set_size 16, 16 pb.last_write(open(file) { |f| f.read }) pb.pixbuf end end class MessageDialog < Gtk::Dialog def initialize(text, stock = Gtk::Stock::DIALOG_ERROR) super "Attention!", $gui, Gtk::Dialog::MODAL message = Gtk::Label.new(text) icon = Gtk::Image.new(stock, Gtk::IconSize::DIALOG) hbox = Gtk::HBox.new(false, 20) hbox.set_border_width 20 hbox.pack_start icon, false hbox.pack_start message, true self.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_NONE) self.signal_connect(:response) { self.destroy } self.vbox.add hbox self.show_all self.run end end class SettingsFrame < Gtk::HBox def initialize super false, 40 self.set_border_width 40 @label = Gtk::Label.new @label.set_markup 'Fill-in Tumblr credentials' @text_e = Gtk::Entry.new @text_e.set_text $cfg.get(:email).to_s @text_p = Gtk::Entry.new @text_p.set_visibility false @text_p.set_text $cfg.get(:password).to_s hbox = Gtk::HBox.new button = Gtk::Button.new('Cancel') button.signal_connect(:released) { $app.quit } hbox.pack_start button button = Gtk::Button.new('Sign in') button.signal_connect(:released) { login } hbox.pack_start button header = Gtk::Label.new header.set_alignment 0.0, 0.8 header.set_markup 'Grumblr 2' vbox = Gtk::VBox.new(false, 4) vbox.pack_start header vbox.pack_with_label '_Email', @text_e vbox.pack_with_label '_Password', @text_p vbox.pack_start @label vbox.pack_start hbox, false logo = Gtk::Image.new($gui.logo) self.pack_start logo self.pack_start vbox self.show_all end def login email = @text_e.text.strip password = @text_p.text.strip if $api.authenticate(email, password) $cfg.set :email, email $cfg.set :password, password self.destroy @dashboard = Dashboard.new $gui.add @dashboard else raise "Authentication failed" end rescue Exception MessageDialog.new $! end end class AboutDialog < Gtk::AboutDialog def initialize Gtk::AboutDialog.set_email_hook do |dialog, email| system("xdg-email #{email}") end Gtk::AboutDialog.set_url_hook do |dialog, url| system("xdg-open #{url}") end super self.logo = $gui.logo self.program_name = Grumblr::APP_NAME self.version = Grumblr::VERSION self.comments = Grumblr::APP_MOTTO self.copyright = "Copyright (c)2009, Paul Philippov" self.license = "New BSD License.\nhttp://creativecommons.org/licenses/BSD/" self.website = "http://themactep.com/grumblr/" self.authors = ['Paul Philippov '] self.run self.destroy end end class StatusIcon < Gtk::StatusIcon def initialize super self.icon_name = 'grumblr' self.tooltip = "#{Grumblr::APP_NAME} #{Grumblr::VERSION}" self.signal_connect(:activate) do if $gui.visible? $gui.minimize else $gui.move $cfg.get(:window_x_pos), $cfg.get(:window_y_pos) $gui.show.present end end self.signal_connect(:popup_menu) do |icon, button, time| menu.popup nil, nil, button, time end end def menu menu = Gtk::Menu.new [ ontop, sep, destroy_account, sep, about, sep, quit ].each do |item| menu.append item end menu.show_all end def sep Gtk::SeparatorMenuItem.new end ## Destroy Config def destroy_account icon = Gtk::ImageMenuItem.new('Forget password') icon.set_image Gtk::Image.new(Gtk::Stock::STOP, Gtk::IconSize::MENU) icon.signal_connect(:activate) do $cfg.destroy end icon.show end def about icon = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT) icon.signal_connect(:activate) do AboutDialog.new end icon.show end def ontop icon = Gtk::CheckMenuItem.new('Always on top') icon.signal_connect(:toggled) do |widget| $gui.keep_above = widget.active? end icon.show end def quit icon = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT) icon.signal_connect(:activate) do $app.quit end icon.show end end end module Gtk DARK = Gdk::Color.parse("#000000") PALE = Gdk::Color.parse("#999999") class Box def pack_with_label(text, widget, expand = false) label = Gtk::Label.new(text, true) label.set_alignment 0.0, 0.5 label.set_mnemonic_widget widget self.pack_start label, false self.pack_start widget, expand end end class CheckButton def get_value self.active? end end class ComboBox alias :get_value :active_text end class Entry alias :get_value :text def clear self.set_text '' end end module FileChooser alias :get_value :filename alias :clear :unselect_all end class Notebook def add_page_with_tab(page, text) filename = File.join(Grumblr::DATA_ROOT, 'pixmaps', '%s.bmp' % text.downcase) icon = Gtk::Image.new(filename) icon.set_padding 4, 0 label = Gtk::Label.new('_' + text, true) label.set_alignment 0.0, 0.5 label.set_padding 2, 2 box = Gtk::HBox.new(false, 2) box.pack_start icon, false box.pack_start label, true box.show_all self.append_page_menu page, box, label end end class TextView def get_value self.buffer.get_text end def text=(text) self.buffer.set_text(text) end end class LabeledEntry < Entry def initialize(label) @label = label super() self.modify_text Gtk::STATE_NORMAL, PALE self.set_text @label self.signal_connect(:focus_in_event) do |widget, type| if widget.text == @label widget.modify_text Gtk::STATE_NORMAL, DARK widget.set_text '' end false end self.signal_connect(:focus_out_event) do |widget, type| widget.clear if widget.text == '' false end self.show end def get_value value = self.text value == @label ? "" : value end def clear self.modify_text Gtk::STATE_NORMAL, PALE self.set_text @label end end class LabeledTextView < TextView def initialize(label) @label = label super() self.set_wrap_mode Gtk::TextTag::WRAP_WORD self.set_accepts_tab false self.set_right_margin 5 self.set_left_margin 5 self.modify_text Gtk::STATE_NORMAL, PALE self.buffer.set_text @label self.signal_connect(:focus_in_event) do |widget, type| if widget.buffer.text == @label widget.modify_text Gtk::STATE_NORMAL, DARK widget.buffer.set_text '' end report_length false end self.signal_connect(:focus_out_event) do |widget, type| self.clear if widget.buffer.text == '' $statusbar.push 0, $app.blog.title false end self.signal_connect(:key_release_event) do |widget, type| report_length false end end def report_length $statusbar.push 0, "Length: #{self.buffer.text.scan(/./mu).count}" self.buffer.text.strip end def get_value value = self.buffer.get_text value == @label ? "" : value end def clear self.modify_text Gtk::STATE_NORMAL, PALE self.buffer.set_text @label end end end