sup 0.3 → 0.4

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sup might be problematic. Click here for more details.

Files changed (52) hide show
  1. data/HACKING +31 -9
  2. data/History.txt +7 -0
  3. data/Manifest.txt +2 -0
  4. data/Rakefile +9 -5
  5. data/bin/sup +81 -57
  6. data/bin/sup-config +1 -1
  7. data/bin/sup-sync +3 -0
  8. data/bin/sup-tweak-labels +127 -0
  9. data/doc/TODO +23 -12
  10. data/lib/sup.rb +13 -11
  11. data/lib/sup/account.rb +25 -12
  12. data/lib/sup/buffer.rb +61 -41
  13. data/lib/sup/colormap.rb +2 -0
  14. data/lib/sup/contact.rb +28 -18
  15. data/lib/sup/crypto.rb +86 -31
  16. data/lib/sup/draft.rb +12 -6
  17. data/lib/sup/horizontal-selector.rb +47 -0
  18. data/lib/sup/imap.rb +50 -37
  19. data/lib/sup/index.rb +76 -13
  20. data/lib/sup/keymap.rb +27 -8
  21. data/lib/sup/maildir.rb +1 -1
  22. data/lib/sup/mbox/loader.rb +1 -1
  23. data/lib/sup/message-chunks.rb +43 -15
  24. data/lib/sup/message.rb +67 -31
  25. data/lib/sup/mode.rb +40 -9
  26. data/lib/sup/modes/completion-mode.rb +1 -1
  27. data/lib/sup/modes/compose-mode.rb +3 -3
  28. data/lib/sup/modes/contact-list-mode.rb +12 -8
  29. data/lib/sup/modes/edit-message-mode.rb +100 -36
  30. data/lib/sup/modes/file-browser-mode.rb +1 -0
  31. data/lib/sup/modes/forward-mode.rb +43 -8
  32. data/lib/sup/modes/inbox-mode.rb +8 -5
  33. data/lib/sup/modes/label-search-results-mode.rb +12 -1
  34. data/lib/sup/modes/line-cursor-mode.rb +4 -7
  35. data/lib/sup/modes/reply-mode.rb +59 -54
  36. data/lib/sup/modes/resume-mode.rb +6 -6
  37. data/lib/sup/modes/scroll-mode.rb +4 -3
  38. data/lib/sup/modes/search-results-mode.rb +8 -5
  39. data/lib/sup/modes/text-mode.rb +19 -2
  40. data/lib/sup/modes/thread-index-mode.rb +109 -40
  41. data/lib/sup/modes/thread-view-mode.rb +180 -49
  42. data/lib/sup/person.rb +3 -3
  43. data/lib/sup/poll.rb +9 -8
  44. data/lib/sup/rfc2047.rb +7 -1
  45. data/lib/sup/sent.rb +1 -1
  46. data/lib/sup/tagger.rb +10 -4
  47. data/lib/sup/textfield.rb +7 -7
  48. data/lib/sup/thread.rb +86 -49
  49. data/lib/sup/update.rb +11 -0
  50. data/lib/sup/util.rb +74 -34
  51. data/test/test_message.rb +441 -0
  52. metadata +136 -117
@@ -1,3 +1,4 @@
1
+ require 'open3'
1
2
  module Redwood
2
3
 
3
4
  class ThreadViewMode < LineCursorMode
@@ -13,6 +14,16 @@ class ThreadViewMode < LineCursorMode
13
14
  DATE_FORMAT = "%B %e %Y %l:%M%P"
14
15
  INDENT_SPACES = 2 # how many spaces to indent child messages
15
16
 
17
+ HookManager.register "detailed-headers", <<EOS
18
+ Add or remove headers from the detailed header display of a message.
19
+ Variables:
20
+ message: The message whose headers are to be formatted.
21
+ headers: A hash of header (name, value) pairs, initialized to the default
22
+ headers.
23
+ Return value:
24
+ None. The variable 'headers' should be modified in place.
25
+ EOS
26
+
16
27
  register_keymap do |k|
17
28
  k.add :toggle_detailed_header, "Toggle detailed header", 'h'
18
29
  k.add :show_header, "Show full message header", 'H'
@@ -23,20 +34,35 @@ class ThreadViewMode < LineCursorMode
23
34
  k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
24
35
  k.add :jump_to_next_open, "Jump to next open message", 'n'
