]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-view-mode.rb
rejigger alignment of messages when using 'n' and 'p'
[sup] / lib / sup / modes / thread-view-mode.rb
1 module Redwood
2
3 class ThreadViewMode < LineCursorMode
4   ## this holds all info we need to lay out a message
5   class MessageLayout
6     attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new
7   end
8
9   class ChunkLayout
10     attr_accessor :state
11   end
12
13   DATE_FORMAT = "%B %e %Y %l:%M%P"
14   INDENT_SPACES = 2 # how many spaces to indent child messages
15
16   HookManager.register "detailed-headers", <<EOS
17 Add or remove headers from the detailed header display of a message.
18 Variables:
19   message: The message whose headers are to be formatted.
20   headers: A hash of header (name, value) pairs, initialized to the default
21            headers.
22 Return value:
23   None. The variable 'headers' should be modified in place.
24 EOS
25
26   HookManager.register "bounce-command", <<EOS
27 Determines the command used to bounce a message.
28 Variables:
29       from: The From header of the message being bounced
30             (eg: likely _not_ your address).
31         to: The addresses you asked the message to be bounced to as an array.
32 Return value:
33   A string representing the command to pipe the mail into.  This
34   should include the entire command except for the destination addresses,
35   which will be appended by sup.
36 EOS
37
38   register_keymap do |k|
39     k.add :toggle_detailed_header, "Toggle detailed header", 'h'
40     k.add :show_header, "Show full message header", 'H'
41     k.add :show_message, "Show full message (raw form)", 'V'
42     k.add :activate_chunk, "Expand/collapse or activate item", :enter
43     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
44     k.add :edit_draft, "Edit draft", 'e'
45     k.add :send_draft, "Send draft", 'y'
46     k.add :edit_labels, "Edit or add labels for a thread", 'l'
47     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
48     k.add :jump_to_next_open, "Jump to next open message", 'n'
49     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
50     k.add :align_current_message, "Align current message in buffer", 'z'
51     k.add :toggle_starred, "Star or unstar message", '*'
52     k.add :toggle_new, "Toggle unread/read status of message", 'N'
53 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
54     k.add :reply, "Reply to a message", 'r'
55     k.add :forward, "Forward a message or attachment", 'f'
56     k.add :bounce, "Bounce message to other recipient(s)", '!'
57     k.add :alias, "Edit alias/nickname for a person", 'i'
58     k.add :edit_as_new, "Edit message as new", 'D'
59     k.add :save_to_disk, "Save message/attachment to disk", 's'
60     k.add :search, "Search for messages from particular people", 'S'
61     k.add :compose, "Compose message to person", 'm'
62     k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
63     k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
64     k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
65
66     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
67       kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
68       kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
69       kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
70       kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
71     end
72
73     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
74       kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
75       kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
76       kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
77       kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
78       kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
79     end
80
81     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
82       kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
83       kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
84       kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
85       kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
86       kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n'
87     end
88   end
89
90   ## there are a couple important instance variables we hold to format
91   ## the thread and to provide line-based functionality. @layout is a
92   ## map from Messages to MessageLayouts, and @chunk_layout from
93   ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
94   ## Message objects.  @chunk_lines is a map from row #s to Chunk
95   ## objects. @person_lines is a map from row #s to Person objects.
96
97   def initialize thread, hidden_labels=[], index_mode=nil
98     super()
99     @thread = thread
100     @hidden_labels = hidden_labels
101
102     ## used for dispatch-and-next
103     @index_mode = index_mode
104     @dying = false
105
106     @layout = SavingHash.new { MessageLayout.new }
107     @chunk_layout = SavingHash.new { ChunkLayout.new }
108     earliest, latest = nil, nil
109     latest_date = nil
110     altcolor = false
111
112     @thread.each do |m, d, p|
113       next unless m
114       earliest ||= m
115       @layout[m].state = initial_state_for m
116       @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
117       @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
118       @layout[m].orig_new = m.has_label? :read
119       altcolor = !altcolor
120       if latest_date.nil? || m.date > latest_date
121         latest_date = m.date
122         latest = m
123       end
124     end
125
126     @layout[latest].state = :open if @layout[latest].state == :closed
127     @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
128
129     @thread.remove_label :unread
130     regen_text
131   end
132
133   def draw_line ln, opts={}
134     if ln == curpos
135       super ln, :highlight => true
136     else
137       super
138     end
139   end
140   def lines; @text.length; end
141   def [] i; @text[i]; end
142
143   def show_header
144     m = @message_lines[curpos] or return
145     BufferManager.spawn_unless_exists("Full header for #{m.id}") do
146       TextMode.new m.raw_header
147     end
148   end
149
150   def show_message
151     m = @message_lines[curpos] or return
152     BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
153       TextMode.new m.raw_message
154     end
155   end
156
157   def toggle_detailed_header
158     m = @message_lines[curpos] or return
159     @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
160     update
161   end
162
163   def reply
164     m = @message_lines[curpos] or return
165     mode = ReplyMode.new m
166     BufferManager.spawn "Reply to #{m.subj}", mode
167   end
168
169   def subscribe_to_list
170     m = @message_lines[curpos] or return
171     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
172       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
173     else
174       BufferManager.flash "Can't find List-Subscribe header for this message."
175     end
176   end
177
178   def unsubscribe_from_list
179     m = @message_lines[curpos] or return
180     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
181       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
182     else
183       BufferManager.flash "Can't find List-Unsubscribe header for this message."
184     end
185   end
186
187   def forward
188     if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
189       ForwardMode.spawn_nicely :attachments => [chunk]
190     elsif(m = @message_lines[curpos])
191       ForwardMode.spawn_nicely :message => m
192     end
193   end
194
195   def bounce
196     m = @message_lines[curpos] or return
197     to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
198
199     defcmd = AccountManager.default_account.sendmail.sub(/\s(\-(ti|it|t))\b/) do |match|
200       case "$1"
201         when '-t' then ''
202         else ' -i'
203       end
204     end
205
206     cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
207           when nil, /^$/ then defcmd
208           else hookcmd
209           end + ' ' + to.map { |t| t.email }.join(' ')
210
211     bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
212
213     if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
214       debug "bounce command: #{cmd}"
215       begin
216         IO.popen(cmd, 'w') do |sm|
217           sm.puts m.raw_message
218         end
219         raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
220       rescue SystemCallError, SendmailCommandFailed => e
221         warn "problem sending mail: #{e.message}"
222         BufferManager.flash "Problem sending mail: #{e.message}"
223       end
224     end
225   end
226
227   include CanAliasContacts
228   def alias
229     p = @person_lines[curpos] or return
230     alias_contact p
231     update
232   end
233
234   def search
235     p = @person_lines[curpos] or return
236     mode = PersonSearchResultsMode.new [p]
237     BufferManager.spawn "Search for #{p.name}", mode
238     mode.load_threads :num => mode.buffer.content_height
239   end    
240
241   def compose
242     p = @person_lines[curpos]
243     if p
244       ComposeMode.spawn_nicely :to_default => p
245     else
246       ComposeMode.spawn_nicely
247     end
248   end    
249
250   def edit_labels
251     reserved_labels = @thread.labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
252     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels
253
254     return unless new_labels
255     @thread.labels = Set.new(reserved_labels) + new_labels
256     new_labels.each { |l| LabelManager << l }
257     update
258     UpdateManager.relay self, :labeled, @thread.first
259   end
260
261   def toggle_starred
262     m = @message_lines[curpos] or return
263     toggle_label m, :starred
264   end
265
266   def toggle_new
267     m = @message_lines[curpos] or return
268     toggle_label m, :unread
269   end
270
271   def toggle_label m, label
272     if m.has_label? label
273       m.remove_label label
274     else
275       m.add_label label
276     end
277     ## TODO: don't recalculate EVERYTHING just to add a stupid little
278     ## star to the display
279     update
280     UpdateManager.relay self, :single_message_labeled, m
281   end
282
283   ## called when someone presses enter when the cursor is highlighting
284   ## a chunk. for expandable chunks (including messages) we toggle
285   ## open/closed state; for viewable chunks (like attachments) we
286   ## view.
287   def activate_chunk
288     chunk = @chunk_lines[curpos] or return
289     if chunk.is_a? Chunk::Text
290       ## if the cursor is over a text region, expand/collapse the
291       ## entire message
292       chunk = @message_lines[curpos]
293     end
294     layout = if chunk.is_a?(Message)
295       @layout[chunk]
296     elsif chunk.expandable?
297       @chunk_layout[chunk]
298     end
299     if layout
300       layout.state = (layout.state != :closed ? :closed : :open)
301       #cursor_down if layout.state == :closed # too annoying
302       update
303     elsif chunk.viewable?
304       view chunk
305     end
306     if chunk.is_a?(Message)
307       jump_to_message chunk
308       jump_to_next_open if layout.state == :closed
309     end
310   end
311
312   def edit_as_new
313     m = @message_lines[curpos] or return
314     mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
315     BufferManager.spawn "edit as new", mode
316     mode.edit_message
317   end
318
319   def save_to_disk
320     chunk = @chunk_lines[curpos] or return
321     case chunk
322     when Chunk::Attachment
323       default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
324       fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
325       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
326     else
327       m = @message_lines[curpos]
328       fn = BufferManager.ask_for_filename :filename, "Save message to file: "
329       return unless fn
330       save_to_file(fn) do |f|
331         m.each_raw_message_line { |l| f.print l }
332       end
333     end
334   end
335
336   def edit_draft
337     m = @message_lines[curpos] or return
338     if m.is_draft?
339       mode = ResumeMode.new m
340       BufferManager.spawn "Edit message", mode
341       BufferManager.kill_buffer self.buffer
342       mode.edit_message
343     else
344       BufferManager.flash "Not a draft message!"
345     end
346   end
347
348   def send_draft
349     m = @message_lines[curpos] or return
350     if m.is_draft?
351       mode = ResumeMode.new m
352       BufferManager.spawn "Send message", mode
353       BufferManager.kill_buffer self.buffer
354       mode.send_message
355     else
356       BufferManager.flash "Not a draft message!"
357     end
358   end
359
360   def jump_to_first_open
361     m = @message_lines[0] or return
362     if @layout[m].state != :closed
363       jump_to_message m#, true
364     else
365       jump_to_next_open #true
366     end
367   end
368
369   def jump_to_next_open force_alignment=nil
370     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
371     m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
372     return unless m
373     while nextm = @layout[m].next
374       break if @layout[nextm].state != :closed
375       m = nextm
376     end
377     jump_to_message nextm, force_alignment if nextm
378   end
379
380   def align_current_message
381     m = @message_lines[curpos] or return
382     jump_to_message m, true
383   end
384
385   def jump_to_prev_open
386     m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
387     return unless m
388     ## jump to the top of the current message if we're in the body;
389     ## otherwise, to the previous message
390     
391     top = @layout[m].top
392     if curpos == top
393       while(prevm = @layout[m].prev)
394         break if @layout[prevm].state != :closed
395         m = prevm
396       end
397       jump_to_message prevm if prevm
398     else
399       jump_to_message m
400     end
401   end
402
403   def jump_to_message m, force_alignment=false
404     l = @layout[m]
405
406     ## boundaries of the message
407     message_left = l.depth * INDENT_SPACES
408     message_right = message_left + l.width
409
410     ## calculate leftmost colum
411     left = if force_alignment # force mode: align exactly
412       message_left
413     else # regular: minimize cursor movement
414       ## leftmost and rightmost are boundaries of all valid left-column
415       ## alignments.
416       leftmost = [message_left, message_right - buffer.content_width + 1].min
417       rightmost = message_left
418       leftcol.clamp(leftmost, rightmost)
419     end
420
421     jump_to_line l.top    # move vertically
422     jump_to_col left      # move horizontally
423     set_cursor_pos l.top  # set cursor pos
424   end
425
426   def expand_all_messages
427     @global_message_state ||= :closed
428     @global_message_state = (@global_message_state == :closed ? :open : :closed)
429     @layout.each { |m, l| l.state = @global_message_state }
430     update
431   end
432
433   def collapse_non_new_messages
434     @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
435     update
436   end
437
438   def expand_all_quotes
439     if(m = @message_lines[curpos])
440       quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
441       numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
442       newstate = numopen > quotes.length / 2 ? :closed : :open
443       quotes.each { |c| @chunk_layout[c].state = newstate }
444       update
445     end
446   end
447
448   def cleanup
449     @layout = @chunk_layout = @text = nil # for good luck
450   end
451
452   def archive_and_kill; archive_and_then :kill end
453   def spam_and_kill; spam_and_then :kill end
454   def delete_and_kill; delete_and_then :kill end
455   def unread_and_kill; unread_and_then :kill end
456
457   def archive_and_next; archive_and_then :next end
458   def spam_and_next; spam_and_then :next end
459   def delete_and_next; delete_and_then :next end
460   def unread_and_next; unread_and_then :next end
461   def do_nothing_and_next; do_nothing_and_then :next end
462
463   def archive_and_prev; archive_and_then :prev end
464   def spam_and_prev; spam_and_then :prev end
465   def delete_and_prev; delete_and_then :prev end
466   def unread_and_prev; unread_and_then :prev end
467   def do_nothing_and_prev; do_nothing_and_then :prev end
468
469   def archive_and_then op
470     dispatch op do
471       @thread.remove_label :inbox
472       UpdateManager.relay self, :archived, @thread.first
473     end
474   end
475
476   def spam_and_then op
477     dispatch op do
478       @thread.apply_label :spam
479       UpdateManager.relay self, :spammed, @thread.first
480     end
481   end
482
483   def delete_and_then op
484     dispatch op do
485       @thread.apply_label :deleted
486       UpdateManager.relay self, :deleted, @thread.first
487     end
488   end
489
490   def unread_and_then op
491     dispatch op do
492       @thread.apply_label :unread
493       UpdateManager.relay self, :unread, @thread.first
494     end
495   end
496
497   def do_nothing_and_then op
498     dispatch op
499   end
500
501   def dispatch op
502     return if @dying
503     @dying = true
504
505     l = lambda do
506       yield if block_given?
507       BufferManager.kill_buffer_safely buffer
508     end
509
510     case op
511     when :next
512       @index_mode.launch_next_thread_after @thread, &l
513     when :prev
514       @index_mode.launch_prev_thread_before @thread, &l
515     when :kill
516       l.call
517     else
518       raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
519     end
520   end
521   private :dispatch
522
523   def pipe_message
524     chunk = @chunk_lines[curpos]
525     chunk = nil unless chunk.is_a?(Chunk::Attachment)
526     message = @message_lines[curpos] unless chunk
527
528     return unless chunk || message
529
530     command = BufferManager.ask(:shell, "pipe command: ")
531     return if command.nil? || command.empty?
532
533     output = pipe_to_process(command) do |stream|
534       if chunk
535         stream.print chunk.raw_content
536       else
537         message.each_raw_message_line { |l| stream.print l }
538       end
539     end
540
541     if output
542       BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
543     else
544       BufferManager.flash "'#{command}' done!"
545     end
546   end
547
548 private
549
550   def initial_state_for m
551     if m.has_label?(:starred) || m.has_label?(:unread)
552       :open
553     else
554       :closed
555     end
556   end
557
558   def update
559     regen_text
560     buffer.mark_dirty if buffer
561   end
562
563   ## here we generate the actual content lines. we accumulate
564   ## everything into @text, and we set @chunk_lines and
565   ## @message_lines, and we update @layout.
566   def regen_text
567     @text = []
568     @chunk_lines = []
569     @message_lines = []
570     @person_lines = []
571
572     prevm = nil
573     @thread.each do |m, depth, parent|
574       unless m.is_a? Message # handle nil and :fake_root
575         @text += chunk_to_lines m, nil, @text.length, depth, parent
576         next
577       end
578       l = @layout[m]
579
580       ## is this still necessary?
581       next unless @layout[m].state # skip discarded drafts
582
583       ## build the patina
584       text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
585       
586       l.top = @text.length
587       l.bot = @text.length + text.length # updated below
588       l.prev = prevm
589       l.next = nil
590       l.depth = depth
591       # l.state we preserve
592       l.width = 0 # updated below
593       @layout[l.prev].next = m if l.prev
594
595       (0 ... text.length).each do |i|
596         @chunk_lines[@text.length + i] = m
597         @message_lines[@text.length + i] = m
598         lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
599       end
600
601       @text += text
602       prevm = m 
603       if l.state != :closed
604         m.chunks.each do |c|
605           cl = @chunk_layout[c]
606
607           ## set the default state for chunks
608           cl.state ||=
609             if c.expandable? && c.respond_to?(:initial_state)
610               c.initial_state
611             else
612               :closed
613             end
614
615           text = chunk_to_lines c, cl.state, @text.length, depth
616           (0 ... text.length).each do |i|
617             @chunk_lines[@text.length + i] = c
618             @message_lines[@text.length + i] = m
619             lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
620             l.width = lw if lw > l.width
621           end
622           @text += text
623         end
624         @layout[m].bot = @text.length
625       end
626     end
627   end
628
629   def message_patina_lines m, state, start, parent, prefix, color, star_color
630     prefix_widget = [color, prefix]
631
632     open_widget = [color, (state == :closed ? "+ " : "- ")]
633     new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
634     starred_widget = if m.has_label?(:starred)
635         [star_color, "*"]
636       else
637         [color, " "]
638       end
639     attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
640
641     case state
642     when :open
643       @person_lines[start] = m.from
644       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
645         [color, 
646             "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
647
648     when :closed
649       @person_lines[start] = m.from
650       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
651         [color, 
652         "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
653
654     when :detailed
655       @person_lines[start] = m.from
656       from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
657           [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
658
659       addressee_lines = []
660       unless m.to.empty?
661         m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
662         addressee_lines += format_person_list "   To: ", m.to
663       end
664       unless m.cc.empty?
665         m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
666         addressee_lines += format_person_list "   Cc: ", m.cc
667       end
668       unless m.bcc.empty?
669         m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
670         addressee_lines += format_person_list "   Bcc: ", m.bcc
671       end
672
673       headers = OrderedHash.new
674       headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
675       headers["Subject"] = m.subj
676
677       show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
678       unless show_labels.empty?
679         headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
680       end
681       if parent
682         headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
683       end
684
685       HookManager.run "detailed-headers", :message => m, :headers => headers
686       
687       from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
688     end
689   end
690
691   def format_person_list prefix, people
692     ptext = people.map { |p| format_person p }
693     pad = " " * prefix.display_length
694     [prefix + ptext.first + (ptext.length > 1 ? "," : "")] + 
695       ptext[1 .. -1].map_with_index do |e, i|
696         pad + e + (i == ptext.length - 1 ? "" : ",")
697       end
698   end
699
700   def format_person p
701     p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
702   end
703
704   ## todo: check arguments on this overly complex function
705   def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
706     prefix = " " * INDENT_SPACES * depth
707     case chunk
708     when :fake_root
709       [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
710     when nil
711       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
712     when Message
713       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
714         (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
715
716     else
717       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
718       if chunk.inlineable?
719         chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
720       elsif chunk.expandable?
721         case state
722         when :closed
723           [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
724         when :open
725           [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
726         end
727       else
728         [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
729       end
730     end
731   end
732
733   def view chunk
734     BufferManager.flash "viewing #{chunk.content_type} attachment..."
735     success = chunk.view!
736     BufferManager.erase_flash
737     BufferManager.completely_redraw_screen
738     unless success
739       BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
740       BufferManager.flash "Couldn't execute view command, viewing as text."
741     end
742   end
743 end
744
745 end