taps 0.3.18 → 0.3.19.pre1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1369 @@
1
+ # To use the GUI JSON editor, start the edit_json.rb executable script. It
2
+ # requires ruby-gtk to be installed.
3
+
4
+ require 'gtk2'
5
+ require 'json'
6
+ require 'rbconfig'
7
+ require 'open-uri'
8
+
9
+ module JSON
10
+ module Editor
11
+ include Gtk
12
+
13
+ # Beginning of the editor window title
14
+ TITLE = 'JSON Editor'.freeze
15
+
16
+ # Columns constants
17
+ ICON_COL, TYPE_COL, CONTENT_COL = 0, 1, 2
18
+
19
+ # JSON primitive types (Containers)
20
+ CONTAINER_TYPES = %w[Array Hash].sort
21
+ # All JSON primitive types
22
+ ALL_TYPES = (%w[TrueClass FalseClass Numeric String NilClass] +
23
+ CONTAINER_TYPES).sort
24
+
25
+ # The Nodes necessary for the tree representation of a JSON document
26
+ ALL_NODES = (ALL_TYPES + %w[Key]).sort
27
+
28
+ DEFAULT_DIALOG_KEY_PRESS_HANDLER = lambda do |dialog, event|
29
+ case event.keyval
30
+ when Gdk::Keyval::GDK_Return
31
+ dialog.response Dialog::RESPONSE_ACCEPT
32
+ when Gdk::Keyval::GDK_Escape
33
+ dialog.response Dialog::RESPONSE_REJECT
34
+ end
35
+ end
36
+
37
+ # Returns the Gdk::Pixbuf of the icon named _name_ from the icon cache.
38
+ def Editor.fetch_icon(name)
39
+ @icon_cache ||= {}
40
+ unless @icon_cache.key?(name)
41
+ path = File.dirname(__FILE__)
42
+ @icon_cache[name] = Gdk::Pixbuf.new(File.join(path, name + '.xpm'))
43
+ end
44
+ @icon_cache[name]
45
+ end
46
+
47
+ # Opens an error dialog on top of _window_ showing the error message
48
+ # _text_.
49
+ def Editor.error_dialog(window, text)
50
+ dialog = MessageDialog.new(window, Dialog::MODAL,
51
+ MessageDialog::ERROR,
52
+ MessageDialog::BUTTONS_CLOSE, text)
53
+ dialog.show_all
54
+ dialog.run
55
+ rescue TypeError
56
+ dialog = MessageDialog.new(Editor.window, Dialog::MODAL,
57
+ MessageDialog::ERROR,
58
+ MessageDialog::BUTTONS_CLOSE, text)
59
+ dialog.show_all
60
+ dialog.run
61
+ ensure
62
+ dialog.destroy if dialog
63
+ end
64
+
65
+ # Opens a yes/no question dialog on top of _window_ showing the error
66
+ # message _text_. If yes was answered _true_ is returned, otherwise
67
+ # _false_.
68
+ def Editor.question_dialog(window, text)
69
+ dialog = MessageDialog.new(window, Dialog::MODAL,
70
+ MessageDialog::QUESTION,
71
+ MessageDialog::BUTTONS_YES_NO, text)
72
+ dialog.show_all
73
+ dialog.run do |response|
74
+ return Gtk::Dialog::RESPONSE_YES === response
75
+ end
76
+ ensure
77
+ dialog.destroy if dialog
78
+ end
79
+
80
+ # Convert the tree model starting from Gtk::TreeIter _iter_ into a Ruby
81
+ # data structure and return it.
82
+ def Editor.model2data(iter)
83
+ return nil if iter.nil?
84
+ case iter.type
85
+ when 'Hash'
86
+ hash = {}
87
+ iter.each { |c| hash[c.content] = Editor.model2data(c.first_child) }
88
+ hash
89
+ when 'Array'
90
+ array = Array.new(iter.n_children)
91
+ iter.each_with_index { |c, i| array[i] = Editor.model2data(c) }
92
+ array
93
+ when 'Key'
94
+ iter.content
95
+ when 'String'
96
+ iter.content
97
+ when 'Numeric'
98
+ content = iter.content
99
+ if /\./.match(content)
100
+ content.to_f
101
+ else
102
+ content.to_i
103
+ end
104
+ when 'TrueClass'
105
+ true
106
+ when 'FalseClass'
107
+ false
108
+ when 'NilClass'
109
+ nil
110
+ else
111
+ fail "Unknown type found in model: #{iter.type}"
112
+ end
113
+ end
114
+
115
+ # Convert the Ruby data structure _data_ into tree model data for Gtk and
116
+ # returns the whole model. If the parameter _model_ wasn't given a new
117
+ # Gtk::TreeStore is created as the model. The _parent_ parameter specifies
118
+ # the parent node (iter, Gtk:TreeIter instance) to which the data is
119
+ # appended, alternativeley the result of the yielded block is used as iter.
120
+ def Editor.data2model(data, model = nil, parent = nil)
121
+ model ||= TreeStore.new(Gdk::Pixbuf, String, String)
122
+ iter = if block_given?
123
+ yield model
124
+ else
125
+ model.append(parent)
126
+ end
127
+ case data
128
+ when Hash
129
+ iter.type = 'Hash'
130
+ data.sort.each do |key, value|
131
+ pair_iter = model.append(iter)
132
+ pair_iter.type = 'Key'
133
+ pair_iter.content = key.to_s
134
+ Editor.data2model(value, model, pair_iter)
135
+ end
136
+ when Array
137
+ iter.type = 'Array'
138
+ data.each do |value|
139
+ Editor.data2model(value, model, iter)
140
+ end
141
+ when Numeric
142
+ iter.type = 'Numeric'
143
+ iter.content = data.to_s
144
+ when String, true, false, nil
145
+ iter.type = data.class.name
146
+ iter.content = data.nil? ? 'null' : data.to_s
147
+ else
148
+ iter.type = 'String'
149
+ iter.content = data.to_s
150
+ end
151
+ model
152
+ end
153
+
154
+ # The Gtk::TreeIter class is reopened and some auxiliary methods are added.
155
+ class Gtk::TreeIter
156
+ include Enumerable
157
+
158
+ # Traverse each of this Gtk::TreeIter instance's children
159
+ # and yield to them.
160
+ def each
161
+ n_children.times { |i| yield nth_child(i) }
162
+ end
163
+
164
+ # Recursively traverse all nodes of this Gtk::TreeIter's subtree
165
+ # (including self) and yield to them.
166
+ def recursive_each(&block)
167
+ yield self
168
+ each do |i|
169
+ i.recursive_each(&block)
170
+ end
171
+ end
172
+
173
+ # Remove the subtree of this Gtk::TreeIter instance from the
174
+ # model _model_.
175
+ def remove_subtree(model)
176
+ while current = first_child
177
+ model.remove(current)
178
+ end
179
+ end
180
+
181
+ # Returns the type of this node.
182
+ def type
183
+ self[TYPE_COL]
184
+ end
185
+
186
+ # Sets the type of this node to _value_. This implies setting
187
+ # the respective icon accordingly.
188
+ def type=(value)
189
+ self[TYPE_COL] = value
190
+ self[ICON_COL] = Editor.fetch_icon(value)
191
+ end
192
+
193
+ # Returns the content of this node.
194
+ def content
195
+ self[CONTENT_COL]
196
+ end
197
+
198
+ # Sets the content of this node to _value_.
199
+ def content=(value)
200
+ self[CONTENT_COL] = value
201
+ end
202
+ end
203
+
204
+ # This module bundles some method, that can be used to create a menu. It
205
+ # should be included into the class in question.
206
+ module MenuExtension
207
+ include Gtk
208
+
209
+ # Creates a Menu, that includes MenuExtension. _treeview_ is the
210
+ # Gtk::TreeView, on which it operates.
211
+ def initialize(treeview)
212
+ @treeview = treeview
213
+ @menu = Menu.new
214
+ end
215
+
216
+ # Returns the Gtk::TreeView of this menu.
217
+ attr_reader :treeview
218
+
219
+ # Returns the menu.
220
+ attr_reader :menu
221
+
222
+ # Adds a Gtk::SeparatorMenuItem to this instance's #menu.
223
+ def add_separator
224
+ menu.append SeparatorMenuItem.new
225
+ end
226
+
227
+ # Adds a Gtk::MenuItem to this instance's #menu. _label_ is the label
228
+ # string, _klass_ is the item type, and _callback_ is the procedure, that
229
+ # is called if the _item_ is activated.
230
+ def add_item(label, keyval = nil, klass = MenuItem, &callback)
231
+ label = "#{label} (C-#{keyval.chr})" if keyval
232
+ item = klass.new(label)
233
+ item.signal_connect(:activate, &callback)
234
+ if keyval
235
+ self.signal_connect(:'key-press-event') do |item, event|
236
+ if event.state & Gdk::Window::ModifierType::CONTROL_MASK != 0 and
237
+ event.keyval == keyval
238
+ callback.call item
239
+ end
240
+ end
241
+ end
242
+ menu.append item
243
+ item
244
+ end
245
+
246
+ # This method should be implemented in subclasses to create the #menu of
247
+ # this instance. It has to be called after an instance of this class is
248
+ # created, to build the menu.
249
+ def create
250
+ raise NotImplementedError
251
+ end
252
+
253
+ def method_missing(*a, &b)
254
+ treeview.__send__(*a, &b)
255
+ end
256
+ end
257
+
258
+ # This class creates the popup menu, that opens when clicking onto the
259
+ # treeview.
260
+ class PopUpMenu
261
+ include MenuExtension
262
+
263
+ # Change the type or content of the selected node.
264
+ def change_node(item)
265
+ if current = selection.selected
266
+ parent = current.parent
267
+ old_type, old_content = current.type, current.content
268
+ if ALL_TYPES.include?(old_type)
269
+ @clipboard_data = Editor.model2data(current)
270
+ type, content = ask_for_element(parent, current.type,
271
+ current.content)
272
+ if type
273
+ current.type, current.content = type, content
274
+ current.remove_subtree(model)
275
+ toplevel.display_status("Changed a node in tree.")
276
+ window.change
277
+ end
278
+ else
279
+ toplevel.display_status(
280
+ "Cannot change node of type #{old_type} in tree!")
281
+ end
282
+ end
283
+ end
284
+
285
+ # Cut the selected node and its subtree, and save it into the
286
+ # clipboard.
287
+ def cut_node(item)
288
+ if current = selection.selected
289
+ if current and current.type == 'Key'
290
+ @clipboard_data = {
291
+ current.content => Editor.model2data(current.first_child)
292
+ }
293
+ else
294
+ @clipboard_data = Editor.model2data(current)
295
+ end
296
+ model.remove(current)
297
+ window.change
298
+ toplevel.display_status("Cut a node from tree.")
299
+ end
300
+ end
301
+
302
+ # Copy the selected node and its subtree, and save it into the
303
+ # clipboard.
304
+ def copy_node(item)
305
+ if current = selection.selected
306
+ if current and current.type == 'Key'
307
+ @clipboard_data = {
308
+ current.content => Editor.model2data(current.first_child)
309
+ }
310
+ else
311
+ @clipboard_data = Editor.model2data(current)
312
+ end
313
+ window.change
314
+ toplevel.display_status("Copied a node from tree.")
315
+ end
316
+ end
317
+
318
+ # Paste the data in the clipboard into the selected Array or Hash by
319
+ # appending it.
320
+ def paste_node_appending(item)
321
+ if current = selection.selected
322
+ if @clipboard_data
323
+ case current.type
324
+ when 'Array'
325
+ Editor.data2model(@clipboard_data, model, current)
326
+ expand_collapse(current)
327
+ when 'Hash'
328
+ if @clipboard_data.is_a? Hash
329
+ parent = current.parent
330
+ hash = Editor.model2data(current)
331
+ model.remove(current)
332
+ hash.update(@clipboard_data)
333
+ Editor.data2model(hash, model, parent)
334
+ if parent
335
+ expand_collapse(parent)
336
+ elsif @expanded
337
+ expand_all
338
+ end
339
+ window.change
340
+ else
341
+ toplevel.display_status(
342
+ "Cannot paste non-#{current.type} data into '#{current.type}'!")
343
+ end
344
+ else
345
+ toplevel.display_status(
346
+ "Cannot paste node below '#{current.type}'!")
347
+ end
348
+ else
349
+ toplevel.display_status("Nothing to paste in clipboard!")
350
+ end
351
+ else
352
+ toplevel.display_status("Append a node into the root first!")
353
+ end
354
+ end
355
+
356
+ # Paste the data in the clipboard into the selected Array inserting it
357
+ # before the selected element.
358
+ def paste_node_inserting_before(item)
359
+ if current = selection.selected
360
+ if @clipboard_data
361
+ parent = current.parent or return
362
+ parent_type = parent.type
363
+ if parent_type == 'Array'
364
+ selected_index = parent.each_with_index do |c, i|
365
+ break i if c == current
366
+ end
367
+ Editor.data2model(@clipboard_data, model, parent) do |m|
368
+ m.insert_before(parent, current)
369
+ end
370
+ expand_collapse(current)
371
+ toplevel.display_status("Inserted an element to " +
372
+ "'#{parent_type}' before index #{selected_index}.")
373
+ window.change
374
+ else
375
+ toplevel.display_status(
376
+ "Cannot insert node below '#{parent_type}'!")
377
+ end
378
+ else
379
+ toplevel.display_status("Nothing to paste in clipboard!")
380
+ end
381
+ else
382
+ toplevel.display_status("Append a node into the root first!")
383
+ end
384
+ end
385
+
386
+ # Append a new node to the selected Hash or Array.
387
+ def append_new_node(item)
388
+ if parent = selection.selected
389
+ parent_type = parent.type
390
+ case parent_type
391
+ when 'Hash'
392
+ key, type, content = ask_for_hash_pair(parent)
393
+ key or return
394
+ iter = create_node(parent, 'Key', key)
395
+ iter = create_node(iter, type, content)
396
+ toplevel.display_status(
397
+ "Added a (key, value)-pair to '#{parent_type}'.")
398
+ window.change
399
+ when 'Array'
400
+ type, content = ask_for_element(parent)
401
+ type or return
402
+ iter = create_node(parent, type, content)
403
+ window.change
404
+ toplevel.display_status("Appendend an element to '#{parent_type}'.")
405
+ else
406
+ toplevel.display_status("Cannot append to '#{parent_type}'!")
407
+ end
408
+ else
409
+ type, content = ask_for_element
410
+ type or return
411
+ iter = create_node(nil, type, content)
412
+ window.change
413
+ end
414
+ end
415
+
416
+ # Insert a new node into an Array before the selected element.
417
+ def insert_new_node(item)
418
+ if current = selection.selected
419
+ parent = current.parent or return
420
+ parent_parent = parent.parent
421
+ parent_type = parent.type
422
+ if parent_type == 'Array'
423
+ selected_index = parent.each_with_index do |c, i|
424
+ break i if c == current
425
+ end
426
+ type, content = ask_for_element(parent)
427
+ type or return
428
+ iter = model.insert_before(parent, current)
429
+ iter.type, iter.content = type, content
430
+ toplevel.display_status("Inserted an element to " +
431
+ "'#{parent_type}' before index #{selected_index}.")
432
+ window.change
433
+ else
434
+ toplevel.display_status(
435
+ "Cannot insert node below '#{parent_type}'!")
436
+ end
437
+ else
438
+ toplevel.display_status("Append a node into the root first!")
439
+ end
440
+ end
441
+
442
+ # Recursively collapse/expand a subtree starting from the selected node.
443
+ def collapse_expand(item)
444
+ if current = selection.selected
445
+ if row_expanded?(current.path)
446
+ collapse_row(current.path)
447
+ else
448
+ expand_row(current.path, true)
449
+ end
450
+ else
451
+ toplevel.display_status("Append a node into the root first!")
452
+ end
453
+ end
454
+
455
+ # Create the menu.
456
+ def create
457
+ add_item("Change node", ?n, &method(:change_node))
458
+ add_separator
459
+ add_item("Cut node", ?X, &method(:cut_node))
460
+ add_item("Copy node", ?C, &method(:copy_node))
461
+ add_item("Paste node (appending)", ?A, &method(:paste_node_appending))
462
+ add_item("Paste node (inserting before)", ?I,
463
+ &method(:paste_node_inserting_before))
464
+ add_separator
465
+ add_item("Append new node", ?a, &method(:append_new_node))
466
+ add_item("Insert new node before", ?i, &method(:insert_new_node))
467
+ add_separator
468
+ add_item("Collapse/Expand node (recursively)", ?e,
469
+ &method(:collapse_expand))
470
+
471
+ menu.show_all
472
+ signal_connect(:button_press_event) do |widget, event|
473
+ if event.kind_of? Gdk::EventButton and event.button == 3
474
+ menu.popup(nil, nil, event.button, event.time)
475
+ end
476
+ end
477
+ signal_connect(:popup_menu) do
478
+ menu.popup(nil, nil, 0, Gdk::Event::CURRENT_TIME)
479
+ end
480
+ end
481
+ end
482
+
483
+ # This class creates the File pulldown menu.
484
+ class FileMenu
485
+ include MenuExtension
486
+
487
+ # Clear the model and filename, but ask to save the JSON document, if
488
+ # unsaved changes have occured.
489
+ def new(item)
490
+ window.clear
491
+ end
492
+
493
+ # Open a file and load it into the editor. Ask to save the JSON document
494
+ # first, if unsaved changes have occured.
495
+ def open(item)
496
+ window.file_open
497
+ end
498
+
499
+ def open_location(item)
500
+ window.location_open
501
+ end
502
+
503
+ # Revert the current JSON document in the editor to the saved version.
504
+ def revert(item)
505
+ window.instance_eval do
506
+ @filename and file_open(@filename)
507
+ end
508
+ end
509
+
510
+ # Save the current JSON document.
511
+ def save(item)
512
+ window.file_save
513
+ end
514
+
515
+ # Save the current JSON document under the given filename.
516
+ def save_as(item)
517
+ window.file_save_as
518
+ end
519
+
520
+ # Quit the editor, after asking to save any unsaved changes first.
521
+ def quit(item)
522
+ window.quit
523
+ end
524
+
525
+ # Create the menu.
526
+ def create
527
+ title = MenuItem.new('File')
528
+ title.submenu = menu
529
+ add_item('New', &method(:new))
530
+ add_item('Open', ?o, &method(:open))
531
+ add_item('Open location', ?l, &method(:open_location))
532
+ add_item('Revert', &method(:revert))
533
+ add_separator
534
+ add_item('Save', ?s, &method(:save))
535
+ add_item('Save As', ?S, &method(:save_as))
536
+ add_separator
537
+ add_item('Quit', ?q, &method(:quit))
538
+ title
539
+ end
540
+ end
541
+
542
+ # This class creates the Edit pulldown menu.
543
+ class EditMenu
544
+ include MenuExtension
545
+
546
+ # Copy data from model into primary clipboard.
547
+ def copy(item)
548
+ data = Editor.model2data(model.iter_first)
549
+ json = JSON.pretty_generate(data, :max_nesting => false)
550
+ c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY)
551
+ c.text = json
552
+ end
553
+
554
+ # Copy json text from primary clipboard into model.
555
+ def paste(item)
556
+ c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY)
557
+ if json = c.wait_for_text
558
+ window.ask_save if @changed
559
+ begin
560
+ window.edit json
561
+ rescue JSON::ParserError
562
+ window.clear
563
+ end
564
+ end
565
+ end
566
+
567
+ # Find a string in all nodes' contents and select the found node in the
568
+ # treeview.
569
+ def find(item)
570
+ @search = ask_for_find_term(@search) or return
571
+ iter = model.get_iter('0') or return
572
+ iter.recursive_each do |i|
573
+ if @iter
574
+ if @iter != i
575
+ next
576
+ else
577
+ @iter = nil
578
+ next
579
+ end
580
+ elsif @search.match(i[CONTENT_COL])
581
+ set_cursor(i.path, nil, false)
582
+ @iter = i
583
+ break
584
+ end
585
+ end
586
+ end
587
+
588
+ # Repeat the last search given by #find.
589
+ def find_again(item)
590
+ @search or return
591
+ iter = model.get_iter('0')
592
+ iter.recursive_each do |i|
593
+ if @iter
594
+ if @iter != i
595
+ next
596
+ else
597
+ @iter = nil
598
+ next
599
+ end
600
+ elsif @search.match(i[CONTENT_COL])
601
+ set_cursor(i.path, nil, false)
602
+ @iter = i
603
+ break
604
+ end
605
+ end
606
+ end
607
+
608
+ # Sort (Reverse sort) all elements of the selected array by the given
609
+ # expression. _x_ is the element in question.
610
+ def sort(item)
611
+ if current = selection.selected
612
+ if current.type == 'Array'
613
+ parent = current.parent
614
+ ary = Editor.model2data(current)
615
+ order, reverse = ask_for_order
616
+ order or return
617
+ begin
618
+ block = eval "lambda { |x| #{order} }"
619
+ if reverse
620
+ ary.sort! { |a,b| block[b] <=> block[a] }
621
+ else
622
+ ary.sort! { |a,b| block[a] <=> block[b] }
623
+ end
624
+ rescue => e
625
+ Editor.error_dialog(self, "Failed to sort Array with #{order}: #{e}!")
626
+ else
627
+ Editor.data2model(ary, model, parent) do |m|
628
+ m.insert_before(parent, current)
629
+ end
630
+ model.remove(current)
631
+ expand_collapse(parent)
632
+ window.change
633
+ toplevel.display_status("Array has been sorted.")
634
+ end
635
+ else
636
+ toplevel.display_status("Only Array nodes can be sorted!")
637
+ end
638
+ else
639
+ toplevel.display_status("Select an Array to sort first!")
640
+ end
641
+ end
642
+
643
+ # Create the menu.
644
+ def create
645
+ title = MenuItem.new('Edit')
646
+ title.submenu = menu
647
+ add_item('Copy', ?c, &method(:copy))
648
+ add_item('Paste', ?v, &method(:paste))
649
+ add_separator
650
+ add_item('Find', ?f, &method(:find))
651
+ add_item('Find Again', ?g, &method(:find_again))
652
+ add_separator
653
+ add_item('Sort', ?S, &method(:sort))
654
+ title
655
+ end
656
+ end
657
+
658
+ class OptionsMenu
659
+ include MenuExtension
660
+
661
+ # Collapse/Expand all nodes by default.
662
+ def collapsed_nodes(item)
663
+ if expanded
664
+ self.expanded = false
665
+ collapse_all
666
+ else
667
+ self.expanded = true
668
+ expand_all
669
+ end
670
+ end
671
+
672
+ # Toggle pretty saving mode on/off.
673
+ def pretty_saving(item)
674
+ @pretty_item.toggled
675
+ window.change
676
+ end
677
+
678
+ attr_reader :pretty_item
679
+
680
+ # Create the menu.
681
+ def create
682
+ title = MenuItem.new('Options')
683
+ title.submenu = menu
684
+ add_item('Collapsed nodes', nil, CheckMenuItem, &method(:collapsed_nodes))
685
+ @pretty_item = add_item('Pretty saving', nil, CheckMenuItem,
686
+ &method(:pretty_saving))
687
+ @pretty_item.active = true
688
+ window.unchange
689
+ title
690
+ end
691
+ end
692
+
693
+ # This class inherits from Gtk::TreeView, to configure it and to add a lot
694
+ # of behaviour to it.
695
+ class JSONTreeView < Gtk::TreeView
696
+ include Gtk
697
+
698
+ # Creates a JSONTreeView instance, the parameter _window_ is
699
+ # a MainWindow instance and used for self delegation.
700
+ def initialize(window)
701
+ @window = window
702
+ super(TreeStore.new(Gdk::Pixbuf, String, String))
703
+ self.selection.mode = SELECTION_BROWSE
704
+
705
+ @expanded = false
706
+ self.headers_visible = false
707
+ add_columns
708
+ add_popup_menu
709
+ end
710
+
711
+ # Returns the MainWindow instance of this JSONTreeView.
712
+ attr_reader :window
713
+
714
+ # Returns true, if nodes are autoexpanding, false otherwise.
715
+ attr_accessor :expanded
716
+
717
+ private
718
+
719
+ def add_columns
720
+ cell = CellRendererPixbuf.new
721
+ column = TreeViewColumn.new('Icon', cell,
722
+ 'pixbuf' => ICON_COL
723
+ )
724
+ append_column(column)
725
+
726
+ cell = CellRendererText.new
727
+ column = TreeViewColumn.new('Type', cell,
728
+ 'text' => TYPE_COL
729
+ )
730
+ append_column(column)
731
+
732
+ cell = CellRendererText.new
733
+ cell.editable = true
734
+ column = TreeViewColumn.new('Content', cell,
735
+ 'text' => CONTENT_COL
736
+ )
737
+ cell.signal_connect(:edited, &method(:cell_edited))
738
+ append_column(column)
739
+ end
740
+
741
+ def unify_key(iter, key)
742
+ return unless iter.type == 'Key'
743
+ parent = iter.parent
744
+ if parent.any? { |c| c != iter and c.content == key }
745
+ old_key = key
746
+ i = 0
747
+ begin
748
+ key = sprintf("%s.%d", old_key, i += 1)
749
+ end while parent.any? { |c| c != iter and c.content == key }
750
+ end
751
+ iter.content = key
752
+ end
753
+
754
+ def cell_edited(cell, path, value)
755
+ iter = model.get_iter(path)
756
+ case iter.type
757
+ when 'Key'
758
+ unify_key(iter, value)
759
+ toplevel.display_status('Key has been changed.')
760
+ when 'FalseClass'
761
+ value.downcase!
762
+ if value == 'true'
763
+ iter.type, iter.content = 'TrueClass', 'true'
764
+ end
765
+ when 'TrueClass'
766
+ value.downcase!
767
+ if value == 'false'
768
+ iter.type, iter.content = 'FalseClass', 'false'
769
+ end
770
+ when 'Numeric'
771
+ iter.content =
772
+ if value == 'Infinity'
773
+ value
774
+ else
775
+ (Integer(value) rescue Float(value) rescue 0).to_s
776
+ end
777
+ when 'String'
778
+ iter.content = value
779
+ when 'Hash', 'Array'
780
+ return
781
+ else
782
+ fail "Unknown type found in model: #{iter.type}"
783
+ end
784
+ window.change
785
+ end
786
+
787
+ def configure_value(value, type)
788
+ value.editable = false
789
+ case type
790
+ when 'Array', 'Hash'
791
+ value.text = ''
792
+ when 'TrueClass'
793
+ value.text = 'true'
794
+ when 'FalseClass'
795
+ value.text = 'false'
796
+ when 'NilClass'
797
+ value.text = 'null'
798
+ when 'Numeric', 'String'
799
+ value.text ||= ''
800
+ value.editable = true
801
+ else
802
+ raise ArgumentError, "unknown type '#{type}' encountered"
803
+ end
804
+ end
805
+
806
+ def add_popup_menu
807
+ menu = PopUpMenu.new(self)
808
+ menu.create
809
+ end
810
+
811
+ public
812
+
813
+ # Create a _type_ node with content _content_, and add it to _parent_
814
+ # in the model. If _parent_ is nil, create a new model and put it into
815
+ # the editor treeview.
816
+ def create_node(parent, type, content)
817
+ iter = if parent
818
+ model.append(parent)
819
+ else
820
+ new_model = Editor.data2model(nil)
821
+ toplevel.view_new_model(new_model)
822
+ new_model.iter_first
823
+ end
824
+ iter.type, iter.content = type, content
825
+ expand_collapse(parent) if parent
826
+ iter
827
+ end
828
+
829
+ # Ask for a hash key, value pair to be added to the Hash node _parent_.
830
+ def ask_for_hash_pair(parent)
831
+ key_input = type_input = value_input = nil
832
+
833
+ dialog = Dialog.new("New (key, value) pair for Hash", nil, nil,
834
+ [ Stock::OK, Dialog::RESPONSE_ACCEPT ],
835
+ [ Stock::CANCEL, Dialog::RESPONSE_REJECT ]
836
+ )
837
+ dialog.width_request = 640
838
+
839
+ hbox = HBox.new(false, 5)
840
+ hbox.pack_start(Label.new("Key:"), false)
841
+ hbox.pack_start(key_input = Entry.new)
842
+ key_input.text = @key || ''
843
+ dialog.vbox.pack_start(hbox, false)
844
+ key_input.signal_connect(:activate) do
845
+ if parent.any? { |c| c.content == key_input.text }
846
+ toplevel.display_status('Key already exists in Hash!')
847
+ key_input.text = ''
848
+ else
849
+ toplevel.display_status('Key has been changed.')
850
+ end
851
+ end
852
+
853
+ hbox = HBox.new(false, 5)
854
+ hbox.pack_start(Label.new("Type:"), false)
855
+ hbox.pack_start(type_input = ComboBox.new(true))
856
+ ALL_TYPES.each { |t| type_input.append_text(t) }
857
+ type_input.active = @type || 0
858
+ dialog.vbox.pack_start(hbox, false)
859
+
860
+ type_input.signal_connect(:changed) do
861
+ value_input.editable = false
862
+ case ALL_TYPES[type_input.active]
863
+ when 'Array', 'Hash'
864
+ value_input.text = ''
865
+ when 'TrueClass'
866
+ value_input.text = 'true'
867
+ when 'FalseClass'
868
+ value_input.text = 'false'
869
+ when 'NilClass'
870
+ value_input.text = 'null'
871
+ else
872
+ value_input.text = ''
873
+ value_input.editable = true
874
+ end
875
+ end
876
+
877
+ hbox = HBox.new(false, 5)
878
+ hbox.pack_start(Label.new("Value:"), false)
879
+ hbox.pack_start(value_input = Entry.new)
880
+ value_input.width_chars = 60
881
+ value_input.text = @value || ''
882
+ dialog.vbox.pack_start(hbox, false)
883
+
884
+ dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER)
885
+ dialog.show_all
886
+ self.focus = dialog
887
+ dialog.run do |response|
888
+ if response == Dialog::RESPONSE_ACCEPT
889
+ @key = key_input.text
890
+ type = ALL_TYPES[@type = type_input.active]
891
+ content = value_input.text
892
+ return @key, type, content
893
+ end
894
+ end
895
+ return
896
+ ensure
897
+ dialog.destroy
898
+ end
899
+
900
+ # Ask for an element to be appended _parent_.
901
+ def ask_for_element(parent = nil, default_type = nil, value_text = @content)
902
+ type_input = value_input = nil
903
+
904
+ dialog = Dialog.new(
905
+ "New element into #{parent ? parent.type : 'root'}",
906
+ nil, nil,
907
+ [ Stock::OK, Dialog::RESPONSE_ACCEPT ],
908
+ [ Stock::CANCEL, Dialog::RESPONSE_REJECT ]
909
+ )
910
+ hbox = HBox.new(false, 5)
911
+ hbox.pack_start(Label.new("Type:"), false)
912
+ hbox.pack_start(type_input = ComboBox.new(true))
913
+ default_active = 0
914
+ types = parent ? ALL_TYPES : CONTAINER_TYPES
915
+ types.each_with_index do |t, i|
916
+ type_input.append_text(t)
917
+ if t == default_type
918
+ default_active = i
919
+ end
920
+ end
921
+ type_input.active = default_active
922
+ dialog.vbox.pack_start(hbox, false)
923
+ type_input.signal_connect(:changed) do
924
+ configure_value(value_input, types[type_input.active])
925
+ end
926
+
927
+ hbox = HBox.new(false, 5)
928
+ hbox.pack_start(Label.new("Value:"), false)
929
+ hbox.pack_start(value_input = Entry.new)
930
+ value_input.width_chars = 60
931
+ value_input.text = value_text if value_text
932
+ configure_value(value_input, types[type_input.active])
933
+
934
+ dialog.vbox.pack_start(hbox, false)
935
+
936
+ dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER)
937
+ dialog.show_all
938
+ self.focus = dialog
939
+ dialog.run do |response|
940
+ if response == Dialog::RESPONSE_ACCEPT
941
+ type = types[type_input.active]
942
+ @content = case type
943
+ when 'Numeric'
944
+ if (t = value_input.text) == 'Infinity'
945
+ 1 / 0.0
946
+ else
947
+ Integer(t) rescue Float(t) rescue 0
948
+ end
949
+ else
950
+ value_input.text
951
+ end.to_s
952
+ return type, @content
953
+ end
954
+ end
955
+ return
956
+ ensure
957
+ dialog.destroy if dialog
958
+ end
959
+
960
+ # Ask for an order criteria for sorting, using _x_ for the element in
961
+ # question. Returns the order criterium, and true/false for reverse
962
+ # sorting.
963
+ def ask_for_order
964
+ dialog = Dialog.new(
965
+ "Give an order criterium for 'x'.",
966
+ nil, nil,
967
+ [ Stock::OK, Dialog::RESPONSE_ACCEPT ],
968
+ [ Stock::CANCEL, Dialog::RESPONSE_REJECT ]
969
+ )
970
+ hbox = HBox.new(false, 5)
971
+
972
+ hbox.pack_start(Label.new("Order:"), false)
973
+ hbox.pack_start(order_input = Entry.new)
974
+ order_input.text = @order || 'x'
975
+ order_input.width_chars = 60
976
+
977
+ hbox.pack_start(reverse_checkbox = CheckButton.new('Reverse'), false)
978
+
979
+ dialog.vbox.pack_start(hbox, false)
980
+
981
+ dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER)
982
+ dialog.show_all
983
+ self.focus = dialog
984
+ dialog.run do |response|
985
+ if response == Dialog::RESPONSE_ACCEPT
986
+ return @order = order_input.text, reverse_checkbox.active?
987
+ end
988
+ end
989
+ return
990
+ ensure
991
+ dialog.destroy if dialog
992
+ end
993
+
994
+ # Ask for a find term to search for in the tree. Returns the term as a
995
+ # string.
996
+ def ask_for_find_term(search = nil)
997
+ dialog = Dialog.new(
998
+ "Find a node matching regex in tree.",
999
+ nil, nil,
1000
+ [ Stock::OK, Dialog::RESPONSE_ACCEPT ],
1001
+ [ Stock::CANCEL, Dialog::RESPONSE_REJECT ]
1002
+ )
1003
+ hbox = HBox.new(false, 5)
1004
+
1005
+ hbox.pack_start(Label.new("Regex:"), false)
1006
+ hbox.pack_start(regex_input = Entry.new)
1007
+ hbox.pack_start(icase_checkbox = CheckButton.new('Icase'), false)
1008
+ regex_input.width_chars = 60
1009
+ if search
1010
+ regex_input.text = search.source
1011
+ icase_checkbox.active = search.casefold?
1012
+ end
1013
+
1014
+ dialog.vbox.pack_start(hbox, false)
1015
+
1016
+ dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER)
1017
+ dialog.show_all
1018
+ self.focus = dialog
1019
+ dialog.run do |response|
1020
+ if response == Dialog::RESPONSE_ACCEPT
1021
+ begin
1022
+ return Regexp.new(regex_input.text, icase_checkbox.active? ? Regexp::IGNORECASE : 0)
1023
+ rescue => e
1024
+ Editor.error_dialog(self, "Evaluation of regex /#{regex_input.text}/ failed: #{e}!")
1025
+ return
1026
+ end
1027
+ end
1028
+ end
1029
+ return
1030
+ ensure
1031
+ dialog.destroy if dialog
1032
+ end
1033
+
1034
+ # Expand or collapse row pointed to by _iter_ according
1035
+ # to the #expanded attribute.
1036
+ def expand_collapse(iter)
1037
+ if expanded
1038
+ expand_row(iter.path, true)
1039
+ else
1040
+ collapse_row(iter.path)
1041
+ end
1042
+ end
1043
+ end
1044
+
1045
+ # The editor main window
1046
+ class MainWindow < Gtk::Window
1047
+ include Gtk
1048
+
1049
+ def initialize(encoding)
1050
+ @changed = false
1051
+ @encoding = encoding
1052
+ super(TOPLEVEL)
1053
+ display_title
1054
+ set_default_size(800, 600)
1055
+ signal_connect(:delete_event) { quit }
1056
+
1057
+ vbox = VBox.new(false, 0)
1058
+ add(vbox)
1059
+ #vbox.border_width = 0
1060
+
1061
+ @treeview = JSONTreeView.new(self)
1062
+ @treeview.signal_connect(:'cursor-changed') do
1063
+ display_status('')
1064
+ end
1065
+
1066
+ menu_bar = create_menu_bar
1067
+ vbox.pack_start(menu_bar, false, false, 0)
1068
+
1069
+ sw = ScrolledWindow.new(nil, nil)
1070
+ sw.shadow_type = SHADOW_ETCHED_IN
1071
+ sw.set_policy(POLICY_AUTOMATIC, POLICY_AUTOMATIC)
1072
+ vbox.pack_start(sw, true, true, 0)
1073
+ sw.add(@treeview)
1074
+
1075
+ @status_bar = Statusbar.new
1076
+ vbox.pack_start(@status_bar, false, false, 0)
1077
+
1078
+ @filename ||= nil
1079
+ if @filename
1080
+ data = read_data(@filename)
1081
+ view_new_model Editor.data2model(data)
1082
+ end
1083
+
1084
+ signal_connect(:button_release_event) do |_,event|
1085
+ if event.button == 2
1086
+ c = Gtk::Clipboard.get(Gdk::Selection::PRIMARY)
1087
+ if url = c.wait_for_text
1088
+ location_open url
1089
+ end
1090
+ false
1091
+ else
1092
+ true
1093
+ end
1094
+ end
1095
+ end
1096
+
1097
+ # Creates the menu bar with the pulldown menus and returns it.
1098
+ def create_menu_bar
1099
+ menu_bar = MenuBar.new
1100
+ @file_menu = FileMenu.new(@treeview)
1101
+ menu_bar.append @file_menu.create
1102
+ @edit_menu = EditMenu.new(@treeview)
1103
+ menu_bar.append @edit_menu.create
1104
+ @options_menu = OptionsMenu.new(@treeview)
1105
+ menu_bar.append @options_menu.create
1106
+ menu_bar
1107
+ end
1108
+
1109
+ # Sets editor status to changed, to indicate that the edited data
1110
+ # containts unsaved changes.
1111
+ def change
1112
+ @changed = true
1113
+ display_title
1114
+ end
1115
+
1116
+ # Sets editor status to unchanged, to indicate that the edited data
1117
+ # doesn't containt unsaved changes.
1118
+ def unchange
1119
+ @changed = false
1120
+ display_title
1121
+ end
1122
+
1123
+ # Puts a new model _model_ into the Gtk::TreeView to be edited.
1124
+ def view_new_model(model)
1125
+ @treeview.model = model
1126
+ @treeview.expanded = true
1127
+ @treeview.expand_all
1128
+ unchange
1129
+ end
1130
+
1131
+ # Displays _text_ in the status bar.
1132
+ def display_status(text)
1133
+ @cid ||= nil
1134
+ @status_bar.pop(@cid) if @cid
1135
+ @cid = @status_bar.get_context_id('dummy')
1136
+ @status_bar.push(@cid, text)
1137
+ end
1138
+
1139
+ # Opens a dialog, asking, if changes should be saved to a file.
1140
+ def ask_save
1141
+ if Editor.question_dialog(self,
1142
+ "Unsaved changes to JSON model. Save?")
1143
+ if @filename
1144
+ file_save
1145
+ else
1146
+ file_save_as
1147
+ end
1148
+ end
1149
+ end
1150
+
1151
+ # Quit this editor, that is, leave this editor's main loop.
1152
+ def quit
1153
+ ask_save if @changed
1154
+ if Gtk.main_level > 0
1155
+ destroy
1156
+ Gtk.main_quit
1157
+ end
1158
+ nil
1159
+ end
1160
+
1161
+ # Display the new title according to the editor's current state.
1162
+ def display_title
1163
+ title = TITLE.dup
1164
+ title << ": #@filename" if @filename
1165
+ title << " *" if @changed
1166
+ self.title = title
1167
+ end
1168
+
1169
+ # Clear the current model, after asking to save all unsaved changes.
1170
+ def clear
1171
+ ask_save if @changed
1172
+ @filename = nil
1173
+ self.view_new_model nil
1174
+ end
1175
+
1176
+ def check_pretty_printed(json)
1177
+ pretty = !!((nl_index = json.index("\n")) && nl_index != json.size - 1)
1178
+ @options_menu.pretty_item.active = pretty
1179
+ end
1180
+ private :check_pretty_printed
1181
+
1182
+ # Open the data at the location _uri_, if given. Otherwise open a dialog
1183
+ # to ask for the _uri_.
1184
+ def location_open(uri = nil)
1185
+ uri = ask_for_location unless uri
1186
+ uri or return
1187
+ ask_save if @changed
1188
+ data = load_location(uri) or return
1189
+ view_new_model Editor.data2model(data)
1190
+ end
1191
+
1192
+ # Open the file _filename_ or call the #select_file method to ask for a
1193
+ # filename.
1194
+ def file_open(filename = nil)
1195
+ filename = select_file('Open as a JSON file') unless filename
1196
+ data = load_file(filename) or return
1197
+ view_new_model Editor.data2model(data)
1198
+ end
1199
+
1200
+ # Edit the string _json_ in the editor.
1201
+ def edit(json)
1202
+ if json.respond_to? :read
1203
+ json = json.read
1204
+ end
1205
+ data = parse_json json
1206
+ view_new_model Editor.data2model(data)
1207
+ end
1208
+
1209
+ # Save the current file.
1210
+ def file_save
1211
+ if @filename
1212
+ store_file(@filename)
1213
+ else
1214
+ file_save_as
1215
+ end
1216
+ end
1217
+
1218
+ # Save the current file as the filename
1219
+ def file_save_as
1220
+ filename = select_file('Save as a JSON file')
1221
+ store_file(filename)
1222
+ end
1223
+
1224
+ # Store the current JSON document to _path_.
1225
+ def store_file(path)
1226
+ if path
1227
+ data = Editor.model2data(@treeview.model.iter_first)
1228
+ File.open(path + '.tmp', 'wb') do |output|
1229
+ data or break
1230
+ if @options_menu.pretty_item.active?
1231
+ output.puts JSON.pretty_generate(data, :max_nesting => false)
1232
+ else
1233
+ output.write JSON.generate(data, :max_nesting => false)
1234
+ end
1235
+ end
1236
+ File.rename path + '.tmp', path
1237
+ @filename = path
1238
+ toplevel.display_status("Saved data to '#@filename'.")
1239
+ unchange
1240
+ end
1241
+ rescue SystemCallError => e
1242
+ Editor.error_dialog(self, "Failed to store JSON file: #{e}!")
1243
+ end
1244
+
1245
+ # Load the file named _filename_ into the editor as a JSON document.
1246
+ def load_file(filename)
1247
+ if filename
1248
+ if File.directory?(filename)
1249
+ Editor.error_dialog(self, "Try to select a JSON file!")
1250
+ nil
1251
+ else
1252
+ @filename = filename
1253
+ if data = read_data(filename)
1254
+ toplevel.display_status("Loaded data from '#@filename'.")
1255
+ end
1256
+ display_title
1257
+ data
1258
+ end
1259
+ end
1260
+ end
1261
+
1262
+ # Load the data at location _uri_ into the editor as a JSON document.
1263
+ def load_location(uri)
1264
+ data = read_data(uri) or return
1265
+ @filename = nil
1266
+ toplevel.display_status("Loaded data from '#{uri}'.")
1267
+ display_title
1268
+ data
1269
+ end
1270
+
1271
+ def parse_json(json)
1272
+ check_pretty_printed(json)
1273
+ if @encoding && !/^utf8$/i.match(@encoding)
1274
+ json = JSON.iconv 'utf-8', @encoding, json
1275
+ end
1276
+ JSON::parse(json, :max_nesting => false, :create_additions => false)
1277
+ end
1278
+ private :parse_json
1279
+
1280
+ # Read a JSON document from the file named _filename_, parse it into a
1281
+ # ruby data structure, and return the data.
1282
+ def read_data(filename)
1283
+ open(filename) do |f|
1284
+ json = f.read
1285
+ return parse_json(json)
1286
+ end
1287
+ rescue => e
1288
+ Editor.error_dialog(self, "Failed to parse JSON file: #{e}!")
1289
+ return
1290
+ end
1291
+
1292
+ # Open a file selecton dialog, displaying _message_, and return the
1293
+ # selected filename or nil, if no file was selected.
1294
+ def select_file(message)
1295
+ filename = nil
1296
+ fs = FileSelection.new(message)
1297
+ fs.set_modal(true)
1298
+ @default_dir = File.join(Dir.pwd, '') unless @default_dir
1299
+ fs.set_filename(@default_dir)
1300
+ fs.set_transient_for(self)
1301
+ fs.signal_connect(:destroy) { Gtk.main_quit }
1302
+ fs.ok_button.signal_connect(:clicked) do
1303
+ filename = fs.filename
1304
+ @default_dir = File.join(File.dirname(filename), '')
1305
+ fs.destroy
1306
+ Gtk.main_quit
1307
+ end
1308
+ fs.cancel_button.signal_connect(:clicked) do
1309
+ fs.destroy
1310
+ Gtk.main_quit
1311
+ end
1312
+ fs.show_all
1313
+ Gtk.main
1314
+ filename
1315
+ end
1316
+
1317
+ # Ask for location URI a to load data from. Returns the URI as a string.
1318
+ def ask_for_location
1319
+ dialog = Dialog.new(
1320
+ "Load data from location...",
1321
+ nil, nil,
1322
+ [ Stock::OK, Dialog::RESPONSE_ACCEPT ],
1323
+ [ Stock::CANCEL, Dialog::RESPONSE_REJECT ]
1324
+ )
1325
+ hbox = HBox.new(false, 5)
1326
+
1327
+ hbox.pack_start(Label.new("Location:"), false)
1328
+ hbox.pack_start(location_input = Entry.new)
1329
+ location_input.width_chars = 60
1330
+ location_input.text = @location || ''
1331
+
1332
+ dialog.vbox.pack_start(hbox, false)
1333
+
1334
+ dialog.signal_connect(:'key-press-event', &DEFAULT_DIALOG_KEY_PRESS_HANDLER)
1335
+ dialog.show_all
1336
+ dialog.run do |response|
1337
+ if response == Dialog::RESPONSE_ACCEPT
1338
+ return @location = location_input.text
1339
+ end
1340
+ end
1341
+ return
1342
+ ensure
1343
+ dialog.destroy if dialog
1344
+ end
1345
+ end
1346
+
1347
+ class << self
1348
+ # Starts a JSON Editor. If a block was given, it yields
1349
+ # to the JSON::Editor::MainWindow instance.
1350
+ def start(encoding = 'utf8') # :yield: window
1351
+ Gtk.init
1352
+ @window = Editor::MainWindow.new(encoding)
1353
+ @window.icon_list = [ Editor.fetch_icon('json') ]
1354
+ yield @window if block_given?
1355
+ @window.show_all
1356
+ Gtk.main
1357
+ end
1358
+
1359
+ # Edit the string _json_ with encoding _encoding_ in the editor.
1360
+ def edit(json, encoding = 'utf8')
1361
+ start(encoding) do |window|
1362
+ window.edit json
1363
+ end
1364
+ end
1365
+
1366
+ attr_reader :window
1367
+ end
1368
+ end
1369
+ end