25
36
  k.add :jump_to_prev_open, "Jump to previous open message", 'p'
37
+ k.add :align_current_message, "Align current message in buffer", 'z'
26
38
  k.add :toggle_starred, "Star or unstar message", '*'
27
- k.add :toggle_new, "Toggle new/read status of message", 'N'
39
+ k.add :toggle_new, "Toggle unread/read status of message", 'N'
28
40
  # k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
29
41
  k.add :reply, "Reply to a message", 'r'
30
- k.add :forward, "Forward a message", 'f'
42
+ k.add :forward, "Forward a message or attachment", 'f'
31
43
  k.add :alias, "Edit alias/nickname for a person", 'i'
32
44
  k.add :edit_as_new, "Edit message as new", 'D'
33
45
  k.add :save_to_disk, "Save message/attachment to disk", 's'
34
46
  k.add :search, "Search for messages from particular people", 'S'
35
47
  k.add :compose, "Compose message to person", 'm'
36
- k.add :archive_and_kill, "Archive thread and kill buffer", 'a'
37
- k.add :delete_and_kill, "Delete thread and kill buffer", 'd'
38
48
  k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
39
49
  k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
50
+ k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
51
+
52
+ k.add_multi "(A)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
53
+ kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
54
+ kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
55
+ kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
56
+ kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
57
+ end
58
+
59
+ k.add_multi "(A)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
60
+ kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
61
+ kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
62
+ kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
63
+ kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
64
+ kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
65
+ end
40
66
  end
41
67
 
42
68
  ## there are a couple important instance variables we hold to format
@@ -46,11 +72,15 @@ class ThreadViewMode < LineCursorMode
46
72
  ## Message objects. @chunk_lines is a map from row #s to Chunk
47
73
  ## objects. @person_lines is a map from row #s to Person objects.
48
74
 
49
- def initialize thread, hidden_labels=[]
75
+ def initialize thread, hidden_labels=[], index_mode=nil
50
76
  super()
51
77
  @thread = thread
52
78
  @hidden_labels = hidden_labels
53
79
 
80
+ ## used for dispatch-and-next
81
+ @index_mode = index_mode
82
+ @dying = false
83
+
54
84
  @layout = SavingHash.new { MessageLayout.new }
55
85
  @chunk_layout = SavingHash.new { ChunkLayout.new }
56
86
  earliest, latest = nil, nil
@@ -90,7 +120,7 @@ class ThreadViewMode < LineCursorMode
90
120
 
91
121
  def show_header
92
122
  m = @message_lines[curpos] or return
93
- BufferManager.spawn_unless_exists("Full header") do
123
+ BufferManager.spawn_unless_exists("Full header for #{m.id}") do
94
124
  TextMode.new m.raw_header
95
125
  end
96
126
  end
@@ -126,8 +156,11 @@ class ThreadViewMode < LineCursorMode
126
156
  end
127
157
 
128
158
  def forward
129
- m = @message_lines[curpos] or return
130
- ForwardMode.spawn_nicely m
159
+ if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
160
+ ForwardMode.spawn_nicely :attachments => [chunk]
161
+ elsif(m = @message_lines[curpos])
162
+ ForwardMode.spawn_nicely :message => m
163
+ end
131
164
  end
132
165
 
133
166
  include CanAliasContacts
@@ -161,7 +194,7 @@ class ThreadViewMode < LineCursorMode
161
194
  @thread.labels = (reserved_labels + new_labels).uniq
162
195
  new_labels.each { |l| LabelManager << l }
163
196
  update
164
- UpdateManager.relay self, :label_thread, @thread
197
+ UpdateManager.relay self, :labeled, @thread.first
165
198
  end
166
199
 
167
200
  def toggle_starred
@@ -183,7 +216,7 @@ class ThreadViewMode < LineCursorMode
183
216
  ## TODO: don't recalculate EVERYTHING just to add a stupid little
184
217
  ## star to the display
185
218
  update
186
- UpdateManager.relay self, :label, m
219
+ UpdateManager.relay self, :single_message_labeled, m
187
220
  end
188
221
 
189
222
  ## called when someone presses enter when the cursor is highlighting
@@ -242,27 +275,34 @@ class ThreadViewMode < LineCursorMode
242
275
  end
243
276
  end
244
277
 
245
- def jump_to_first_open
278
+ def jump_to_first_open loose_alignment=false
246
279
  m = @message_lines[0] or return
247
280
  if @layout[m].state != :closed
