tkri 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+