X-Git-Url: https://review.fuel-infra.org/gitweb?a=blobdiff_plain;f=lib%2Fmcollective%2Fvendor%2Fjson%2Flib%2Fjson%2Feditor.rb;fp=lib%2Fmcollective%2Fvendor%2Fjson%2Flib%2Fjson%2Feditor.rb;h=985a5549e0ddaf28f009f88ca2af5fbced38164d;hb=b87d2f4e68281062df1913440ca5753ae63314a9;hp=0000000000000000000000000000000000000000;hpb=ab0ea530b8ac956091f17b104ab2311336cfc250;p=packages%2Fprecise%2Fmcollective.git diff --git a/lib/mcollective/vendor/json/lib/json/editor.rb b/lib/mcollective/vendor/json/lib/json/editor.rb new file mode 100644 index 0000000..985a554 --- /dev/null +++ b/lib/mcollective/vendor/json/lib/json/editor.rb @@ -0,0 +1,1369 @@ +# To use the GUI JSON editor, start the edit_json.rb executable script. It +# requires ruby-gtk to be installed. + +require 'gtk2' +require 'json' +require 'rbconfig' +require 'open-uri' + +module JSON + module Editor + include Gtk + + # Beginning of the editor window title + TITLE = 'JSON Editor'.freeze + + # Columns constants + ICON_COL, TYPE_COL, CONTENT_COL = 0, 1, 2 + + # JSON primitive types (Containers) + CONTAINER_TYPES = %w[Array Hash].sort + # All JSON primitive types + ALL_TYPES = (%w[TrueClass FalseClass Numeric String NilClass] + + CONTAINER_TYPES).sort + + # The Nodes necessary for the tree representation of a JSON document + ALL_NODES = (ALL_TYPES + %w[Key]).sort + + DEFAULT_DIALOG_KEY_PRESS_HANDLER = lambda do |dialog, event| + case event.keyval + when Gdk::Keyval::GDK_Return + dialog.response Dialog::RESPONSE_ACCEPT + when Gdk::Keyval::GDK_Escape + dialog.response Dialog::RESPONSE_REJECT + end + end + + # Returns the Gdk::Pixbuf of the icon named _name_ from the icon cache. + def Editor.fetch_icon(name) + @icon_cache ||= {} + unless @icon_cache.key?(name) + path = File.dirname(__FILE__) + @icon_cache[name] = Gdk::Pixbuf.new(File.join(path, name + '.xpm')) + end + @icon_cache[name] + end + + # Opens an error dialog on top of _window_ showing the error message + # _text_. + def Editor.error_dialog(window, text) + dialog = MessageDialog.new(window, Dialog::MODAL, + MessageDialog::ERROR, + MessageDialog::BUTTONS_CLOSE, text) + dialog.show_all + dialog.run + rescue TypeError + dialog = MessageDialog.new(Editor.window, Dialog::MODAL, + MessageDialog::ERROR, + MessageDialog::BUTTONS_CLOSE, text) + dialog.show_all + dialog.run + ensure + dialog.destroy if dialog + end + + # Opens a yes/no question dialog on top of _window_ showing the error + # message _text_. If yes was answered _true_ is returned, otherwise + # _false_. + def Editor.question_dialog(window, text) + dialog = MessageDialog.new(window, Dialog::MODAL, + MessageDialog::QUESTION, + MessageDialog::BUTTONS_YES_NO, text) + dialog.show_all + dialog.run do |response| + return Gtk::Dialog::RESPONSE_YES === response + end + ensure + dialog.destroy if dialog + end + + # Convert the tree model starting from Gtk::TreeIter _iter_ into a Ruby + # data structure and return it. + def Editor.model2data(iter) + return nil if iter.nil? + case iter.type + when 'Hash' + hash = {} + iter.each { |c| hash[c.content] = Editor.model2data(c.first_child) } + hash + when 'Array' + array = Array.new(iter.n_children) + iter.each_with_index { |c, i| array[i] = Editor.model2data(c) } + array + when 'Key' + iter.content + when 'String' + iter.content + when 'Numeric' + content = iter.content + if /\./.match(content) + content.to_f + else + content.to_i + end + when 'TrueClass' + true + when 'FalseClass' + false + when 'NilClass' + nil + else + fail "Unknown type found in model: #{iter.type}" + end + end + + # Convert the Ruby data structure _data_ into tree model data for Gtk and + # returns the whole model. If the parameter _model_ wasn't given a new + # Gtk::TreeStore is created as the model. The _parent_ parameter specifies + # the parent node (iter, Gtk:TreeIter instance) to which the data is + # appended, alternativeley the result of the yielded block is used as iter. + def Editor.data2model(data, model = nil, parent = nil) + model ||= TreeStore.new(Gdk::Pixbuf, String, String) + iter = if block_given? + yield model + else + model.append(parent) + end + case data + when Hash + iter.type = 'Hash' + data.sort.each do |key, value| + pair_iter = model.append(iter) + pair_iter.type = 'Key' + pair_iter.content = key.to_s + Editor.data2model(value, model, pair_iter) + end + when Array + iter.type = 'Array' + data.each do |value| + Editor.data2model(value, model, iter) + end + when Numeric + iter.type = 'Numeric' + iter.content = data.to_s + when String, true, false, nil + iter.type = data.class.name + iter.content = data.nil? ? 'null' : data.to_s + else + iter.type = 'String' + iter.content = data.to_s + end + model + end + + # The Gtk::TreeIter class is reopened and some auxiliary methods are added. + class Gtk::TreeIter + include Enumerable + + # Traverse each of this Gtk::TreeIter instance's children + # and yield to them. + def each + n_children.times { |i| yield nth_child(i) } + end + + # Recursively traverse all nodes of this Gtk::TreeIter's subtree + # (including self) and yield to them. + def recursive_each(&block) + yield self + each do |i| + i.recursive_each(&block) + end + end + + # Remove the subtree of this Gtk::TreeIter instance from the + # model _model_. + def remove_subtree(model) + while current = first_child + model.remove(current) + end + end + + # Returns the type of this node. + def type + self[TYPE_COL] + end + + # Sets the type of this node to _value_. This implies setting + # the respective icon accordingly. + def type=(value) + self[TYPE_COL] = value + self[ICON_COL] = Editor.fetch_icon(value) + end + + # Returns the content of this node. + def content + self[CONTENT_COL] + end + + # Sets the content of this node to _value_. + def content=(value) + self[CONTENT_COL] = value + end + end + + # This module bundles some method, that can be used to create a menu. It + # should be included into the class in question. + module MenuExtension + include Gtk + + # Creates a Menu, that includes MenuExtension. _treeview_ is the + # Gtk::TreeView, on which it operates. + def initialize(treeview) + @treeview = treeview + @menu = Menu.new + end + + # Returns the Gtk::TreeView of this menu. + attr_reader :treeview + + # Returns the menu. + attr_reader :menu + + # Adds a Gtk::SeparatorMenuItem to this instance's #menu. + def add_separator + menu.append SeparatorMenuItem.new + end + + # Adds a Gtk::MenuItem to this instance's #menu. _label_ is the label + # string, _klass_ is the item type, and _callback_ is the procedure, that + # is called if the _item_ is activated. + def add_item(label, keyval = nil, klass = MenuItem, &callback) + label = "#{label} (C-#{keyval.chr})" if keyval + item = klass.new(label) + item.signal_connect(:activate, &callback) + if keyval + self.signal_connect(:'key-press-event') do |item, event| + if event.state & Gdk::Window::ModifierType::CONTROL_MASK != 0 and + event.keyval == keyval + callback.call item + end + end + end + menu.append item + item + end + + # This method should be implemented in subclasses to create the #menu of + # this instance. It has to be called after an instance of this class is + # created, to build the menu. + def create + raise NotImplementedError + end + + def method_missing(*a, &b) + treeview.__send__(*a, &b) + end + end + + # This class creates the popup menu, that opens when clicking onto the + # treeview. + class PopUpMenu + include MenuExtension + + # Change the type or content of the selected node. + def change_node(item) + if current = selection.selected + parent = current.parent + old_type, old_content = current.type, current.content + if ALL_TYPES.include?(old_type) + @clipboard_data = Editor.model2data(current) + type, content = ask_for_element(parent, current.type, + current.content) + if type + current.type, current.content = type, content + current.remove_subtree(model) + toplevel.display_status("Changed a node in tree.") + window.change + end + else + toplevel.display_status( + "Cannot change node of type #{old_type} in tree!") + end + end + end + + # Cut the selected node and its subtree, and save it into the + # clipboard. + def cut_node(item) + if current = selection.selected + if current and current.type == 'Key' + @clipboard_data = { + current.content => Editor.model2data(current.first_child) + } + else + @clipboard_data = Editor.model2data(current) + end + model.remove(current) + window.change + toplevel.display_status("Cut a node from tree.") + end + end + + # Copy the selected node and its subtree, and save it into the + # clipboard. + def copy_node(item) + if current = selection.selected + if current and current.type == 'Key' + @clipboard_data = { + current.content => Editor.model2data(current.first_child) + } + else + @clipboard_data = Editor.model2data(current) + end + window.change + toplevel.display_status("Copied a node from tree.") + end + end + + # Paste the data in the clipboard into the selected Array or Hash by + # appending it. + def paste_node_appending(item) + if current = selection.selected + if @clipboard_data + case current.type + when 'Array' + Editor.data2model(@clipboard_data, model, current) + expand_collapse(current) + when 'Hash' + if @clipboard_data.is_a? Hash + parent = current.parent + hash = Editor.model2data(current) + model.remove(current) + hash.update(@clipboard_data) + Editor.data2model(hash, model, parent) + if parent + expand_collapse(parent) + elsif @expanded + expand_all + end + window.change + else + toplevel.display_status( + "Cannot paste non-#{current.type} data into '#{current.type}'!") + end + else + toplevel.display_status( + "Cannot paste node below '#{current.type}'!") + end + else + toplevel.display_status("Nothing to paste in clipboard!") + end + else + toplevel.display_status("Append a node into the root first!") + end + end + + # Paste the data in the clipboard into the selected Array inserting it + # before the selected element. + def paste_node_inserting_before(item) + if current = selection.selected + if @clipboard_data + parent = current.parent or return + parent_type = parent.type + if parent_type == 'Array' + selected_index = parent.each_with_index do |c, i| + break i if c == current + end + Editor.data2model(@clipboard_data, model, parent) do |m| + m.insert_before(parent, current) + end + expand_collapse(current) + toplevel.display_status("Inserted an element to " + + "'#{parent_type}' before index #{selected_index}.") + window.change + else + toplevel.display_status( + "Cannot insert node below '#{parent_type}'!") + end + else + toplevel.display_status("Nothing to paste in clipboard!") + end + else + toplevel.display_status("Append a node into the root first!") + end + end + + # Append a new node to the selected Hash or Array. + def append_new_node(item) + if parent = selection.selected + parent_type = parent.type + case parent_type + when 'Hash' + key, type, content = ask_for_hash_pair(parent) + key or return + iter = create_node(parent, 'Key', key) + iter = create_node(iter, type, content) + toplevel.display_status( + "Added a (key, value)-pair to '#{parent_type}'.") + window.change + when 'Array' + type, content = ask_for_element(parent) + type or return + iter = create_node(parent, type, content) + window.change + toplevel.display_status("Appendend an element to '#{parent_type}'.") + else + toplevel.display_status("Cannot append to '#{parent_type}'!") + end + else + type, content = ask_for_element + type or return + iter = create_node(nil, type, content) + window.change + end + end + + # Insert a new node into an Array before the selected element. + def insert_new_node(item) + if current = selection.selected + parent = current.parent or return + parent_parent = parent.parent + parent_type = parent.type + if parent_type == 'Array' + selected_index = parent.each_with_index do |c, i| + break i if c == current + end + type, content = ask_for_element(parent) + type or return + iter = model.insert_before(parent, current) + iter.type, iter.content = type, content + toplevel.display_status("Inserted an element to " + + "'#{parent_type}' before index #{selected_index}.") + window.change + else + toplevel.display_status( + "Cannot insert node below '#{parent_type}'!") + end + else + toplevel.display_status("Append a node into the root first!") + end + end + + # Recursively collapse/expand a subtree starting from the selected node. + def collapse_expand(item) + if current = selection.selected + if row_expanded?(current.path) + collapse_row(current.path) + else + expand_row(current.path, true) + end + else + toplevel.display_status("Append a node into the root first!") + end + end + + # Create the menu. + def create + add_item("Change node", ?n, &method(:change_node)) + add_separator + add_item("Cut node", ?X, &method(:cut_node)) + add_item("Copy node", ?C, &method(:copy_node)) + add_item("Paste node (appending)", ?A, &method(:paste_node_appending)) + add_item("Paste node (inserting before)", ?I, + &method(:paste_node_inserting_before)) + add_separator + add_item("Append new node", ?a, &method(:append_new_node)) + add_item("Insert new node before", ?i, &method(:insert_new_node)) + add_separator + add_item("Collapse/Expand node (recursively)", ?e, + &method(:collapse_expand)) + + menu.show_all + signal_connect(:button_press_event) do |widget, event| + if event.kind_of? Gdk::EventButton and event.button == 3 + menu.popup(nil, nil, event.button, event.time) + end + end + signal_connect(:popup_menu) do + menu.popup(nil, nil, 0, Gdk::Event::CURRENT_TIME) + end + end + end + + # This class creates the File pulldown menu. + class FileMenu + include MenuExtension + + # Clear the model and filename, but ask to save the JSON document, if + # unsaved changes have occured. + def new(item) + window.clear + end + + # Open a file and load it into the editor. Ask to save the JSON document + # first, if unsaved changes have occured. + def open(item) + window.file_open + end + + def open_location(item) + window.location_open + end + + # Revert the current JSON document in the editor to the saved version. + def revert(item) + window.instance_eval do + @filename and file_open(@filename) + end + end + + # Save the current JSON document. + def save(item) + window.file_save + end + + # Save the current JSON document under the given filename. + def save_as(item) + window.file_save_as + end + + # Quit the editor, after asking to save any unsaved changes first. + def quit(item) + window.quit + end + + # Create the menu. + def create + title = MenuItem.new('File') + title.submenu = menu + add_item('New', &method(:new)) + add_item('Open', ?o, &method(:open)) + add_item('Open location', ?l, &method(:open_location)) + add_item('Revert', &method(:revert)) + add_separator + add_item('Save', ?s, &method(:save)) + add_item('Save As', ?S, &method(:save_as)) + add_separator + add_item('Quit', ?q, &method(:quit)) + title + end + end + + # This class creates the Edit pulldown menu. + class EditMenu + include MenuExtension + + # Copy data from model into primary clipboard. + def copy(item) + data = Editor.model2data(model.iter_first) + json = JSON.pretty_generate(data, :max_nesting => false) + c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY) + c.text = json + end + + # Copy json text from primary clipboard into model. + def paste(item) + c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY) + if json = c.wait_for_text + window.ask_save if @changed + begin + window.edit json + rescue JSON::ParserError + window.clear + end + end + end + + # Find a string in all nodes' contents and select the found node in the + # treeview. + def find(item) + @search = ask_for_find_term(@search) or return + iter = model.get_iter('0') or return + iter.recursive_each do |i| + if @iter + if @iter != i + next + else + @iter = nil + next + end + elsif @search.match(i[CONTENT_COL]) + set_cursor(i.path, nil, false) + @iter = i + break + end + end + end + + # Repeat the last search given by #find. + def find_again(item) + @search or return + iter = model.get_iter('0') + iter.recursive_each do |i| + if @iter + if @iter != i + next + else + @iter = nil + next + end + elsif @search.match(i[CONTENT_COL]) + set_cursor(i.path, nil, false) + @iter = i + break + end + end + end + + # Sort (Reverse sort) all elements of the selected array by the given + # expression. _x_ is the element in question. + def sort(item) + if current = selection.selected + if current.type == 'Array' + parent = current.parent + ary = Editor.model2data(current) + order, reverse = ask_for_order + order or return + begin + block = eval "lambda { |x| #{order} }" + if reverse + ary.sort! { |a,b| block[b] <=> block[a] } + else + ary.sort! { |a,b| block[a] <=> block[b] } + end + rescue => e + Editor.error_dialog(self, "Failed to sort Array with #{order}: #{e}!") + else + Editor.data2model(ary, model, parent) do |m| + m.insert_before(parent, current) + end + model.remove(current) + expand_collapse(parent) + window.change + toplevel.display_status("Array has been sorted.") + end + else + toplevel.display_status("Only Array nodes can be sorted!") + end + else + toplevel.display_status("Select an Array to sort first!") + end + end + + # Create the menu. + def create + title = MenuItem.new('Edit') + title.submenu = menu + add_item('Copy', ?c, &method(:copy)) + add_item('Paste', ?v, &method(:paste)) + add_separator + add_item('Find', ?f, &method(:find)) + add_item('Find Again', ?g, &method(:find_again)) + add_separator + add_item('Sort', ?S, &method(:sort)) + title + end + end + + class OptionsMenu + include MenuExtension + + # Collapse/Expand all nodes by default. + def collapsed_nodes(item) + if expanded + self.expanded = false + collapse_all + else + self.expanded = true + expand_all + end + end + + # Toggle pretty saving mode on/off. + def pretty_saving(item) + @pretty_item.toggled + window.change + end + + attr_reader :pretty_item + + # Create the menu. + def create + title = MenuItem.new('Options') + title.submenu = menu + add_item('Collapsed nodes', nil, CheckMenuItem, &method(:collapsed_nodes)) + @pretty_item = add_item('Pretty saving', nil, CheckMenuItem, + &method(:pretty_saving)) + @pretty_item.active = true + window.unchange + title + end + end + + # This class inherits from Gtk::TreeView, to configure it and to add a lot + # of behaviour to it. + class JSONTreeView < Gtk::TreeView + include Gtk + + # Creates a JSONTreeView instance, the parameter _window_ is + # a MainWindow instance and used for self delegation. + def initialize(window) + @window = window + super(TreeStore.new(Gdk::Pixbuf, String, String)) + self.selection.mode = SELECTION_BROWSE + + @expanded = false + self.headers_visible = false + add_columns + add_popup_menu + end + + # Returns the MainWindow instance of this JSONTreeView. + attr_reader :window + + # Returns true, if nodes are autoexpanding, false otherwise. + attr_accessor :expanded + + private + + def add_columns + cell = CellRendererPixbuf.new + column = TreeViewColumn.new('Icon', cell, + 'pixbuf' => ICON_COL + ) + append_column(column) + + cell = CellRendererText.new + column = TreeViewColumn.new('Type', cell, + 'text' => TYPE_COL + ) + append_column(column) + + cell = CellRendererText.new + cell.editable = true + column = TreeViewColumn.new('Content', cell, + 'text' => CONTENT_COL + ) + cell.signal_connect(:edited, &method(:cell_edited)) + append_column(column) + end + + def unify_key(iter, key) + return unless iter.type == 'Key' + parent = iter.parent + if parent.any? { |c| c != iter and c.content == key } + old_key = key + i = 0 + begin + key = sprintf("%s.%d", old_key, i += 1) + end while parent.any? { |c| c != iter and c.content == key } + end + iter.content = key + end + + def cell_edited(cell, path, value) + iter = model.get_iter(path) + case iter.type + when 'Key' + unify_key(iter, value) + toplevel.display_status('Key has been changed.') + when 'FalseClass' + value.downcase! + if value == 'true' + iter.type, iter.content = 'TrueClass', 'true' + end + when 'TrueClass' + value.downcase! + if value == 'false' + iter.type, iter.content = 'FalseClass', 'false' + end + when 'Numeric' + iter.content = + if value == 'Infinity' + value + else + (Integer(value) rescue Float(value) rescue 0).to_s + end + when 'String' + iter.content = value + when 'Hash', 'Array' + return + else + fail "Unknown type found in model: #{iter.type}" + end + window.change + end + + def configure_value(value, type) + value.editable = false + case type + when 'Array', 'Hash' + value.text = '' + when 'TrueClass' + value.text = 'true' + when 'FalseClass' + value.text = 'false' + when 'NilClass' + value.text = 'null' + when 'Numeric', 'String' + value.text ||= '' + value.editable = true + else + raise ArgumentError, "unknown type '#{type}' encountered" + end + end + + def add_popup_menu + menu = PopUpMenu.new(self) + menu.create + end + + public + + # Create a _type_ node with content _content_, and add it to _parent_ + # in the model. If _parent_ is nil, create a new model and put it into + # the editor treeview. + def create_node(parent, type, content) + iter = if parent + model.append(parent) + else + new_model = Editor.data2model(nil) + toplevel.view_new_model(new_model) + new_model.iter_first + end + iter.type, iter.content = type, content + expand_collapse(parent) if parent + iter + end + + # Ask for a hash key, value pair to be added to the Hash node _parent_. + def ask_for_hash_pair(parent) + key_input = type_input = value_input = nil + + dialog = Dialog.new("New (key, value) pair for Hash", nil, nil, + [ Stock::OK, Dialog::RESPONSE_ACCEPT ], + [ Stock::CANCEL, Dialog::RESPONSE_REJECT ] + ) + dialog.width_request = 640 + + hbox = HBox.new(false, 5) + hbox.pack_start(Label.new("Key:"), false) + hbox.pack_start(key_input = Entry.new) + key_input.text = @key || '' + dialog.vbox.pack_start(hbox, false) + key_input.signal_connect(:activate) do + if parent.any? { |c| c.content == key_input.text } + toplevel.display_status('Key already exists in Hash!') + key_input.text = '' + else + toplevel.display_status('Key has been changed.') + end + end + + hbox = HBox.new(false, 5) + hbox.pack_start(Label.new("Type:"), false) + hbox.pack_start(type_input = ComboBox.new(true)) + ALL_TYPES.each { |t| type_input.append_text(t) } + type_input.active = @type || 0 + dialog.vbox.pack_start(hbox, false) + + type_input.signal_connect(:changed) do + value_input.editable = false + case ALL_TYPES[type_input.active] + when 'Array', 'Hash' + value_input.text = '' + when 'TrueClass' + value_input.text = 'true' + when 'FalseClass' + value_input.text = 'false' + when 'NilClass' + value_input.text = 'null' + else + value_input.text = '' + value_input.editable = true + end + end + + hbox = HBox.new(false, 5) + hbox.pack_start(Label.new("Value:"), false) + hbox.pack_start(value_input = Entry.new) + value_input.width_chars = 60 + value_input.text = @value || '' + dialog.vbox.pack_start(hbox, false) + + dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER) + dialog.show_all + self.focus = dialog + dialog.run do |response| + if response == Dialog::RESPONSE_ACCEPT + @key = key_input.text + type = ALL_TYPES[@type = type_input.active] + content = value_input.text + return @key, type, content + end + end + return + ensure + dialog.destroy + end + + # Ask for an element to be appended _parent_. + def ask_for_element(parent = nil, default_type = nil, value_text = @content) + type_input = value_input = nil + + dialog = Dialog.new( + "New element into #{parent ? parent.type : 'root'}", + nil, nil, + [ Stock::OK, Dialog::RESPONSE_ACCEPT ], + [ Stock::CANCEL, Dialog::RESPONSE_REJECT ] + ) + hbox = HBox.new(false, 5) + hbox.pack_start(Label.new("Type:"), false) + hbox.pack_start(type_input = ComboBox.new(true)) + default_active = 0 + types = parent ? ALL_TYPES : CONTAINER_TYPES + types.each_with_index do |t, i| + type_input.append_text(t) + if t == default_type + default_active = i + end + end + type_input.active = default_active + dialog.vbox.pack_start(hbox, false) + type_input.signal_connect(:changed) do + configure_value(value_input, types[type_input.active]) + end + + hbox = HBox.new(false, 5) + hbox.pack_start(Label.new("Value:"), false) + hbox.pack_start(value_input = Entry.new) + value_input.width_chars = 60 + value_input.text = value_text if value_text + configure_value(value_input, types[type_input.active]) + + dialog.vbox.pack_start(hbox, false) + + dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER) + dialog.show_all + self.focus = dialog + dialog.run do |response| + if response == Dialog::RESPONSE_ACCEPT + type = types[type_input.active] + @content = case type + when 'Numeric' + if (t = value_input.text) == 'Infinity' + 1 / 0.0 + else + Integer(t) rescue Float(t) rescue 0 + end + else + value_input.text + end.to_s + return type, @content + end + end + return + ensure + dialog.destroy if dialog + end + + # Ask for an order criteria for sorting, using _x_ for the element in + # question. Returns the order criterium, and true/false for reverse + # sorting. + def ask_for_order + dialog = Dialog.new( + "Give an order criterium for 'x'.", + nil, nil, + [ Stock::OK, Dialog::RESPONSE_ACCEPT ], + [ Stock::CANCEL, Dialog::RESPONSE_REJECT ] + ) + hbox = HBox.new(false, 5) + + hbox.pack_start(Label.new("Order:"), false) + hbox.pack_start(order_input = Entry.new) + order_input.text = @order || 'x' + order_input.width_chars = 60 + + hbox.pack_start(reverse_checkbox = CheckButton.new('Reverse'), false) + + dialog.vbox.pack_start(hbox, false) + + dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER) + dialog.show_all + self.focus = dialog + dialog.run do |response| + if response == Dialog::RESPONSE_ACCEPT + return @order = order_input.text, reverse_checkbox.active? + end + end + return + ensure + dialog.destroy if dialog + end + + # Ask for a find term to search for in the tree. Returns the term as a + # string. + def ask_for_find_term(search = nil) + dialog = Dialog.new( + "Find a node matching regex in tree.", + nil, nil, + [ Stock::OK, Dialog::RESPONSE_ACCEPT ], + [ Stock::CANCEL, Dialog::RESPONSE_REJECT ] + ) + hbox = HBox.new(false, 5) + + hbox.pack_start(Label.new("Regex:"), false) + hbox.pack_start(regex_input = Entry.new) + hbox.pack_start(icase_checkbox = CheckButton.new('Icase'), false) + regex_input.width_chars = 60 + if search + regex_input.text = search.source + icase_checkbox.active = search.casefold? + end + + dialog.vbox.pack_start(hbox, false) + + dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER) + dialog.show_all + self.focus = dialog + dialog.run do |response| + if response == Dialog::RESPONSE_ACCEPT + begin + return Regexp.new(regex_input.text, icase_checkbox.active? ? Regexp::IGNORECASE : 0) + rescue => e + Editor.error_dialog(self, "Evaluation of regex /#{regex_input.text}/ failed: #{e}!") + return + end + end + end + return + ensure + dialog.destroy if dialog + end + + # Expand or collapse row pointed to by _iter_ according + # to the #expanded attribute. + def expand_collapse(iter) + if expanded + expand_row(iter.path, true) + else + collapse_row(iter.path) + end + end + end + + # The editor main window + class MainWindow < Gtk::Window + include Gtk + + def initialize(encoding) + @changed = false + @encoding = encoding + super(TOPLEVEL) + display_title + set_default_size(800, 600) + signal_connect(:delete_event) { quit } + + vbox = VBox.new(false, 0) + add(vbox) + #vbox.border_width = 0 + + @treeview = JSONTreeView.new(self) + @treeview.signal_connect(:'cursor-changed') do + display_status('') + end + + menu_bar = create_menu_bar + vbox.pack_start(menu_bar, false, false, 0) + + sw = ScrolledWindow.new(nil, nil) + sw.shadow_type = SHADOW_ETCHED_IN + sw.set_policy(POLICY_AUTOMATIC, POLICY_AUTOMATIC) + vbox.pack_start(sw, true, true, 0) + sw.add(@treeview) + + @status_bar = Statusbar.new + vbox.pack_start(@status_bar, false, false, 0) + + @filename ||= nil + if @filename + data = read_data(@filename) + view_new_model Editor.data2model(data) + end + + signal_connect(:button_release_event) do |_,event| + if event.button == 2 + c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY) + if url = c.wait_for_text + location_open url + end + false + else + true + end + end + end + + # Creates the menu bar with the pulldown menus and returns it. + def create_menu_bar + menu_bar = MenuBar.new + @file_menu = FileMenu.new(@treeview) + menu_bar.append @file_menu.create + @edit_menu = EditMenu.new(@treeview) + menu_bar.append @edit_menu.create + @options_menu = OptionsMenu.new(@treeview) + menu_bar.append @options_menu.create + menu_bar + end + + # Sets editor status to changed, to indicate that the edited data + # containts unsaved changes. + def change + @changed = true + display_title + end + + # Sets editor status to unchanged, to indicate that the edited data + # doesn't containt unsaved changes. + def unchange + @changed = false + display_title + end + + # Puts a new model _model_ into the Gtk::TreeView to be edited. + def view_new_model(model) + @treeview.model = model + @treeview.expanded = true + @treeview.expand_all + unchange + end + + # Displays _text_ in the status bar. + def display_status(text) + @cid ||= nil + @status_bar.pop(@cid) if @cid + @cid = @status_bar.get_context_id('dummy') + @status_bar.push(@cid, text) + end + + # Opens a dialog, asking, if changes should be saved to a file. + def ask_save + if Editor.question_dialog(self, + "Unsaved changes to JSON model. Save?") + if @filename + file_save + else + file_save_as + end + end + end + + # Quit this editor, that is, leave this editor's main loop. + def quit + ask_save if @changed + if Gtk.main_level > 0 + destroy + Gtk.main_quit + end + nil + end + + # Display the new title according to the editor's current state. + def display_title + title = TITLE.dup + title << ": #@filename" if @filename + title << " *" if @changed + self.title = title + end + + # Clear the current model, after asking to save all unsaved changes. + def clear + ask_save if @changed + @filename = nil + self.view_new_model nil + end + + def check_pretty_printed(json) + pretty = !!((nl_index = json.index("\n")) && nl_index != json.size - 1) + @options_menu.pretty_item.active = pretty + end + private :check_pretty_printed + + # Open the data at the location _uri_, if given. Otherwise open a dialog + # to ask for the _uri_. + def location_open(uri = nil) + uri = ask_for_location unless uri + uri or return + ask_save if @changed + data = load_location(uri) or return + view_new_model Editor.data2model(data) + end + + # Open the file _filename_ or call the #select_file method to ask for a + # filename. + def file_open(filename = nil) + filename = select_file('Open as a JSON file') unless filename + data = load_file(filename) or return + view_new_model Editor.data2model(data) + end + + # Edit the string _json_ in the editor. + def edit(json) + if json.respond_to? :read + json = json.read + end + data = parse_json json + view_new_model Editor.data2model(data) + end + + # Save the current file. + def file_save + if @filename + store_file(@filename) + else + file_save_as + end + end + + # Save the current file as the filename + def file_save_as + filename = select_file('Save as a JSON file') + store_file(filename) + end + + # Store the current JSON document to _path_. + def store_file(path) + if path + data = Editor.model2data(@treeview.model.iter_first) + File.open(path + '.tmp', 'wb') do |output| + data or break + if @options_menu.pretty_item.active? + output.puts JSON.pretty_generate(data, :max_nesting => false) + else + output.write JSON.generate(data, :max_nesting => false) + end + end + File.rename path + '.tmp', path + @filename = path + toplevel.display_status("Saved data to '#@filename'.") + unchange + end + rescue SystemCallError => e + Editor.error_dialog(self, "Failed to store JSON file: #{e}!") + end + + # Load the file named _filename_ into the editor as a JSON document. + def load_file(filename) + if filename + if File.directory?(filename) + Editor.error_dialog(self, "Try to select a JSON file!") + nil + else + @filename = filename + if data = read_data(filename) + toplevel.display_status("Loaded data from '#@filename'.") + end + display_title + data + end + end + end + + # Load the data at location _uri_ into the editor as a JSON document. + def load_location(uri) + data = read_data(uri) or return + @filename = nil + toplevel.display_status("Loaded data from '#{uri}'.") + display_title + data + end + + def parse_json(json) + check_pretty_printed(json) + if @encoding && !/^utf8$/i.match(@encoding) + json = JSON.iconv 'utf-8', @encoding, json + end + JSON::parse(json, :max_nesting => false, :create_additions => false) + end + private :parse_json + + # Read a JSON document from the file named _filename_, parse it into a + # ruby data structure, and return the data. + def read_data(filename) + open(filename) do |f| + json = f.read + return parse_json(json) + end + rescue => e + Editor.error_dialog(self, "Failed to parse JSON file: #{e}!") + return + end + + # Open a file selecton dialog, displaying _message_, and return the + # selected filename or nil, if no file was selected. + def select_file(message) + filename = nil + fs = FileSelection.new(message) + fs.set_modal(true) + @default_dir = File.join(Dir.pwd, '') unless @default_dir + fs.set_filename(@default_dir) + fs.set_transient_for(self) + fs.signal_connect(:destroy) { Gtk.main_quit } + fs.ok_button.signal_connect(:clicked) do + filename = fs.filename + @default_dir = File.join(File.dirname(filename), '') + fs.destroy + Gtk.main_quit + end + fs.cancel_button.signal_connect(:clicked) do + fs.destroy + Gtk.main_quit + end + fs.show_all + Gtk.main + filename + end + + # Ask for location URI a to load data from. Returns the URI as a string. + def ask_for_location + dialog = Dialog.new( + "Load data from location...", + nil, nil, + [ Stock::OK, Dialog::RESPONSE_ACCEPT ], + [ Stock::CANCEL, Dialog::RESPONSE_REJECT ] + ) + hbox = HBox.new(false, 5) + + hbox.pack_start(Label.new("Location:"), false) + hbox.pack_start(location_input = Entry.new) + location_input.width_chars = 60 + location_input.text = @location || '' + + dialog.vbox.pack_start(hbox, false) + + dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER) + dialog.show_all + dialog.run do |response| + if response == Dialog::RESPONSE_ACCEPT + return @location = location_input.text + end + end + return + ensure + dialog.destroy if dialog + end + end + + class << self + # Starts a JSON Editor. If a block was given, it yields + # to the JSON::Editor::MainWindow instance. + def start(encoding = 'utf8') # :yield: window + Gtk.init + @window = Editor::MainWindow.new(encoding) + @window.icon_list = [ Editor.fetch_icon('json') ] + yield @window if block_given? + @window.show_all + Gtk.main + end + + # Edit the string _json_ with encoding _encoding_ in the editor. + def edit(json, encoding = 'utf8') + start(encoding) do |window| + window.edit json + end + end + + attr_reader :window + end + end +end