248
- jump_to_message m
281
+ jump_to_message m, loose_alignment
249
282
  else
250
- jump_to_next_open
283
+ jump_to_next_open loose_alignment
251
284
  end
252
285
  end
253
286
 
254
- def jump_to_next_open
287
+ def jump_to_next_open loose_alignment=false
255
288
  return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
256
- m = @message_lines[curpos] or return
289
+ m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
290
+ return unless m
257
291
  while nextm = @layout[m].next
258
292
  break if @layout[nextm].state != :closed
259
293
  m = nextm
260
294
  end
261
- jump_to_message nextm if nextm
295
+ jump_to_message nextm, loose_alignment if nextm
262
296
  end
263
297
 
264
- def jump_to_prev_open
298
+ def align_current_message
265
299
  m = @message_lines[curpos] or return
300
+ jump_to_message m
301
+ end
302
+
303
+ def jump_to_prev_open loose_alignment=false
304
+ m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
305
+ return unless m
266
306
  ## jump to the top of the current message if we're in the body;
267
307
  ## otherwise, to the previous message
268
308
 
@@ -272,22 +312,32 @@ class ThreadViewMode < LineCursorMode
272
312
  break if @layout[prevm].state != :closed
273
313
  m = prevm
274
314
  end
275
- jump_to_message prevm if prevm
315
+ jump_to_message prevm, loose_alignment if prevm
276
316
  else
277
- jump_to_message m
317
+ jump_to_message m, loose_alignment
278
318
  end
279
319
  end
280
320
 
281
- def jump_to_message m
321
+ def jump_to_message m, loose_alignment=false
282
322
  l = @layout[m]
283
323
  left = l.depth * INDENT_SPACES
284
324
  right = left + l.width
285
325
 
286
- ## jump to the top line unless both top and bottom fit in the current view
287
- jump_to_line l.top unless l.top >= topline && l.top <= botline && l.bot >= topline && l.bot <= botline
326
+ ## jump to the top line
327
+ if loose_alignment
328
+ jump_to_line [l.top - 3, 0].max # give 3 lines of top context
329
+ else
330
+ jump_to_line l.top
331
+ end
288
332
 
289
- ## jump to the left columns unless both left and right fit in the current view
290
- jump_to_col left unless left >= leftcol && left <= rightcol && right >= leftcol && right <= rightcol
333
+ ## jump to the left column
334
+ if loose_alignment
335
+ ## try and give 4 columns of left context, but not if it means that
336
+ ## the right of the message is truncated.
337
+ jump_to_col [[left - 4, rightcol - l.width - 1].min, 0].max
338
+ else
339
+ jump_to_col left
340
+ end
291
341
 
292
342
  ## either way, move the cursor to the first line
293
343
  set_cursor_pos l.top
@@ -319,16 +369,91 @@ class ThreadViewMode < LineCursorMode
319
369
  @layout = @chunk_layout = @text = nil # for good luck
320
370
  end
321
371
 
322
- def archive_and_kill
323
- @thread.remove_label :inbox
324
- UpdateManager.relay self, :archived, @thread
325
- BufferManager.kill_buffer_safely buffer
372
+ def archive_and_kill; archive_and_then :kill end
373
+ def spam_and_kill; spam_and_then :kill end
374
+ def delete_and_kill; delete_and_then :kill end
375
+ def unread_and_kill; unread_and_then :kill end
376
+
377
+ def archive_and_next; archive_and_then :next end
378
+ def spam_and_next; spam_and_then :next end
379
+ def delete_and_next; delete_and_then :next end
380
+ def unread_and_next; unread_and_then :next end
381
+ def do_nothing_and_next; do_nothing_and_then :next end
382
+
383
+ def archive_and_then op
384
+ dispatch op do
385
+ @thread.remove_label :inbox
386
+ UpdateManager.relay self, :archived, @thread.first
387
+ end
388
+ end
389
+
390
+ def spam_and_then op
391
+ dispatch op do
392
+ @thread.apply_label :spam
393
+ UpdateManager.relay self, :spammed, @thread.first
394
+ end
395
+ end
396
+
397
+ def delete_and_then op
398
+ dispatch op do
399
+ @thread.apply_label :deleted
400
+ UpdateManager.relay self, :deleted, @thread.first
401
+ end
402
+ end
403
+
404
+ def unread_and_then op
405
+ dispatch op do
406
+ @thread.apply_label :unread
407
+ UpdateManager.relay self, :unread, @thread.first
408
+ end
409
+ end
410
+
411
+ def do_nothing_and_then op
412
+ dispatch op
413
+ end
414
+
415
+ def dispatch op
416
+ return if @dying
417
+ @dying = true
418
+
419
+ case op
420
+ when :next
421
+ @index_mode.launch_next_thread_after(@thread) do
422
+ @thread.save Index if block_given? && yield
423
+ BufferManager.kill_buffer_safely buffer
424
+ end
425
+ when :kill
426
+ @thread.save Index if yield
427
+ BufferManager.kill_buffer_safely buffer
428
+ else
429
+ raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
430
+ end
326
431
  end
