]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-index-mode.rb
Merge branch 'console-mode'
[sup] / lib / sup / modes / thread-index-mode.rb
1 require 'set'
2
3 module Redwood
4
5 ## subclasses should implement:
6 ## - is_relevant?
7
8 class ThreadIndexMode < LineCursorMode
9   DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
10   MIN_FROM_WIDTH = 15
11   LOAD_MORE_THREAD_NUM = 20
12
13   HookManager.register "index-mode-size-widget", <<EOS
14 Generates the per-thread size widget for each thread.
15 Variables:
16   thread: The message thread to be formatted.
17 EOS
18
19   HookManager.register "mark-as-spam", <<EOS
20 This hook is run when a thread is marked as spam
21 Variables:
22   thread: The message thread being marked as spam.
23 EOS
24
25   register_keymap do |k|
26     k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
27     k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
28       kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
29     end
30     k.add :cancel_search, "Cancel current search", :ctrl_g
31     k.add :reload, "Refresh view", '@'
32     k.add :toggle_archived, "Toggle archived status", 'a'
33     k.add :toggle_starred, "Star or unstar all messages in thread", '*'
34     k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
35     k.add :edit_labels, "Edit or add labels for a thread", 'l'
36     k.add :edit_message, "Edit message (drafts only)", 'e'
37     k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
38     k.add :toggle_deleted, "Delete/undelete thread", 'd'
39     k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
40     k.add :save, "Save changes now", '$'
41     k.add :jump_to_next_new, "Jump to next new thread", :tab
42     k.add :reply, "Reply to latest message in a thread", 'r'
43     k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
44     k.add :forward, "Forward latest message in a thread", 'f'
45     k.add :toggle_tagged, "Tag/untag selected thread", 't'
46     k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
47     k.add :tag_matching, "Tag matching threads", 'g'
48     k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
49     k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
50     k.add :undo, "Undo the previous action", 'u'
51   end
52
53   def initialize hidden_labels=[], load_thread_opts={}
54     super()
55     @mutex = Mutex.new # covers the following variables:
56     @threads = {}
57     @hidden_threads = {}
58     @size_widget_width = nil
59     @size_widgets = {}
60     @tags = Tagger.new self
61
62     ## these guys, and @text and @lines, are not covered
63     @load_thread = nil
64     @load_thread_opts = load_thread_opts
65     @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
66     @date_width = DATE_WIDTH
67
68     @interrupt_search = false
69     
70     initialize_threads # defines @ts and @ts_mutex
71     update # defines @text and @lines
72
73     UpdateManager.register self
74
75     @save_thread_mutex = Mutex.new
76
77     @last_load_more_size = nil
78     to_load_more do |size|
79       next if @last_load_more_size == 0
80       load_threads :num => 1, :background => false
81       load_threads :num => (size - 1),
82                    :when_done => lambda { |num| @last_load_more_size = num }
83     end
84   end
85
86   def unsaved?; dirty? end
87   def lines; @text.length; end
88   def [] i; @text[i]; end
89   def contains_thread? t; @threads.include?(t) end
90
91   def reload
92     drop_all_threads
93     UndoManager.clear
94     BufferManager.draw_screen
95     load_threads :num => buffer.content_height
96   end
97
98   ## open up a thread view window
99   def select t=nil, when_done=nil
100     t ||= cursor_thread or return
101
102     Redwood::reporting_thread("load messages for thread-view-mode") do
103       num = t.size
104       message = "Loading #{num.pluralize 'message body'}..."
105       BufferManager.say(message) do |sid|
106         t.each_with_index do |(m, *o), i|
107           next unless m
108           BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
109           m.load_from_source! 
110         end
111       end
112       mode = ThreadViewMode.new t, @hidden_labels, self
113       BufferManager.spawn t.subj, mode
114       BufferManager.draw_screen
115       mode.jump_to_first_open true
116       BufferManager.draw_screen # lame TODO: make this unnecessary
117       ## the first draw_screen is needed before topline and botline
118       ## are set, and the second to show the cursor having moved
119
120       update_text_for_line curpos
121       UpdateManager.relay self, :read, t.first
122       when_done.call if when_done
123     end
124   end
125
126   def multi_select threads
127     threads.each { |t| select t }
128   end
129
130   ## these two methods are called by thread-view-modes when the user
131   ## wants to view the previous/next thread without going back to
132   ## index-mode. we update the cursor as a convenience.
133   def launch_next_thread_after thread, &b
134     launch_another_thread thread, 1, &b
135   end
136
137   def launch_prev_thread_before thread, &b
138     launch_another_thread thread, -1, &b
139   end
140
141   def launch_another_thread thread, direction, &b
142     l = @lines[thread] or return
143     target_l = l + direction
144     t = @mutex.synchronize do
145       if target_l >= 0 && target_l < @threads.length
146         @threads[target_l]
147       end
148     end
149
150     if t # there's a next thread
151       set_cursor_pos target_l # move out of mutex?
152       select t, b
153     elsif b # no next thread. call the block anyways
154       b.call
155     end
156   end
157   
158   def handle_single_message_labeled_update sender, m
159     ## no need to do anything different here; we don't differentiate 
160     ## messages from their containing threads
161     handle_labeled_update sender, m
162   end
163
164   def handle_labeled_update sender, m
165     if(t = thread_containing(m)) 
166       l = @lines[t] or return
167       update_text_for_line l
168     elsif is_relevant?(m)
169       add_or_unhide m
170     end
171   end
172
173   def handle_simple_update sender, m
174     t = thread_containing(m) or return
175     l = @lines[t] or return
176     update_text_for_line l
177   end
178
179   %w(read unread archived starred unstarred).each do |state|
180     define_method "handle_#{state}_update" do |*a|
181       handle_simple_update(*a)
182     end
183   end
184
185   ## overwrite me!
186   def is_relevant? m; false; end
187
188   def handle_added_update sender, m
189     add_or_unhide m
190     BufferManager.draw_screen
191   end
192
193   def handle_single_message_deleted_update sender, m
194     @ts_mutex.synchronize do
195       return unless @ts.contains? m
196       @ts.remove_id m.id
197     end
198     update
199   end
200
201   def handle_deleted_update sender, m
202     t = @ts_mutex.synchronize { @ts.thread_for m }
203     return unless t
204     hide_thread t
205     update
206   end
207
208   def handle_spammed_update sender, m
209     t = @ts_mutex.synchronize { @ts.thread_for m }
210     return unless t
211     hide_thread t
212     update
213   end
214
215   def handle_undeleted_update sender, m
216     add_or_unhide m
217   end
218
219   def undo
220     UndoManager.undo
221   end
222
223   def update
224     @mutex.synchronize do
225       ## let's see you do THIS in python
226       @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| [t.date, t.first.id] }.reverse
227       @size_widgets = @threads.map { |t| size_widget_for_thread t }
228       @size_widget_width = @size_widgets.max_of { |w| w.display_length }
229     end
230
231     regen_text
232   end
233
234   def edit_message
235     return unless(t = cursor_thread)
236     message, *crap = t.find { |m, *o| m.has_label? :draft }
237     if message
238       mode = ResumeMode.new message
239       BufferManager.spawn "Edit message", mode
240     else
241       BufferManager.flash "Not a draft message!"
242     end
243   end
244
245   ## returns an undo lambda
246   def actually_toggle_starred t
247     pos = curpos
248     if t.has_label? :starred # if ANY message has a star
249       t.remove_label :starred # remove from all
250       UpdateManager.relay self, :unstarred, t.first
251       lambda do
252         t.first.add_label :starred
253         UpdateManager.relay self, :starred, t.first
254         regen_text
255       end
256     else
257       t.first.add_label :starred # add only to first
258       UpdateManager.relay self, :starred, t.first
259       lambda do
260         t.remove_label :starred
261         UpdateManager.relay self, :unstarred, t.first
262         regen_text
263       end
264     end
265   end  
266
267   def toggle_starred 
268     t = cursor_thread or return
269     undo = actually_toggle_starred t
270     UndoManager.register "toggling thread starred status", undo
271     update_text_for_line curpos
272     cursor_down
273   end
274
275   def multi_toggle_starred threads
276     UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
277       threads.map { |t| actually_toggle_starred t }
278     regen_text
279   end
280
281   ## returns an undo lambda
282   def actually_toggle_archived t
283     thread = t
284     pos = curpos
285     if t.has_label? :inbox
286       t.remove_label :inbox
287       UpdateManager.relay self, :archived, t.first
288       lambda do
289         thread.apply_label :inbox
290         update_text_for_line pos
291         UpdateManager.relay self,:unarchived, thread.first
292       end
293     else
294       t.apply_label :inbox
295       UpdateManager.relay self, :unarchived, t.first
296       lambda do
297         thread.remove_label :inbox
298         update_text_for_line pos
299         UpdateManager.relay self, :unarchived, thread.first
300       end
301     end
302   end
303
304   ## returns an undo lambda
305   def actually_toggle_spammed t
306     thread = t
307     if t.has_label? :spam
308       t.remove_label :spam
309       add_or_unhide t.first
310       UpdateManager.relay self, :unspammed, t.first
311       lambda do
312         thread.apply_label :spam
313         self.hide_thread thread
314         UpdateManager.relay self,:spammed, thread.first
315       end
316     else
317       t.apply_label :spam
318       hide_thread t
319       UpdateManager.relay self, :spammed, t.first
320       lambda do
321         thread.remove_label :spam
322         add_or_unhide thread.first
323         UpdateManager.relay self,:unspammed, thread.first
324       end
325     end
326   end
327
328   ## returns an undo lambda
329   def actually_toggle_deleted t
330     if t.has_label? :deleted
331       t.remove_label :deleted
332       add_or_unhide t.first
333       UpdateManager.relay self, :undeleted, t.first
334       lambda do
335         t.apply_label :deleted
336         hide_thread t
337         UpdateManager.relay self, :deleted, t.first
338       end
339     else
340       t.apply_label :deleted
341       hide_thread t
342       UpdateManager.relay self, :deleted, t.first
343       lambda do
344         t.remove_label :deleted
345         add_or_unhide t.first
346         UpdateManager.relay self, :undeleted, t.first
347       end
348     end
349   end
350
351   def toggle_archived 
352     t = cursor_thread or return
353     undo = actually_toggle_archived t
354     UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }
355     update_text_for_line curpos
356   end
357
358   def multi_toggle_archived threads
359     undos = threads.map { |t| actually_toggle_archived t }
360     UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }
361     regen_text
362   end
363
364   def toggle_new
365     t = cursor_thread or return
366     t.toggle_label :unread
367     update_text_for_line curpos
368     cursor_down
369   end
370
371   def multi_toggle_new threads
372     threads.each { |t| t.toggle_label :unread }
373     regen_text
374   end
375
376   def multi_toggle_tagged threads
377     @mutex.synchronize { @tags.drop_all_tags }
378     regen_text
379   end
380
381   def join_threads
382     ## this command has no non-tagged form. as a convenience, allow this
383     ## command to be applied to tagged threads without hitting ';'.
384     @tags.apply_to_tagged :join_threads
385   end
386
387   def multi_join_threads threads
388     @ts.join_threads threads or return
389     @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
390     update
391   end
392
393   def jump_to_next_new
394     n = @mutex.synchronize do
395       ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
396         (0 ... curpos).find { |i| @threads[i].has_label? :unread }
397     end
398     if n
399       ## jump there if necessary
400       jump_to_line n unless n >= topline && n < botline
401       set_cursor_pos n
402     else
403       BufferManager.flash "No new messages"
404     end
405   end
406
407   def toggle_spam
408     t = cursor_thread or return
409     multi_toggle_spam [t]
410     HookManager.run("mark-as-spam", :thread => t)
411   end
412
413   ## both spam and deleted have the curious characteristic that you
414   ## always want to hide the thread after either applying or removing
415   ## that label. in all thread-index-views except for
416   ## label-search-results-mode, when you mark a message as spam or
417   ## deleted, you want it to disappear immediately; in LSRM, you only
418   ## see deleted or spam emails, and when you undelete or unspam them
419   ## you also want them to disappear immediately.
420   def multi_toggle_spam threads
421     undos = threads.map { |t| actually_toggle_spammed t }
422     UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
423                          undos, lambda { regen_text }
424     regen_text
425   end
426
427   def toggle_deleted
428     t = cursor_thread or return
429     multi_toggle_deleted [t]
430   end
431
432   ## see comment for multi_toggle_spam
433   def multi_toggle_deleted threads
434     undos = threads.map { |t| actually_toggle_deleted t }
435     UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
436                          undos, lambda { regen_text }
437     regen_text
438   end
439
440   def kill
441     t = cursor_thread or return
442     multi_kill [t]
443   end
444
445   ## m-m-m-m-MULTI-KILL
446   def multi_kill threads
447     UndoManager.register "killing #{threads.size.pluralize 'thread'}" do
448       threads.each do |t|
449         t.remove_label :killed
450         add_or_unhide t.first
451       end
452       regen_text
453     end
454
455     threads.each do |t|
456       t.apply_label :killed
457       hide_thread t
458     end
459
460     regen_text
461     BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
462   end
463
464   def save background=true
465     if background
466       Redwood::reporting_thread("saving thread") { actually_save }
467     else
468       actually_save
469     end
470   end
471
472   def actually_save
473     @save_thread_mutex.synchronize do
474       BufferManager.say("Saving contacts...") { ContactManager.instance.save }
475       dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
476       next if dirty_threads.empty?
477
478       BufferManager.say("Saving threads...") do |say_id|
479         dirty_threads.each_with_index do |t, i|
480           BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
481           t.save_state Index
482         end
483       end
484     end
485   end
486
487   def cleanup
488     UpdateManager.unregister self
489
490     if @load_thread
491       @load_thread.kill 
492       BufferManager.clear @mbid if @mbid
493       sleep 0.1 # TODO: necessary?
494       BufferManager.erase_flash
495     end
496     save false
497     super
498   end
499
500   def toggle_tagged
501     t = cursor_thread or return
502     @mutex.synchronize { @tags.toggle_tag_for t }
503     update_text_for_line curpos
504     cursor_down
505   end
506   
507   def toggle_tagged_all
508     @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
509     regen_text
510   end
511
512   def tag_matching
513     query = BufferManager.ask :search, "tag threads matching (regex): "
514     return if query.nil? || query.empty?
515     query = begin
516       /#{query}/i
517     rescue RegexpError => e
518       BufferManager.flash "error interpreting '#{query}': #{e.message}"
519       return
520     end
521     @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
522     regen_text
523   end
524
525   def apply_to_tagged; @tags.apply_to_tagged; end
526
527   def edit_labels
528     thread = cursor_thread or return
529     speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
530
531     old_labels = thread.labels
532     pos = curpos
533
534     keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
535
536     user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
537     return unless user_labels
538
539     thread.labels = Set.new(keepl) + user_labels
540     user_labels.each { |l| LabelManager << l }
541     update_text_for_line curpos
542
543     UndoManager.register "labeling thread" do
544       thread.labels = old_labels
545       update_text_for_line pos
546       UpdateManager.relay self, :labeled, thread.first
547     end
548
549     UpdateManager.relay self, :labeled, thread.first
550   end
551
552   def multi_edit_labels threads
553     user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
554     return unless user_labels
555
556     user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
557     hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
558     unless hl.empty?
559       BufferManager.flash "'#{hl}' is a reserved label!"
560       return
561     end
562
563     old_labels = threads.map { |t| t.labels.dup }
564
565     threads.each do |t|
566       user_labels.each do |(l, to_remove)|
567         if to_remove
568           t.remove_label l
569         else
570           t.apply_label l
571           LabelManager << l
572         end
573       end
574       UpdateManager.relay self, :labeled, t.first
575     end
576
577     regen_text
578
579     UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
580       threads.zip(old_labels).map do |t, old_labels|
581         t.labels = old_labels
582         UpdateManager.relay self, :labeled, t.first
583       end
584       regen_text
585     end
586   end
587
588   def reply type_arg=nil
589     t = cursor_thread or return
590     m = t.latest_message
591     return if m.nil? # probably won't happen
592     m.load_from_source!
593     mode = ReplyMode.new m, type_arg
594     BufferManager.spawn "Reply to #{m.subj}", mode
595   end
596
597   def reply_all; reply :all; end
598
599   def forward
600     t = cursor_thread or return
601     m = t.latest_message
602     return if m.nil? # probably won't happen
603     m.load_from_source!
604     ForwardMode.spawn_nicely :message => m
605   end
606
607   def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
608     return if @load_thread # todo: wrap in mutex
609     @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
610       num = load_n_threads n, opts
611       opts[:when_done].call(num) if opts[:when_done]
612       @load_thread = nil
613     end
614   end
615
616   ## TODO: figure out @ts_mutex in this method
617   def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
618     @interrupt_search = false
619     @mbid = BufferManager.say "Searching for threads..."
620
621     ts_to_load = n
622     ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
623
624     orig_size = @ts.size
625     last_update = Time.now
626     @ts.load_n_threads(ts_to_load, opts) do |i|
627       if (Time.now - last_update) >= 0.25
628         BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
629         update
630         BufferManager.draw_screen
631         last_update = Time.now
632       end
633       break if @interrupt_search
634     end
635     @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
636
637     update
638     BufferManager.clear @mbid
639     @mbid = nil
640     BufferManager.draw_screen
641     @ts.size - orig_size
642   end
643   ignore_concurrent_calls :load_n_threads
644
645   def status
646     if (l = lines) == 0
647       "line 0 of 0"
648     else
649       "line #{curpos + 1} of #{l} #{dirty? ? '*modified*' : ''}"
650     end
651   end
652
653   def cancel_search
654     @interrupt_search = true
655   end
656
657   def load_all_threads
658     load_threads :num => -1
659   end
660
661   def load_threads opts={}
662     if opts[:num].nil?
663       n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
664     else
665       n = opts[:num]
666     end
667
668     myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
669       opts[:when_done].call(num) if opts[:when_done]
670
671       if num > 0
672         BufferManager.flash "Found #{num.pluralize 'thread'}."
673       else
674         BufferManager.flash "No matches."
675       end
676     end)})
677
678     if opts[:background] || opts[:background].nil?
679       load_n_threads_background n, myopts
680     else
681       load_n_threads n, myopts
682     end
683   end
684   ignore_concurrent_calls :load_threads
685
686   def resize rows, cols
687     regen_text
688     super
689   end
690
691 protected
692
693   def add_or_unhide m
694     @ts_mutex.synchronize do
695       if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
696         @ts.load_thread_for_message m, @load_thread_opts
697       end
698
699       @hidden_threads.delete @ts.thread_for(m)
700     end
701
702     update
703   end
704
705   def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
706
707   ## used to tag threads by query. this can be made a lot more sophisticated,
708   ## but for right now we'll do the obvious this.
709   def thread_matches? t, query
710     t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
711   end
712
713   def size_widget_for_thread t
714     HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
715   end
716
717   def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
718
719   def drop_all_threads
720     @tags.drop_all_tags
721     initialize_threads
722     update
723   end
724
725   def hide_thread t
726     @mutex.synchronize do
727       i = @threads.index(t) or return
728       raise "already hidden" if @hidden_threads[t]
729       @hidden_threads[t] = true
730       @threads.delete_at i
731       @size_widgets.delete_at i
732       @tags.drop_tag_for t
733     end
734   end
735
736   def update_text_for_line l
737     return unless l # not sure why this happens, but it does, occasionally
738     
739     need_update = false
740
741     @mutex.synchronize do
742       @size_widgets[l] = size_widget_for_thread @threads[l]
743
744       ## if the widget size has increased, we need to redraw everyone
745       need_update = @size_widgets[l].size > @size_widget_width
746     end
747
748     if need_update
749       update
750     else
751       @text[l] = text_for_thread_at l
752       buffer.mark_dirty if buffer
753     end
754   end
755
756   def regen_text
757     threads = @mutex.synchronize { @threads }
758     @text = threads.map_with_index { |t, i| text_for_thread_at i }
759     @lines = threads.map_with_index { |t, i| [t, i] }.to_h
760     buffer.mark_dirty if buffer
761   end
762   
763   def authors; map { |m, *o| m.from if m }.compact.uniq; end
764
765   def author_names_and_newness_for_thread t, limit=nil
766     new = {}
767     authors = Set.new
768     t.each do |m, *o|
769       next unless m
770       break if limit and authors.size >= limit
771
772       name = 
773         if AccountManager.is_account?(m.from)
774           "me"
775         elsif t.authors.size == 1
776           m.from.mediumname
777         else
778           m.from.shortname
779         end
780
781       new[name] ||= m.has_label?(:unread)
782       authors << name
783     end
784
785     authors.to_a.map { |a| [a, new[a]] }
786   end
787
788   AUTHOR_LIMIT = 5
789   def text_for_thread_at line
790     t, size_widget = @mutex.synchronize { [@threads[line], @size_widgets[line]] }
791
792     date = t.date.to_nice_s
793
794     starred = t.has_label? :starred
795
796     ## format the from column
797     cur_width = 0
798     ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
799     from = []
800     ann.each_with_index do |(name, newness), i|
801       break if cur_width >= from_width
802       last = i == ann.length - 1
803
804       abbrev =
805         if cur_width + name.display_length > from_width
806           name[0 ... (from_width - cur_width - 1)] + "."
807         elsif cur_width + name.display_length == from_width
808           name[0 ... (from_width - cur_width)]
809         else
810           if last
811             name[0 ... (from_width - cur_width)]
812           else
813             name[0 ... (from_width - cur_width - 1)] + "," 
814           end
815         end
816
817       cur_width += abbrev.display_length
818
819       if last && from_width > cur_width
820         abbrev += " " * (from_width - cur_width)
821       end
822
823       from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
824     end
825
826     dp = t.direct_participants.any? { |p| AccountManager.is_account? p }
827     p = dp || t.participants.any? { |p| AccountManager.is_account? p }
828
829     subj_color =
830       if t.has_label?(:draft)
831         :index_draft_color
832       elsif t.has_label?(:unread)
833         :index_new_color
834       elsif starred
835         :index_starred_color
836       else 
837         :index_old_color
838       end
839
840     snippet = t.snippet + (t.snippet.empty? ? "" : "...")
841
842     size_widget_text = sprintf "%#{ @size_widget_width}s", size_widget
843
844     [ 
845       [:tagged_color, @tags.tagged?(t) ? ">" : " "],
846       [:none, sprintf("%#{@date_width}s", date)],
847       (starred ? [:starred_color, "*"] : [:none, " "]),
848     ] +
849       from +
850       [
851       [subj_color, size_widget_text],
852       [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
853       [:to_me_color, dp ? ">" : (p ? '+' : " ")],
854     ] +
855       (t.labels - @hidden_labels).map { |label| [:label_color, "#{label} "] } +
856       [
857       [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
858       [:snippet_color, snippet],
859     ]
860   end
861
862   def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
863
864 private
865
866   def default_size_widget_for t
867     case t.size
868     when 1
869       ""
870     else
871       "(#{t.size})"
872     end
873   end
874
875   def from_width
876     [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
877   end
878
879   def initialize_threads
880     @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
881     @ts_mutex = Mutex.new
882     @hidden_threads = {}
883   end
884 end
885
886 end