]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-view-mode.rb
7a37899242cd65e89fdfab55277052cd97658a0c
[sup] / lib / sup / modes / thread-view-mode.rb
1 module Redwood
2
3 class ThreadViewMode < LineCursorMode
4   DATE_FORMAT = "%B %e %Y %l:%M%P"
5
6   register_keymap do |k|
7     k.add :toggle_detailed_header, "Toggle detailed header", 'd'
8     k.add :show_header, "Show full message header", 'H'
9     k.add :toggle_expanded, "Expand/collapse item", :enter
10     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
11     k.add :edit_message, "Edit message (drafts only)", 'e'
12     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
13     k.add :jump_to_next_open, "Jump to next open message", 'n'
14     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
15     k.add :toggle_starred, "Star or unstar message", '*'
16     k.add :collapse_non_new_messages, "Collapse all but new messages", 'N'
17     k.add :reply, "Reply to a message", 'r'
18     k.add :forward, "Forward a message", 'f'
19     k.add :save_to_disk, "Save message/attachment to disk", 's'
20   end
21
22   def initialize thread, hidden_labels=[]
23     super()
24     @thread = thread
25     @state = {}
26     @hidden_labels = hidden_labels
27
28     earliest = nil
29     latest = nil
30     latest_date = nil
31     @thread.each do |m, d, p|
32       next unless m
33       earliest ||= m
34       @state[m] = 
35         if m.has_label?(:unread) && m == earliest
36           :detailed
37         elsif m.has_label?(:starred) || m.has_label?(:unread)
38           :open
39         else
40           :closed
41         end
42       if latest_date.nil? || m.date > latest_date
43         latest_date = m.date
44         latest = m
45       end
46     end
47     @state[latest] = :open if @state[latest] == :closed
48
49     regen_chunks
50     regen_text
51   end
52
53   def draw_line ln, opts={}
54     if ln == curpos
55       super ln, :highlight => true
56     else
57       super
58     end
59   end
60   def lines; @text.length; end
61   def [] i; @text[i]; end
62
63   def show_header
64     return unless(m = @message_lines[curpos])
65     BufferManager.spawn_unless_exists("Full header") do
66       TextMode.new m.raw_header
67     end
68   end
69
70   def toggle_detailed_header
71     return unless(m = @message_lines[curpos])
72     @state[m] = (@state[m] == :detailed ? :open : :detailed)
73     update
74   end
75
76   def reply
77     return unless(m = @message_lines[curpos])
78     mode = ReplyMode.new m
79     BufferManager.spawn "Reply to #{m.subj}", mode
80   end
81
82   def forward
83     return unless(m = @message_lines[curpos])
84     mode = ForwardMode.new m
85     BufferManager.spawn "Forward of #{m.subj}", mode
86     mode.edit
87   end
88
89   def toggle_starred
90     return unless(m = @message_lines[curpos])
91     if m.has_label? :starred
92       m.remove_label :starred
93     else
94       m.add_label :starred
95     end
96     ## TODO: don't recalculate EVERYTHING just to add a stupid little
97     ## star to the display
98     update
99     UpdateManager.relay :starred, m
100   end
101
102   def toggle_expanded
103     return unless(chunk = @chunk_lines[curpos])
104     case chunk
105     when Message, Message::Quote, Message::Signature
106       @state[chunk] = (@state[chunk] != :closed ? :closed : :open)
107     when Message::Attachment
108       view_attachment chunk
109     end
110     update
111   end
112
113   def save fn
114     if File.exists? fn
115       return unless BufferManager.ask_yes_or_no "File exists. Overwrite?"
116     end
117     begin
118       File.open(fn, "w") { |f| yield f }
119       BufferManager.flash "Successfully wrote #{fn}."
120     rescue SystemCallError => e
121       BufferManager.flash "Error writing to file: #{e.message}"
122     end
123   end
124   private :save
125
126   def save_to_disk
127     return unless(chunk = @chunk_lines[curpos])
128     case chunk
129     when Message::Attachment
130       fn = BufferManager.ask :filename, "save attachment to file: ", chunk.filename
131       save(fn) { |f| f.print chunk } if fn
132     else
133       m = @message_lines[curpos]
134       fn = BufferManager.ask :filename, "save message to file: "
135       save(fn) { |f| f.print m.raw_full_message } if fn
136     end
137   end
138
139   def edit_message
140     return unless(m = @message_lines[curpos])
141     if m.is_draft?
142       mode = ResumeMode.new m
143       BufferManager.spawn "Edit message", mode
144     else
145       BufferManager.flash "Not a draft message!"
146     end
147   end
148
149   def jump_to_next_open
150     return unless(m = @message_lines[curpos])
151     while nextm = @messages[m][3]
152       break if @state[nextm] == :open
153       m = nextm
154     end
155     jump_to_message nextm if nextm
156   end
157
158   def jump_to_prev_open
159     return unless(m = @message_lines[curpos])
160     ## jump to the top of the current message if we're in the body;
161     ## otherwise, to the previous message
162     top = @messages[m][0]
163     if curpos == top
164       while prevm = @messages[m][2]
165         break if @state[prevm] == :open
166         m = prevm
167       end
168       jump_to_message prevm if prevm
169     else
170       jump_to_message m
171     end
172   end
173
174   def jump_to_message m
175     top, bot, prevm, nextm = @messages[m]
176     jump_to_line top unless top >= topline &&
177       top <= botline && bot >= topline && bot <= botline
178     set_cursor_pos top
179   end
180
181   def expand_all_messages
182     @global_message_state ||= :closed
183     @global_message_state = (@global_message_state == :closed ? :open : :closed)
184     @state.each { |m, v| @state[m] = @global_message_state if m.is_a? Message }
185     update
186   end
187
188
189   def collapse_non_new_messages
190     @messages.each { |m, v| @state[m] = m.has_label?(:unread) ? :open : :closed }
191     update
192   end
193
194   def expand_all_quotes
195     if(m = @message_lines[curpos])
196       quotes = @chunks[m].select { |c| c.is_a?(Message::Quote) || c.is_a?(Message::Signature) }
197       open, closed = quotes.partition { |c| @state[c] == :open }
198       newstate = open.length > closed.length ? :closed : :open
199       Redwood::log "#{open.length} opened, #{closed.length} closed, new state is thus #{newstate}"
200       quotes.each { |c| @state[c] = newstate }
201       update
202     end
203   end
204
205   ## not sure if this is really necessary but we might as well...
206   def cleanup
207     @thread.each do |m, d, p|
208       if m && m.has_label?(:unread)
209         m.remove_label :unread 
210         UpdateManager.relay :read, m
211       end
212     end
213
214     Redwood::log "releasing chunks and text from \"#{buffer.title}\""
215     @messages = @chunks = @text = nil
216   end
217
218 private 
219
220   def update
221     regen_text
222     buffer.mark_dirty if buffer
223   end
224
225   def regen_chunks
226     @chunks = {}
227     @thread.each { |m, d, p| @chunks[m] = m.to_chunks if m.is_a?(Message) }
228   end
229   
230   def regen_text
231     @text = []
232     @chunk_lines = []
233     @message_lines = []
234     @messages = {}
235
236     prev_m = nil
237     @thread.each do |m, depth, parent|
238       text = chunk_to_lines m, @state[m], @text.length, depth, parent
239       (0 ... text.length).each do |i|
240         @chunk_lines[@text.length + i] = m
241         @message_lines[@text.length + i] = m
242       end
243
244       @messages[m] = [@text.length, @text.length + text.length, prev_m, nil]
245       @messages[prev_m][3] = m if prev_m
246       prev_m = m
247
248       @text += text
249       if @state[m] != :closed && @chunks.member?(m)
250         @chunks[m].each do |c|
251           @state[c] ||= :closed
252           text = chunk_to_lines c, @state[c], @text.length, depth
253           (0 ... text.length).each do |i|
254             @chunk_lines[@text.length + i] = c
255             @message_lines[@text.length + i] = m
256           end
257           @text += text
258         end
259         @messages[m][1] = @text.length
260       end
261     end
262   end
263
264   def message_patina_lines m, state, parent, prefix
265     prefix_widget = [:message_patina_color, prefix]
266     widget = 
267       case state
268       when :closed
269         [:message_patina_color, "+ "]
270       when :open, :detailed
271         [:message_patina_color, "- "]
272       end
273     imp_widget = 
274       if m.has_label?(:starred)
275         [:starred_patina_color, "* "]
276       else
277         [:message_patina_color, "  "]
278       end
279
280     case state
281     when :open
282       [[prefix_widget, widget, imp_widget,
283         [:message_patina_color, 
284             "#{m.from ? m.from.mediumname : '?'} to #{m.to.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
285 #        (m.to.empty? ? [] : [[[:message_patina_color, prefix + "    To: " + m.recipients.map { |x| x.mediumname }.join(", ")]]]) +
286     when :closed
287       [[prefix_widget, widget, imp_widget,
288         [:message_patina_color, 
289         "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
290     when :detailed
291       labels = m.labels# - @hidden_labels
292       x = [[prefix_widget, widget, imp_widget, [:message_patina_color, "From: #{m.from ? m.from.longname : '?'}"]]] +
293         ((m.to.empty? ? [] : break_into_lines("  To: ", m.to.map { |x| x.longname })) +
294               (m.cc.empty? ? [] : break_into_lines("  Cc: ", m.cc.map { |x| x.longname })) +
295               (m.bcc.empty? ? [] : break_into_lines("  Bcc: ", m.bcc.map { |x| x.longname })) +
296               ["  Date: #{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"] +
297               ["  Subject: #{m.subj}"] +
298               [(parent ? "  In reply to: #{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}" : nil)] +
299               [labels.empty? ? nil : "  Labels: #{labels.join(', ')}"]
300             ).flatten.compact.map { |l| [[:message_patina_color, prefix + "  " + l]] }
301       #raise x.inspect
302       x
303     end
304   end
305
306   def break_into_lines prefix, list
307     pad = " " * prefix.length
308     [prefix + list.first + (list.length > 1 ? "," : "")] + 
309       list[1 .. -1].map_with_index do |e, i|
310         pad + e + (i == list.length - 1 ? "" : ",")
311       end
312   end
313
314
315   def chunk_to_lines chunk, state, start, depth, parent=nil
316     prefix = "  " * depth
317     case chunk
318     when :fake_root
319       [[[:message_patina_color, "#{prefix}<one or more unreceived messages>"]]]
320     when nil
321       [[[:message_patina_color, "#{prefix}<an unreceived message>"]]]
322     when Message
323       message_patina_lines(chunk, state, parent, prefix) +
324         (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
325
326     when Message::Attachment
327       [[[:mime_color, "#{prefix}+ MIME attachment #{chunk.content_type}#{chunk.desc ? ' (' + chunk.desc + ')': ''}"]]]
328     when Message::Text
329       t = chunk.lines
330       if t.last =~ /^\s*$/
331         t.pop while t[t.length - 2] =~ /^\s*$/
332       end
333       t.map { |line| [[:none, "#{prefix}#{line}"]] }
334     when Message::Quote
335       case state
336       when :closed
337         [[[:quote_patina_color, "#{prefix}+ #{chunk.lines.length} quoted lines"]]]
338       when :open
339         t = chunk.lines
340         [[[:quote_patina_color, "#{prefix}- #{chunk.lines.length} quoted lines"]]] +
341            t.map { |line| [[:quote_color, "#{prefix}#{line}"]] }
342       end
343     when Message::Signature
344       case state
345       when :closed
346         [[[:sig_patina_color, "#{prefix}+ #{chunk.lines.length}-line signature"]]]
347       when :open
348         t = chunk.lines
349         [[[:sig_patina_color, "#{prefix}- #{chunk.lines.length}-line signature"]]] +
350            t.map { |line| [[:sig_color, "#{prefix}#{line}"]] }
351       end
352     else
353       raise "unknown chunk type #{chunk.class.name}"
354     end
355   end
356
357   def view_attachment a
358     BufferManager.flash "viewing #{a.content_type} attachment..."
359     a.view!
360     BufferManager.erase_flash
361     BufferManager.completely_redraw_screen
362   end
363
364 end
365
366 end