sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

lib/sup/modes/thread_index_mode.rb (29521B) - raw

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