=begin
Copyright 2013-2014, Andreas Eisenbarth
All Rights Reserved

Permission to use, copy, modify, and distribute this software for
any purpose and without fee is hereby granted, provided that the above
copyright notice appear in all copies.

THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.

Name:         ToolbarEditor.rb

Author:       Andreas Eisenbarth

Usage:        Menu 'Window' → 'ToolbarEditor'
              Opens a dialog where you can drag and drop toolbar buttons from the
              list on the right into toolbar panels on the left. Click "Apply" to
              save the changes. You need to restart SketchUp for all changes to
              take effect.

Description:  This plugin lets you create your own toolbars.

Required:     SketchUp 8 M1 or higher

Version:      1.1.2

Date:         21.05.2014


Information:  The principle of custom toolbars is to save&read the defined toolbars
              from a file, then loop over each toolbar configuration and create
              a new UI::Toolbar and add buttons for its commands.
              However there is no standard way in SketchUp to detect features/
              commands of other plugins and execute them (UI::Command.proc.call).
              One way is to write code for each plugin's method name manually in
              a configuration file or script (so the user would need to look 
              into each plugin's source code).
              Another way is to get all UI::Command instances from Ruby's ObjectSpace.
              Unfortunately UI::Command don't have persistent identifiers to
              remember them over SketchUp sessions. The Plugin LaunchUp therefore
              creates an index and generates a unique and persistent hash from
              each command's metadata. This plugin uses LaunchUp's index (if installed)
              or a lightweight substitute.
              Because of that it is important that the interception (in LaunchUp)
              happens before other scripts load, and that custom toolbars are
              created after all other scripts have loaded.
=end
require 'sketchup.rb'


module AE


module ToolbarEditor

VERSION = "1.1.2" unless defined?(self::VERSION)

# This plugin's folder.
DIR = File.dirname(__FILE__) unless defined?(self::DIR)

# Translation library.
require(File.join(DIR, 'Translate.rb'))

# Load translation strings and add plugin to UI.
TRANSLATE = Translate.new("ToolbarEditor", File.join(DIR, "resources")) unless defined?(self::TRANSLATE)

# WebDialog Helper.
require(File.join(DIR, 'Dialog.rb'))

# Index of all commands.
if defined?(AE::LaunchUp) && defined?(AE::LaunchUp::Index) &&
    AE::LaunchUp::Index.method_defined?(:get_all)
  Index = AE::LaunchUp::Index unless defined?(self::Index)
else
  require(File.join(DIR, 'Index.rb'))
end

# Options.
require(File.join(DIR, 'Options.rb'))
@options ||= Options.new(ToolbarEditor, {
  :toolbars => {},       # id => { "name" => [String], "buttons" => [Array<Fixnum>] }
  :custom_buttons => {}, # id => { "name" => [String], … }
}).migrate("0.0.0", "1.1.1"){ |key, value|
  case key
  when :toolbars then
    new_value = {}
    value.each{ |hash|
      id = (hash["id"].is_a?(Fixnum)) ? hash["id"] : (!new_value.keys.empty?) ? new_value.keys.max.next : 0
      hash["id"] = id
      new_value[id] = hash
    }
    [key, new_value]
  when :snippets then
    new_value = {}
    value.each{ |hash|
      id = (hash["id"].is_a?(Fixnum)) ? hash["id"] : (!new_value.keys.empty?) ? new_value.keys.max.next : 0
      hash["id"] = id
      new_value[id] = hash
    }
    [:custom_buttons, {}]
  else
    [key, value]
  end
}

# Reference to the main dialog.
@editor_dlg ||= nil

# Data directory for saving files.
dir = [
       File.join(DIR, "data"),                         # Plugins folder
       ENV["APPDATA"],                                 # Windows
       File.join(ENV["HOME"].to_s, ".local", "share"), # Free desktop standard
       File.join(ENV["HOME"].to_s, "Library", "Application Support"), # OS X
       File.expand_path(".")                           # Fallback: user's folder.
      ].compact.find{ |path| File.exists?(path) && File.writable?(path) }
DATA_DIR = File.join(File.expand_path(dir), "SketchUp ToolbarEditor") unless defined?(self::DATA_DIR)


public


# This displays the main dialog with search field.
def self.show_dialog
  # If the dialog exists, bring it to the front.
  if @editor_dlg && @editor_dlg.visible?
    @editor_dlg.bring_to_front
  else
    @editor_dlg = AE::ToolbarEditor::Dialog.new({
      :dialog_title => TRANSLATE["Toolbar Editor"],
      :scrollable => false,
      :preferences_key => "AE_ToolbarEditor",
      :height => 550,
      :width => 400,
      :left => 600,
      :top => 200,
      :resizable => true
    })
    @editor_dlg.min_width = 420
    @editor_dlg.min_height = 350
    html_path = File.join(DIR, "html", "ToolbarEditor.html")
    @editor_dlg.set_file(html_path)

    @editor_dlg.on_show { |dlg|
      TRANSLATE.webdialog(dlg)
      # Wait a short moment so that the dialog can render before the dialog is busy loading the buttons.
      UI.start_timer(0.1, false){
        buttons = Index.instance.get_all{ |hash|
          hash[:icon].is_a?(String) && !hash[:icon].empty? &&
          # If the LaunchUp Index is used, it will also include duplicates of the custom buttons.
          !CustomButtons.get_all.any?{ |e| e[:command] == hash[:command] }
        }.sort_by{ |entry|
          entry[:name] || ""
        }.sort_by{ |entry|
          entry[:category] || ""
        }
        dlg.call_function("AE.ToolbarEditor.loadCustomButtons", CustomButtons.get_all) # Array
        dlg.call_function("AE.ToolbarEditor.loadButtons", buttons) # Array
        dlg.call_function("AE.ToolbarEditor.loadToolbars", Toolbars.get_all) # Array
      }
    }

    # Search the index.
    #@editor_dlg.on("look_up") { |dlg, search_string|
    #  results = Index.instance.look_up(search_string, 1000).map{ |hash| hash[:id] }
    #  dlg.return results
    #}

    # Create a new custom button.
    @editor_dlg.on("save_custom_button") { |dlg, id, code, metadata|
      success = CustomButtons.save(id, code, metadata)
      if success
        # Save the data.
        @options[:custom_buttons][id] = metadata
        @options.save
      end
      dlg.return success
    }

    # Delete a new custom button.
    @editor_dlg.on("delete_custom_button") { |dlg, id|
      CustomButtons.delete(id)
      # Save the data.
      @options[:custom_buttons].delete(id)
      @options.save
    }

    # Save the toolbars and close the dialog.
    @editor_dlg.on("save_toolbars") { |dlg, hash|
      hash2 = {}
      hash.each{ |id, metadata|
        id = id.to_i
        Toolbars.save(id, metadata)
        hash2[id] = metadata
      }
      # Save the data.
      @options[:toolbars] = hash2
      @options.save
      dlg.close
    }

    # Delete a toolbar.
    @editor_dlg.on("delete_toolbar") { |dlg, id|
      Toolbars.delete(id)
      # Save the data.
      @options[:toolbars].delete(id)
      @options.save
    }

    # File dialog.
    @editor_dlg.on("openpanel") { |dlg, path|
      new_path = UI.openpanel(path)
      dlg.return((new_path) ? File.expand_path(new_path) : new_path)
    }

    # Close the dialog.
    @editor_dlg.on("close") { |dlg|
      dlg.close
    }

    # Show the webdialog.
    @editor_dlg.show

  end
  return @editor_dlg
end


# Closes the main dialog if it is visible.
def self.close
  if @editor_dlg && @editor_dlg.visible?
    @editor_dlg.close
  end
end


# Creates toolbar objects for commands remembered from @options and identified by IDs.
def self.load
  # Load custom buttons.
  @options[:custom_buttons].each{ |id, metadata|
    next unless metadata.is_a?(Hash) && metadata["name"].is_a?(String)
    CustomButtons.load(id, metadata)
  }
  # Load toolbars.
  @options[:toolbars].each{ |id, metadata|
    next unless metadata.is_a?(Hash) && metadata["name"].is_a?(String) &&
      metadata["buttons"].is_a?(Array) && !metadata["buttons"].empty?
    Toolbars.load(id, metadata)
  }
end


module Toolbars


  @@entries ||= {}
  @@toolbars ||= {}


  # Gets all entries of toolbars hashes.
  # @return [Array<Hash>]
  def self.get_all
    return @@entries.values.map{ |entry| entry.clone }
  end


  # Saves a new or modified toolbar.
  # @param [Fixnum] id
  # @param [Hash] metadata
  def self.save(id, metadata)
    if @@entries.include?(id) && @@toolbars.include?(id)
      self.update(id, metadata)
    else
      self.create(id, metadata)
    end
  end
  # We can use the same method when loading toolbars on startup
  def self.load(id, metadata)
    self.save(id, metadata)
  end


  # Creates a new toolbar.
  # @param [Fixnum] id
  # @param [Hash] metadata
  def self.create(id, metadata)
    # Create the entry.
    entry = {:id => id}
    update_entry(entry, metadata)
    # Create the toolbar.
    toolbar = create_toolbar(metadata["name"], metadata["buttons"])
    # toolbar.show
    toolbar.restore
    # Per bug 2902434, adding a timer call to restore the toolbar. This
    # fixes a toolbar resizing regression on Windows as the restore() call
    # does not seem to work as the script is first loading.
    UI.start_timer(0.1, false) {
      toolbar.restore
    }
    @@toolbars[id] = toolbar
    @@entries[id] = entry
  end


  # Updates an existing toolbar.
  # @param [Fixnum] id
  # @param [Hash] metadata
  def self.update(id, metadata)
    # Update the entry.
    update_entry(@@entries[id], metadata)
    # Update the toolbar.
    update_toolbar(id, metadata["name"], metadata["buttons"])
  end


  # Deletes a toolbar.
  # @param [Fixnum] id
  def self.delete(id)
    @@entries.delete(id)
    @@toolbars.delete(id)
  end


  class << self


    # Updates the entry hash of a toolbar.
    # @param [Hash] entry
    # @param [Hash] metadata
    def update_entry(entry, metadata)
      entry.merge!(metadata)
    end
    private :update_entry


    # Creates a toolbar UI object.
    # @param [String] name
    # @param [Array<Fixnum>] button_ids
    # @return [UI::Toolbar]
    def create_toolbar(name, button_ids)
      # Create a new toolbar and add the commands.
      toolbar = UI::Toolbar.new(name)
      button_ids.each{ |id|
        if id == 0
          # Add a separator
          toolbar.add_separator()
        else
          # Get the entry.
          entry = Index.instance.get_by_id(id)
          entry = CustomButtons.get_by_id(id) if entry.nil?
          next if entry.nil?
          command = entry[:command] || UI::Command.new(entry[:name], &entry[:proc]) rescue next
          toolbar.add_item(command)
        end
      }
      return toolbar
    end
    private :create_toolbar


    # Updates the toolbar UI object.
    # Since we can currently not remove commands from toolbars, we only add new ones.
    # @param [Fixnum] id
    # @param [String] name
    # @param [Array<Fixnum>] button_ids
    def update_toolbar(id, name, button_ids)
      toolbar = @@toolbars[id]
      # toolbar.name = name
      # Get all commands that this toolbar already has, ignore strings.
      # Note: Iterating a toolbar returns new, different command references, and a 
      # direct comparison to references to another instance of the "same" command fails.
      commands = toolbar.entries
      # Check if there are new buttons.
      # if commands.length < button_ids.length
      button_ids.each{ |id|
        if id == 0
          # Add a separator
          toolbar.add_separator() unless commands.include?("|")
          next
        else
          # Get the entry.
          entry = Index.instance.get_by_id(id)
          entry = CustomButtons.get_by_id(id) if entry.nil?
          next if entry.nil?
          # Don't add it if it already is on the toolbar. 
          # Direct comparison fails, so we compare the name.
          next if commands.find{ |c| c == entry[:command] || c.is_a?(UI::Command) && c.menu_text == entry[:name] }
          # Add the command.
          command = entry[:command] || UI::Command.new(entry[:name], &entry[:proc]) rescue next
          toolbar.add_item(command)
        end
      }
      # end
    end
    private :update_toolbar


  end # class << self


end


module CustomButtons


  @@entries ||= {}


  # Gets all entries of custom button hashes.
  # @return [Array<Hash>]
  def self.get_all
    return @@entries.values.map{ |entry| entry.clone }
  end


  # Gets the entry hash of a specific custom button.
  # @param [Fixnum] id
  # @return [Hash]
  def self.get_by_id(id)
    return @@entries[id].clone if @@entries.include?(id)
  end


  # Loads a custom button from file.
  # @param [Fixnum] id
  # @param [Hash] metadata
  def self.load(id, metadata)
    # Load the code from a file.
    code = read_from_file(id)
    # Create the command.
    if code
      self.create(id, code, metadata)
    end
  end


  # Saves a new or modified custom button.
  # @param [Fixnum] id
  # @param [String] code
  # @param [Hash] metadata
  # @return [Boolean] success
  def self.save(id, code, metadata)
    if @@entries.include?(id)
      success = self.update(id, code, metadata)
    else
      success = self.create(id, code, metadata)
    end
    return success
  end


  # Creates a new custom button.
  # @param [Fixnum] id
  # @param [String] code
  # @param [Hash] metadata
  # @return [Boolean] success
  def self.create(id, code, metadata)
    return false unless id.is_a?(Fixnum) || id.is_a?(String)
    # Create the entry.
    entry = {:id => id}
    update_entry(entry, metadata)
    # Create the command.
    return false unless create_command(entry, code)
    # Write the code to a file.
    return false unless write_to_file(id, code, metadata)
    @@entries[id] = entry
    return true
  end


  # Updates an existing custom button.
  # @param [Fixnum] id
  # @param [String] code
  # @param [Hash] metadata
  # @return [Boolean] success
  def self.update(id, code, metadata)
    # Update the entry.
    entry = @@entries[id]
    return false unless entry.is_a?(Hash)
    update_entry(entry, metadata)
    # Update the command.
    return false unless update_command(entry, code)
    # Write the code to a file.
    return false unless write_to_file(id, code, metadata)
    return true
  end


  # Deletse a custom button.
  # @param [Fixnum] id
  def self.delete(id)
    @@entries.delete(id)
  end


  class << self


    # Updates the entry hash of a custom button.
    # @param [Hash] entry
    # @param [Hash] metadata
    def update_entry(entry, metadata)
      entry[:name] = metadata["name"]
      entry[:category] = metadata["category"] if metadata.include?("category")
      entry[:description] = metadata["description"] if metadata.include?("description")
      entry[:icon] = metadata["icon"] if metadata.include?("icon")
    end
    private :update_entry


    # Creates the command object of a custom button.
    # @param [Hash] entry
    # @param [String] code
    # @return [UI::Command,NilClass] depending on success
    def create_command(entry, code)
      name = entry[:name]
      raise ArgumentError.new("Argument 'code' must be a String and Hash 'entry' must contain 'name'") unless code.is_a?(String) && name.is_a?(String)
      # Create a Proc.
      begin
        block = eval(
          "Proc.new{
            begin
              #{code}
            rescue Exception => e
              e.message << ' in custom button #{name}'
              if defined?(AE::Console)
                AE::Console.error(e)
              else
                $stderr.write(e.message << $/)
                $stderr.write(e.backtrace.join($/) << $/)
              end
            end
          }", TOPLEVEL_BINDING
        )
      rescue SyntaxError => e
        e.message << " in custom button #{name}"
        if defined?(AE::Console)
          AE::Console.error(e)
        else
          $stderr.write(e.message << $/)
          $stderr.write(e.backtrace.join($/) << $/)
        end
        return nil
      end
      # Create a command.
      # We only bind a reference to the entry, and fetch everytime the current proc. 
      # This allows the command to run updated code.
      command = UI::Command.new(name){
        entry[:proc].call if entry.is_a?(Hash) && entry[:proc].is_a?(Proc)
      }
      command.tooltip = entry[:description] || ""
      command.status_bar_text = entry[:instruction] || ""
      command.large_icon = entry[:large_icon] || entry[:icon] || ""
      command.small_icon = entry[:small_icon] || entry[:icon] || ""
      entry[:proc] = block
      entry[:command] = command
      entry[:code] = code
      return command
    end
    # Since we can currently neither set nor read the Proc of a UI::Command, we 
    # use the same method to update a command by creating a new command instance.
    alias_method :update_command, :create_command
    private :create_command, :update_command


    # Reads the code snippet from a Ruby file.
    # @param [Fixnum] id
    # @return [String, NilClass] the code string
    def read_from_file(id)
      snippet_path = Dir.glob(File.join(DATA_DIR, "#{id}*.rb")).first
      return nil unless snippet_path && File.exists?(snippet_path)
      code = IO.read(snippet_path)
      # Drop comment lines.
      code = $' if code[/^=begin\n(?:.|\n\r?)*?\n=end\n/]
      return code
    end
    private :read_from_file


    # Write the code snippet to a file.
    # @param [Fixnum] id
    # @param [String] code
    # @return [Boolean] success whether the snippet was saved successfully
    def write_to_file(id, code, metadata)
      entry = @@entries[id] || metadata
      name = entry[:name] || metadata["name"] || ""
      Dir.mkdir(DATA_DIR) unless File.exists?(DATA_DIR)
      # Delete old file.
      Dir.glob(File.join(DATA_DIR, "#{id}*.rb")).each{ |old_file| 
        File.delete(old_file) rescue nil
      }
      # Write new file.
      path = File.join(DATA_DIR, "#{id}_#{name}.rb")
      File.open(path, "w") { |file|
        # Write some relevant metadata as comment into the file.
        file.puts("=begin")
        entry.each{ |key, value|
          next if !value.is_a?(String) || key == :code || key == "code"
          # If the value contains line breaks, indent every new line.
          value_lines = value.gsub(/\n\r?/, "\n" + " "*12)
          file.puts("#{key.to_s.rjust(12, ' ')}: #{value_lines}")
        }
        file.puts("=end")
        # Write the code to the file.
        file.write(code)
      }
      return true
    rescue Errno::ENOENT => e
      e.message << " Could not write #{path}"
      if defined?(AE::Console)
        AE::Console.error(e)
      else
        $stderr.write(e.message << $/)
        $stderr.write(e.backtrace.join($/) << $/)
      end
      return false
    end
    private :write_to_file


  end # class << self

