tkri 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. data/README +11 -0
  2. data/bin/tkri +9 -0
  3. data/lib/tkri.rb +585 -0
  4. metadata +64 -0
data/README ADDED
@@ -0,0 +1,11 @@
1
+ == tkri
2
+
3
+ tkri is a GUI front-end to the 'ri', or 'qri', executables. It displays
4
+ their output in a window where each word is "hyperlinked".
5
+
6
+ == Use
7
+
8
+ Launch it by typing 'tkri' at the operating system prompt. You can
9
+ provide a starting topic as an argument on the command line. Inside the
10
+ application, type the topic you wish to go to at the address bar, or
11
+ click on a word in the main text.
data/bin/tkri ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'tkri'
4
+
5
+ app = Tkri::App.new
6
+ ARGV.each do |topic|
7
+ app.go topic, true
8
+ end
9
+ app.run
data/lib/tkri.rb ADDED
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/ruby
2
+ # $Id$
3
+
4
+ # @file
5
+ # A GUI front-end to RI.
6
+ #
7
+ # @author Mooffie <mooffie@gmail.com>
8
+
9
+ require 'tk'
10
+
11
+ module Tkri
12
+
13
+ COMMAND = {
14
+ # Each platform may use a different command. The commands are indexed by any
15
+ # substring in RUBY_PLATFORM. If none matches the platform, the 'default' key
16
+ # is used.
17
+ 'default' => 'qri -f ansi "%s"',
18
+ /linux/ => 'qri -f ansi "%s" 2>&1',
19
+ /darwin/ => 'qri -f ansi "%s" 2>&1',
20
+ }
21
+
22
+ TAGS = {
23
+ 'bold' => { :foreground => 'blue' },
24
+ 'italic' => { :foreground => '#6b8e23' }, # greenish
25
+ 'code' => { :foreground => '#1874cd' }, # blueish
26
+ 'header2' => { :background => '#ffe4b5' },
27
+ 'header3' => { :background => '#ffe4b5' },
28
+ 'keyword' => { :foreground => 'red' },
29
+ 'search' => { :background => 'yellow' },
30
+ 'hidden' => { :elide => true },
31
+ }
32
+
33
+ HistoryEntry = Struct.new(:topic, :cursor, :yview)
34
+
35
+ # A Tab encapsulates an @address box, where you type the topic to go to; a "Go"
36
+ # button; and an @info box in which to show the topic.
37
+ class Tab < TkFrame
38
+
39
+ attr_reader :topic
40
+
41
+ def initialize(tk_parent, app, configuration = {})
42
+ @app = app
43
+ super(tk_parent, configuration)
44
+
45
+ #
46
+ # The address bar
47
+ #
48
+ addressbar = TkFrame.new(self) { |ab|
49
+ pack :side => 'top', :fill => 'x'
50
+ TkButton.new(ab) {
51
+ text 'Go'
52
+ command { app.go }
53
+ pack :side => 'right'
54
+ }
55
+ }
56
+ @address = TkEntry.new(addressbar) {
57
+ configure :font => 'courier', :width => 30
58
+ pack :side => 'left', :expand => true, :fill => 'both'
59
+ }
60
+
61
+ #
62
+ # The info box, where the main text is displayed.
63
+ #
64
+ _frame = self
65
+ @info = TkText.new(self) { |t|
66
+ pack :side => 'left', :fill => 'both', :expand => true
67
+ TkScrollbar.new(_frame) { |s|
68
+ pack :side => 'right', :fill => 'y'
69
+ command { |*args| t.yview *args }
70
+ t.yscrollcommand { |first,last| s.set first,last }
71
+ }
72
+ }
73
+
74
+ TAGS.each do |name, conf|
75
+ @info.tag_configure(name, conf)
76
+ end
77
+
78
+ # Key and mouse bindings
79
+ @address.bind('Key-Return') { go }
80
+ @address.bind('Key-KP_Enter') { go }
81
+ @info.bind('ButtonRelease-1') { |e| go_xy_word(e.x, e.y) }
82
+ # If I make the following "ButtonRelease-2" instead, the <PasteSelection>
83
+ # cancellation that follows won't work. Strange.
84
+ @info.bind('Button-2') { |e| go_xy_word(e.x, e.y, true) }
85
+ @info.bind('ButtonRelease-3') { |e| back }
86
+ @info.bind('Key-BackSpace') { |e| back; break }
87
+
88
+ # Tk doesn't support "read-only" text widget. We "disable" the following
89
+ # keys explicitly (using 'break'). We also forward these search keys to
90
+ # @app.
91
+ @info.bind('<PasteSelection>') { break }
92
+ @info.bind('Key-slash') { @app.search; break }
93
+ @info.bind('Key-n') { @app.search_next; break }
94
+ @info.bind('Key-N') { @app.search_prev; break }
95
+
96
+ @history = []
97
+ end
98
+
99
+ # Moves the keyboard focus to the address box. Also, selects all the
100
+ # text, like modern GUIs do.
101
+ def focus_address
102
+ @address.selection_range('0', 'end')
103
+ @address.icursor = 'end'
104
+ @address.focus
105
+ end
106
+
107
+ # Finds the next occurrence of a word.
108
+ def search_next_word(word)
109
+ @info.focus
110
+ highlight_word word
111
+ cursor = @info.index('insert')
112
+ pos = @info.search_with_length(Regexp.new(Regexp::quote(word), Regexp::IGNORECASE), cursor + ' 1 chars')[0]
113
+ if pos.empty?
114
+ @app.status = 'Cannot find "%s"' % word
115
+ else
116
+ set_cursor(pos)
117
+ if @info.compare(cursor, '>=', pos)
118
+ @app.status = 'Continuing search at top'
119
+ else
120
+ @app.status = ''
121
+ end
122
+ end
123
+ end
124
+
125
+ # Finds the previous occurrence of a word.
126
+ def search_prev_word(word)
127
+ @info.focus
128
+ highlight_word word
129
+ cursor = @info.index('insert')
130
+ pos = @info.rsearch_with_length(Regexp.new(Regexp::quote(word), Regexp::IGNORECASE), cursor)[0]
131
+ if pos.empty?
132
+ @app.status = 'Cannot find "%s"' % word
133
+ else
134
+ set_cursor(pos)
135
+ if @info.compare(cursor, '<=', pos)
136
+ @app.status = 'Continuing search at bottom'
137
+ else
138
+ @app.status = ''
139
+ end
140
+ end
141
+ end
142
+
143
+ # Highlights a word in the text. Used by the search methods.
144
+ def highlight_word(word)
145
+ return if word.empty?
146
+ @info.tag_remove('search', '1.0', 'end')
147
+ _highlight_word(word.downcase, @info.get('1.0', 'end').downcase, 'search')
148
+ end
149
+
150
+ def _highlight_word(word_or_regexp, text, tag_name)
151
+ pos = -1
152
+ while pos = text.index(word_or_regexp, pos + 1)
153
+ length = (word_or_regexp.is_a? String) ? word_or_regexp.length : $&.length
154
+ @info.tag_add(tag_name, '1.0 + %d chars' % pos,
155
+ '1.0 + %d chars' % (pos + length))
156
+ end
157
+ end
158
+
159
+ # Navigate to the topic mentioned under the mouse cursor (given by x,y
160
+ # coordinates)
161
+ def go_xy_word(x, y, newtab=false)
162
+ if not newtab and not @info.tag_ranges('sel').empty?
163
+ # We don't want to prohibit selecting text, so we don't trigger
164
+ # navigation if some text is selected. (Remember, this method is called
165
+ # upon releasing the mouse button.)
166
+ return
167
+ end
168
+ if (word = get_xy_word(x, y))
169
+ @app.go word, newtab
170
+ end
171
+ end
172
+
173
+ # Returns the section (the header) the cursor is in.
174
+ def get_previous_header cursor
175
+ ret = @info.rsearch_with_length(/[\r\n]\w[^\r\n]*/, cursor)
176
+ if !ret[0].empty? and @info.compare(cursor, '>=', ret[0])
177
+ return ret[2].strip
178
+ end
179
+ end
180
+
181
+ # Returns the first class mentioned before the cursor.
182
+ def get_previous_class cursor
183
+ ret = @info.rsearch_with_length(/[A-Z]\w*/, cursor)
184
+ return ret[0].empty? ? nil : ret[2]
185
+ end
186
+
187
+ # Get the "topic" under the mouse cursor.
188
+ def get_xy_word(x,y)
189
+ cursor = '@' + x.to_s + ',' + y.to_s
190
+ line = @info.get(cursor + ' linestart', cursor + ' lineend')
191
+ pos = @info.get(cursor + ' linestart', cursor).length
192
+
193
+ line = ' ' + line + ' '
194
+ pos += 1
195
+
196
+ a = pos
197
+ a -= 1 while line[a-1,1] !~ /[ (]/
198
+ z = pos
199
+ z += 1 while line[z+1,1] !~ /[ ()]/
200
+ word = line[a..z]
201
+
202
+ # Get rid of English punctuation.
203
+ word.gsub!(/[,.:;]$/, '')
204
+
205
+ # Get rid of italic, bold, and code markup.
206
+ if word =~ /^(_|\*|\+).*\1$/
207
+ word = word[1...-1]
208
+ a += 1
209
+ end
210
+
211
+ a -= 1 # Undo the `line = ' ' + line` we did previously.
212
+ @info.tag_add('keyword', '%s linestart + %d chars' % [ cursor, a ],
213
+ '%s linestart + %d chars' % [ cursor, a+word.length ])
214
+ word.strip!
215
+
216
+ return nil if word.empty?
217
+ return nil if word =~ /^-+$/ # A special case: a line of '-----'
218
+
219
+ case get_previous_header(cursor)
220
+ when 'Instance methods:'
221
+ word = topic + '#' + word
222
+ when 'Class methods:'
223
+ word = topic + '::' + word
224
+ when 'Includes:'
225
+ word = get_previous_class(cursor) + '#' + word if not word =~ /^[A-Z]/
226
+ end
227
+
228
+ return word
229
+ end
230
+
231
+ # Sets the text of the @info box, converting ANSI escape sequences to Tk
232
+ # tags.
233
+ def set_ansi_text(text)
234
+ text = text.dup
235
+ ansi_tags = {
236
+ # The following possibilities were taken from /usr/lib/ruby/1.8/rdoc/ri/ri_formatter.rb
237
+ '1' => 'bold',
238
+ '33' => 'italic',
239
+ '36' => 'code',
240
+ '4;32' => 'header2',
241
+ '32' => 'header3',
242
+ }
243
+ ranges = []
244
+ while text =~ /\x1b\[([\d;]+)m ([^\x1b]*) \x1b\[0?m/x
245
+ start = $`.length
246
+ length = $2.length
247
+ raw_length = $&.length
248
+ text[start, raw_length] = $2
249
+ ranges << { :start => start, :length => length, :tag => ansi_tags[$1] }
250
+ end
251
+
252
+ @info.delete('1.0', 'end')
253
+ @info.insert('end', text)
254
+
255
+ ranges.each do |range|
256
+ if range[:tag]
257
+ @info.tag_add(range[:tag], '1.0 + %d chars' % range[:start],
258
+ '1.0 + %d chars' % (range[:start] + range[:length]))
259
+ end
260
+ end
261
+ # Hide any remaining sequences. This may happen because our previous regexp
262
+ # (or any regexp) can't handle nested sequences.
263
+ _highlight_word(/\x1b\[([\d;]*)m/, text, 'hidden')
264
+ end
265
+
266
+ # Allow for some shortcuts when typing topics...
267
+ def fixup_topic(topic)
268
+ case topic
269
+ when 'S', 's', 'string'
270
+ 'String'
271
+ when 'A', 'a', 'array'
272
+ 'Array'
273
+ when 'H', 'h', 'hash'
274
+ 'Hash'
275
+ when 'File::new'
276
+ # See qri bug at http://rubyforge.org/tracker/index.php?func=detail&aid=23504&group_id=2545&atid=9811
277
+ 'File#new'
278
+ else
279
+ topic
280
+ end
281
+ end
282
+
283
+ # Navigates to some topic.
284
+ def go(topic=nil, skip_history=false)
285
+ topic = (topic || @address.get).strip
286
+ return if topic.empty?
287
+ if @topic and not skip_history
288
+ # Push current topic into history.
289
+ @history << HistoryEntry.new(@topic, @info.index('insert'), @info.yview[0])
290
+ end
291
+ @topic = fixup_topic(topic)
292
+ @app.status = 'Loading "%s"...' % @topic
293
+ @address.delete('0', 'end')
294
+ @address.insert('end', @topic)
295
+ focus_address
296
+ # We need to give our GUI a chance to redraw itself, so we run the
297
+ # time-consuming 'ri' command "in the next go".
298
+ TkAfter.new 100, 1 do
299
+ ri = @app.fetch_ri(@topic)
300
+ set_ansi_text(ri)
301
+ @app.refresh_tabsbar
302
+ @app.status = ''
303
+ @info.focus
304
+ set_cursor '1.0'
305
+ yield if block_given?
306
+ end.start
307
+ end
308
+
309
+ # Navigate to the previous topic viewed.
310
+ def back
311
+ if (entry = @history.pop)
312
+ go(entry.topic, true) do
313
+ @info.yview_moveto entry.yview
314
+ set_cursor entry.cursor
315
+ end
316
+ end
317
+ end
318
+
319
+ # Sets @info's caret position. Scroll the view if needed.
320
+ def set_cursor(pos)
321
+ @info.mark_set('insert', pos)
322
+ @info.see(pos)
323
+ end
324
+
325
+ def new?
326
+ not @topic
327
+ end
328
+
329
+ def show
330
+ pack :fill => 'both', :expand => true
331
+ end
332
+
333
+ def hide
334
+ pack_forget
335
+ end
336
+ end
337
+
338
+ # The tabsbar holds the buttons used to switch among the tabs.
339
+ class Tabsbar < TkFrame
340
+
341
+ def initialize(tk_parent, tabs, configuration = {})
342
+ @tabs = tabs
343
+ super(tk_parent, configuration)
344
+ @buttons = []
345
+ build_buttons
346
+ end
347
+
348
+ def set_current_tab new
349
+ @buttons.each_with_index do |b, i|
350
+ b.relief = (i == new) ? 'sunken' : 'raised'
351
+ end
352
+ @tabs.set_current_tab new
353
+ end
354
+
355
+ def build_buttons
356
+ @buttons.each { |b| b.destroy }
357
+ @buttons = []
358
+
359
+ @tabs.each_with_index do |tab, i|
360
+ b = TkButton.new(self, :text => (tab.topic || '<new>')).pack :side => 'left'
361
+ b.command { set_current_tab i }
362
+ b.bind('Button-3') { @tabs.close tab }
363
+ @buttons << b
364
+ end
365
+
366
+ plus = TkButton.new(self, :text => '+').pack :side => 'left'
367
+ plus.command { @tabs.new_tab }
368
+ @buttons << plus
369
+
370
+ set_current_tab @tabs.get_current_tab
371
+ end
372
+ end
373
+
374
+ # A 'Tabs' object holds several child objects of class 'Tab' and switches their
375
+ # visibility so that only one is visible at one time.
376
+ class Tabs < TkFrame
377
+
378
+ include Enumerable
379
+
380
+ def initialize(tk_parent, app, configuration = {})
381
+ @app = app
382
+ super(tk_parent, configuration)
383
+ @tabs = []
384
+ new_tab
385
+ end
386
+
387
+ def new_tab
388
+ tab = Tab.new(self, @app)
389
+ tab.focus_address
390
+ @tabs << tab
391
+ set_current_tab(@tabs.size - 1)
392
+ @app.refresh_tabsbar
393
+ end
394
+
395
+ def close(tab)
396
+ if (@tabs.size > 1 and i = @tabs.index(tab))
397
+ @tabs.delete_at i
398
+ tab.destroy
399
+ set_current_tab(@current - 1) if @current >= i and @current > 0
400
+ @app.refresh_tabsbar
401
+ end
402
+ end
403
+
404
+ def set_current_tab(new)
405
+ self.each_with_index do |tab, i|
406
+ if i == new; tab.show; else tab.hide; end
407
+ end
408
+ @current = new
409
+ end
410
+
411
+ def get_current_tab
412
+ return @current
413
+ end
414
+
415
+ def current
416
+ @tabs[get_current_tab]
417
+ end
418
+
419
+ def each
420
+ @tabs.each do |tab|
421
+ yield tab
422
+ end
423
+ end
424
+
425
+ end
426
+
427
+ class App
428
+
429
+ def initialize
430
+ @root = root = TkRoot.new { title 'Tkri' }
431
+ @search_word = nil
432
+
433
+ menu_spec = [
434
+ [['File', 0],
435
+ ['Close tab', proc { @tabs.close @tabs.current }, 0, 'Ctrl+W' ],
436
+ '---',
437
+ ['Quit', proc { exit }, 0, 'Ctrl+Q' ]],
438
+ [['Search', 0],
439
+ ['Search', proc { search }, 0, '/'],
440
+ ['Repeat search', proc { search_next }, 0, 'n'],
441
+ ['Repeat backwards', proc { search_prev }, 7, 'N']],
442
+ # The following :menu_name=>'help' has no effect, but it should have...
443
+ # probably a bug in RubyTK.
444
+ [['Help', 0, { :menu_name => 'help' }],
445
+ ['General', proc { help_general }, 0],
446
+ ['Key bindings', proc { help_key_bindings }, 0]],
447
+ ]
448
+ TkMenubar.new(root, menu_spec).pack(:side => 'top', :fill => 'x')
449
+
450
+ root.bind('Control-q') { exit }
451
+ root.bind('Control-w') { @tabs.close @tabs.current }
452
+ root.bind('Control-l') { @tabs.current.focus_address }
453
+
454
+ { 'Key-slash' => 'search',
455
+ 'Key-n' => 'search_next',
456
+ 'Key-N' => 'search_prev',
457
+ }.each do |event, method|
458
+ root.bind(event) { |e|
459
+ send(method) if e.widget.class != TkEntry
460
+ }
461
+ end
462
+
463
+ @tabs = Tabs.new(root, self) {
464
+ pack :side => 'top', :fill => 'both', :expand => true
465
+ }
466
+ @tabsbar = Tabsbar.new(root, @tabs) {
467
+ pack :side => 'top', :fill => 'x', :before => @tabs
468
+ }
469
+ @statusbar = TkLabel.new(root, :anchor => 'w') {
470
+ pack :side => 'bottom', :fill => 'x'
471
+ }
472
+ end
473
+
474
+ def run
475
+ Tk.mainloop
476
+ end
477
+
478
+ # Navigates to some topic. This method simply delegates to the current tab.
479
+ def go(topic=nil, newtab=false)
480
+ @tabs.new_tab if newtab and not @tabs.current.new?
481
+ @tabs.current.go topic
482
+ end
483
+
484
+ # Sets the text to show in the status bar.
485
+ def status=(status)
486
+ @statusbar.configure(:text => status)
487
+ end
488
+
489
+ def refresh_tabsbar
490
+ @tabsbar.build_buttons if @tabsbar
491
+ end
492
+
493
+ def search_prev
494
+ if @search_word
495
+ @tabs.current.search_prev_word @search_word
496
+ end
497
+ end
498
+
499
+ def search_next
500
+ if @search_word
501
+ @tabs.current.search_next_word @search_word
502
+ else
503
+ search
504
+ end
505
+ end
506
+
507
+ def search
508
+ self.status = 'Type the string to search'
509
+ entry = TkEntry.new(@root).pack(:fill => 'x').focus
510
+ entry.bind('Key-Return') {
511
+ self.status = ''
512
+ @search_word = entry.get
513
+ @tabs.current.search_next_word entry.get
514
+ entry.destroy
515
+ }
516
+ ['Key-Escape', 'FocusOut'].each do |event|
517
+ entry.bind(event) {
518
+ self.status = ''
519
+ entry.destroy
520
+ }
521
+ end
522
+ entry.bind('KeyRelease') {
523
+ @tabs.current.highlight_word entry.get
524
+ }
525
+ end
526
+
527
+ # Executes the 'ri' command and returns its output.
528
+ def fetch_ri topic
529
+ if !@ri_cache
530
+ @ri_cache = {}
531
+ @cached_topics = []
532
+ end
533
+
534
+ return @ri_cache[topic] if @ri_cache[topic]
535
+
536
+ command = COMMAND.select { |k,v| RUBY_PLATFORM.index(k) }.first.to_a[1] || COMMAND['default']
537
+ ri = Kernel.`(command % topic) # `
538
+ if $? != 0
539
+ ri += "\n" + "ERROR: Failed to run the command '%s' (exit code: %d). Please make sure you have this command in your PATH.\n\nYou may wish to modify this program's source (%s) to update the command to something that works on your system." % [command % topic, $?, $0]
540
+ else
541
+ if ri == "nil\n"
542
+ ri = 'Topic "%s" not found.' % topic
543
+ end
544
+ @ri_cache[topic] = ri
545
+ @cached_topics << topic
546
+ end
547
+
548
+ # Remove the oldest topic from the cache
549
+ if @cached_topics.length > 10
550
+ @ri_cache.delete @cached_topics.shift
551
+ end
552
+
553
+ return ri
554
+ end
555
+
556
+ def helpbox(title, text)
557
+ w = TkToplevel.new(:title => title)
558
+ TkText.new(w, :height => text.count("\n"), :width => 80).pack.insert('1.0', text)
559
+ TkButton.new(w, :text => 'Close', :command => proc { w.destroy }).pack
560
+ end
561
+
562
+ def help_general
563
+ helpbox('Help: General', <<EOS)
564
+ Tkri (pronounce TIK-ri) is a GUI fron-end to RI (actually, by default,
565
+ to FastRI).
566
+ EOS
567
+ end
568
+
569
+ def help_key_bindings
570
+ helpbox('Help: key bindings', <<EOS)
571
+ Left mouse button
572
+ Navigate to the topic under the cursor.
573
+ Middle mouse button
574
+ Navigate to the topic under the cursor. Opens in a new tab.
575
+ Right mouse button
576
+ Move back in the history.
577
+ Ctrl+W. Or right mouse button, on a tab button
578
+ Close the tab (unless this is the only tab).
579
+ Ctrl+L
580
+ Move the keyboard focus to the "address" box, where you can type a topic.
581
+ EOS
582
+ end
583
+ end
584
+
585
+ end # module Tkri
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tkri
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Mooffie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-12 00:00:00 +02:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: fastri
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0.3"
24
+ version:
25
+ description:
26
+ email: mooffie@gmail.com
27
+ executables:
28
+ - tkri
29
+ extensions: []
30
+
31
+ extra_rdoc_files: []
32
+
33
+ files:
34
+ - README
35
+ - lib/tkri.rb
36
+ - bin/tkri
37
+ has_rdoc: false
38
+ homepage: http://rubyforge.org/projects/tkri/
39
+ post_install_message:
40
+ rdoc_options: []
41
+
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: "0"
49
+ version:
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: "0"
55
+ version:
56
+ requirements: []
57
+
58
+ rubyforge_project: tkri
59
+ rubygems_version: 1.3.1
60
+ signing_key:
61
+ specification_version: 2
62
+ summary: GUI front-end to FastRI's or RI's executables.
63
+ test_files: []
64
+