432
+ private :dispatch
433
+
434
+ def pipe_message
435
+ chunk = @chunk_lines[curpos]
436
+ chunk = nil unless chunk.is_a?(Chunk::Attachment)
437
+ message = @message_lines[curpos] unless chunk
327
438
 
328
- def delete_and_kill
329
- @thread.apply_label :deleted
330
- UpdateManager.relay self, :deleted, @thread
331
- BufferManager.kill_buffer_safely buffer
439
+ return unless chunk || message
440
+
441
+ command = BufferManager.ask(:shell, "pipe command: ")
442
+ return if command.nil? || command.empty?
443
+
444
+ output = pipe_to_process(command) do |stream|
445
+ if chunk
446
+ stream.print chunk.raw_content
447
+ else
448
+ message.each_raw_message_line { |l| stream.print l }
449
+ end
450
+ end
451
+
452
+ if output
453
+ BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
454
+ else
455
+ BufferManager.flash "'#{command}' done!"
456
+ end
332
457
  end
333
458
 
334
459
  private
@@ -439,32 +564,38 @@ private
439
564
 
440
565
  when :detailed
441
566
  @person_lines[start] = m.from
442
- from = [[prefix_widget, open_widget, new_widget, starred_widget,
567
+ from_line = [[prefix_widget, open_widget, new_widget, starred_widget,
443
568
  [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
444
569
 
445
- rest = []
570
+ addressee_lines = []
446
571
  unless m.to.empty?
447
- m.to.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p }
448
- rest += format_person_list " To: ", m.to
572
+ m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
573
+ addressee_lines += format_person_list " To: ", m.to
449
574
  end
450
575
  unless m.cc.empty?
451
- m.cc.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p }
452
- rest += format_person_list " Cc: ", m.cc
576
+ m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
577
+ addressee_lines += format_person_list " Cc: ", m.cc
453
578
  end
454
579
  unless m.bcc.empty?
455
- m.bcc.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p }
456
- rest += format_person_list " Bcc: ", m.bcc
580
+ m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
581
+ addressee_lines += format_person_list " Bcc: ", m.bcc
457
582
  end
458
583
 
584
+ headers = OrderedHash.new
585
+ headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
586
+ headers["Subject"] = m.subj
587
+
459
588
  show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
460
- rest += [
461
- " Date: #{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})",
462
- " Subject: #{m.subj}",
463
- (parent ? " In reply to: #{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}" : nil),
464
- show_labels.empty? ? nil : " Labels: #{show_labels.join(', ')}",
465
- ].compact
589
+ unless show_labels.empty?
590
+ headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
591
+ end
592
+ if parent
593
+ headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
594
+ end
595
+
596
+ HookManager.run "detailed-headers", :message => m, :headers => headers
466
597
 
467
- from + rest.map { |l| [[color, prefix + " " + l]] }
598
+ from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] }
468
599
  end
469
600
  end
470
601
 
@@ -478,7 +609,7 @@ private
478
609
  end
479
610
 
480
611
  def format_person p
481
- p.longname + (ContactManager.is_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
612
+ p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
482
613
  end
483
614
 
484
615
  ## todo: check arguments on this overly complex function
@@ -516,7 +647,7 @@ private
516
647
  BufferManager.erase_flash
517
648
  BufferManager.completely_redraw_screen
518
649
  unless success
519
- BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s)
650
+ BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
520
651
  BufferManager.flash "Couldn't execute view command, viewing as text."
521
652
  end
522
653
  end
@@ -117,12 +117,12 @@ class Person
117
117
  def full_address
118
118
  if @name && @email