end


unless file_loaded?(File.basename(__FILE__))

  # Add this plugin to the UI
  cmd_editor = UI::Command.new(TRANSLATE["Toolbar Editor"]){ AE::ToolbarEditor.show_dialog }
  cmd_editor.tooltip = TRANSLATE["A drag&drop editor to create custom toolbars."]
  cmd_editor.small_icon = File.join(DIR, "images", "icon_toolbareditor_16.png")
  cmd_editor.large_icon = File.join(DIR, "images", "icon_toolbareditor_24.png")
  cmd_editor.set_validation_proc {
    (@editor_dlg && @editor_dlg.visible?) ? MF_CHECKED : MF_UNCHECKED
  }
  UI.menu("Window").add_item(cmd_editor)

  # Load all custom toolbars when SketchUp starts, but only after all other plugins have been loaded.
  # Alternative 1: require all in load path:
=begin
  $LOAD_PATH.each{ |path|
    next unless path.is_a?(String) && File.directory?(path)
    # The following freezes SketchUp:
    Dir[File.join(path, "*.{rb,rbs}")][0,10].each{ |file|
      next if file == __FILE__ || $".include?(file)
      begin
        require(file)
      rescue
        next
      end
    }
  }
  self.load()
=end
  # Alternative 2: use timer that will execute after loading has finished.
  # Problem: SketchUp2013 doesn't remember UI::Toolbar.get_last_state for toolbars
  # that were created long after loading finished. This was fixed in 2013.
  UI.start_timer(0, false) { self.load() }

  file_loaded(File.basename(__FILE__))
end # end unless


end # module ToolbarEditor


end # module AE
