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