119
119
  if @name =~ /[",@]/
120
- "#{@name.inspect} <#@email>" # escape quotes
120
+ "#{@name.inspect} <#{@email}>" # escape quotes
121
121
  else
122
- "#@name <#@email>"
122
+ "#{@name} <#{@email}>"
123
123
  end
124
124
  else
125
- @email
125
+ email
126
126
  end
127
127
  end
128
128
 
@@ -19,12 +19,13 @@ EOS
19
19
  HookManager.register "after-poll", <<EOS
20
20
  Executes immediately after a poll for new messages completes.
21
21
  Variables:
22
- num: the total number of new messages
23
- num_inbox: the number of new messages appearing in the inbox (i.e.
24
- not auto-archived).
25
- from_and_subj: an array of (from email address, subject) pairs
26
- from_and_subj_inbox: an array of (from email address, subject) pairs for
27
- only those messages appearing in the inbox
22
+ num: the total number of new messages added in this poll
23
+ num_inbox: the number of new messages added in this poll which
24
+ appear in the inbox (i.e. were not auto-archived).
25
+ num_inbox_total_unread: the total number of unread messages in the inbox
26
+ from_and_subj: an array of (from email address, subject) pairs
27
+ from_and_subj_inbox: an array of (from email address, subject) pairs for
28
+ only those messages appearing in the inbox
28
29
  EOS
29
30
 
30
31
  DELAY = 300
@@ -56,7 +57,7 @@ EOS
56
57
  BufferManager.flash "No new messages."
57
58
  end
58
59
 
59
- HookManager.run "after-poll", :num => num, :num_inbox => numi, :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox
60
+ HookManager.run "after-poll", :num => num, :num_inbox => numi, :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox, :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }
60
61
 
61
62
  @polling = false
62
63
  [num, numi]
@@ -157,7 +158,7 @@ EOS
157
158
  HookManager.run "before-add-message", :message => m
158
159
  m = yield(m, offset, entry) or next
159
160
  Index.sync_message m, docid, entry
160
- UpdateManager.relay self, :add, m unless entry
161
+ UpdateManager.relay self, :added, m unless entry
161
162
  rescue MessageFormatError => e
162
163
  Redwood::log "ignoring erroneous message at #{source}##{offset}: #{e.message}"
163
164
  end
@@ -52,10 +52,16 @@ module Rfc2047
52
52
  # WORD.
53
53
  end
54
54
 
55
+ charset = "utf-8" if charset =~ /UTF_?8/i
56
+
55
57
  # Convert:
56
58
  #
57
59
  # Remember - Iconv.open(to, from)!
58
- text = Iconv.iconv(target, charset, text).join
60
+ begin
61
+ text = Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
62
+ rescue Iconv::InvalidCharacter
63
+ text
64
+ end
59
65
  end
60
66
  end
61
67
  end
@@ -25,7 +25,7 @@ class SentManager
25
25
  @source.each do |offset, labels|
26
26
  m = Message.new :source => @source, :source_info => offset, :labels => @source.labels
27
27
  Index.sync_message m
28
- UpdateManager.relay self, :add, m
28
+ UpdateManager.relay self, :added, m
29
29
  end
30
30
  end
31
31
  end
@@ -8,10 +8,12 @@ class Tagger
8
8
 
9
9
  def tagged? o; @tagged[o]; end
10
10
  def toggle_tag_for o; @tagged[o] = !@tagged[o]; end
11
+ def tag o; @tagged[o] = true; end
12
+ def untag o; @tagged[o] = false; end
11
13
  def drop_all_tags; @tagged.clear; end
12
14
  def drop_tag_for o; @tagged.delete o; end
13
15
 
14
- def apply_to_tagged
16
+ def apply_to_tagged action=nil
15
17
  targets = @tagged.select_by_value
16
18
  num_tagged = targets.size
17
19
  if num_tagged == 0
@@ -20,10 +22,14 @@ class Tagger
20
22
  end
21
23
 
22
24
  noun = num_tagged == 1 ? "thread" : "threads"
23
- c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:"
24
- return if c.nil? # user cancelled
25
25
 
26
- if(action = @mode.resolve_input(c))
26
+ unless action
27
+ c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:"
28
+ return if c.nil? # user cancelled
29
+ action = @mode.resolve_input c
30
+ end
31
+
32
+ if action
27
33
  tagged_sym = "multi_#{action}".intern
28
34
  if @mode.respond_to? tagged_sym
29
35
  @mode.send tagged_sym, targets