sup

A curses threads-with-tags style email client

sup.git

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

lib/sup/buffer.rb (21993B) - raw

      1 # encoding: utf-8
      2 
      3 require 'etc'
      4 require 'thread'
      5 require 'ncursesw'
      6 
      7 require 'sup/util/ncurses'
      8 
      9 module Redwood
     10 
     11 class InputSequenceAborted < StandardError; end
     12 
     13 class Buffer
     14   attr_reader :mode, :x, :y, :width, :height, :title, :atime
     15   bool_reader :dirty, :system
     16   bool_accessor :force_to_top, :hidden
     17 
     18   def initialize window, mode, width, height, opts={}
     19     @w = window
     20     @mode = mode
     21     @dirty = true
     22     @focus = false
     23     @title = opts[:title] || ""
     24     @force_to_top = opts[:force_to_top] || false
     25     @hidden = opts[:hidden] || false
     26     @x, @y, @width, @height = 0, 0, width, height
     27     @atime = Time.at 0
     28     @system = opts[:system] || false
     29   end
     30 
     31   def content_height; @height - 1; end
     32   def content_width; @width; end
     33 
     34   def resize rows, cols
     35     return if cols == @width && rows == @height
     36     @width = cols
     37     @height = rows
     38     @dirty = true
     39     mode.resize rows, cols
     40   end
     41 
     42   def redraw status
     43     if @dirty
     44       draw status
     45     else
     46       draw_status status
     47     end
     48 
     49     commit
     50   end
     51 
     52   def mark_dirty; @dirty = true; end
     53 
     54   def commit
     55     @dirty = false
     56     @w.noutrefresh
     57   end
     58 
     59   def draw status
     60     @mode.draw
     61     draw_status status
     62     commit
     63     @atime = Time.now
     64   end
     65 
     66   ## s nil means a blank line!
     67   def write y, x, s, opts={}
     68     return if x >= @width || y >= @height
     69 
     70     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
     71     s ||= ""
     72     maxl = @width - x # maximum display width width
     73 
     74     # fill up the line with blanks to overwrite old screen contents
     75     @w.mvaddstr y, x, " " * maxl unless opts[:no_fill]
     76 
     77     @w.mvaddstr y, x, s.slice_by_display_length(maxl)
     78   end
     79 
     80   def clear
     81     @w.clear
     82   end
     83 
     84   def draw_status status
     85     write @height - 1, 0, status, :color => :status_color
     86   end
     87 
     88   def focus
     89     @focus = true
     90     @dirty = true
     91     @mode.focus
     92   end
     93 
     94   def blur
     95     @focus = false
     96     @dirty = true
     97     @mode.blur
     98   end
     99 end
    100 
    101 class BufferManager
    102   include Redwood::Singleton
    103 
    104   attr_reader :focus_buf
    105 
    106   ## we have to define the key used to continue in-buffer search here, because
    107   ## it has special semantics that BufferManager deals with---current searches
    108   ## are canceled by any keypress except this one.
    109   CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
    110 
    111   HookManager.register "status-bar-text", <<EOS
    112 Sets the status bar. The default status bar contains the mode name, the buffer
    113 title, and the mode status. Note that this will be called at least once per
    114 keystroke, so excessive computation is discouraged.
    115 
    116 Variables:
    117          num_inbox: number of messages in inbox
    118   num_inbox_unread: total number of messages marked as unread
    119          num_total: total number of messages in the index
    120           num_spam: total number of messages marked as spam
    121              title: title of the current buffer
    122               mode: current mode name (string)
    123             status: current mode status (string)
    124 Return value: a string to be used as the status bar.
    125 EOS
    126 
    127   HookManager.register "terminal-title-text", <<EOS
    128 Sets the title of the current terminal, if applicable. Note that this will be
    129 called at least once per keystroke, so excessive computation is discouraged.
    130 
    131 Variables: the same as status-bar-text hook.
    132 Return value: a string to be used as the terminal title.
    133 EOS
    134 
    135   HookManager.register "extra-contact-addresses", <<EOS
    136 A list of extra addresses to propose for tab completion, etc. when the
    137 user is entering an email address. Can be plain email addresses or can
    138 be full "User Name <email@domain.tld>" entries.
    139 
    140 Variables: none
    141 Return value: an array of email address strings.
    142 EOS
    143 
    144   def initialize
    145     @name_map = {}
    146     @buffers = []
    147     @focus_buf = nil
    148     @dirty = true
    149     @minibuf_stack = []
    150     @minibuf_mutex = Mutex.new
    151     @textfields = {}
    152     @flash = nil
    153     @shelled = @asking = false
    154     @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
    155     @sigwinch_happened = false
    156     @sigwinch_mutex = Mutex.new
    157   end
    158 
    159   def sigwinch_happened!
    160     @sigwinch_mutex.synchronize do
    161       return if @sigwinch_happened
    162       @sigwinch_happened = true
    163       Ncurses.ungetch ?\C-l.ord
    164     end
    165   end
    166 
    167   def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
    168 
    169   def buffers; @name_map.to_a; end
    170   def shelled?; @shelled; end
    171 
    172   def focus_on buf
    173     return unless @buffers.member? buf
    174     return if buf == @focus_buf
    175     @focus_buf.blur if @focus_buf
    176     @focus_buf = buf
    177     @focus_buf.focus
    178   end
    179 
    180   def raise_to_front buf
    181     @buffers.delete(buf) or return
    182     if @buffers.length > 0 && @buffers.last.force_to_top?
    183       @buffers.insert(-2, buf)
    184     else
    185       @buffers.push buf
    186     end
    187     focus_on @buffers.last
    188     @dirty = true
    189   end
    190 
    191   ## we reset force_to_top when rolling buffers. this is so that the
    192   ## human can actually still move buffers around, while still
    193   ## programmatically being able to pop stuff up in the middle of
    194   ## drawing a window without worrying about covering it up.
    195   ##
    196   ## if we ever start calling roll_buffers programmatically, we will
    197   ## have to change this. but it's not clear that we will ever actually
    198   ## do that.
    199   def roll_buffers
    200     bufs = rollable_buffers
    201     bufs.last.force_to_top = false
    202     raise_to_front bufs.first
    203   end
    204 
    205   def roll_buffers_backwards
    206     bufs = rollable_buffers
    207     return unless bufs.length > 1
    208     bufs.last.force_to_top = false
    209     raise_to_front bufs[bufs.length - 2]
    210   end
    211 
    212   def rollable_buffers
    213     @buffers.select { |b| !(b.system? || b.hidden?) || @buffers.last == b }
    214   end
    215 
    216   def handle_input c
    217     if @focus_buf
    218       if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY
    219         @focus_buf.mode.cancel_search!
    220         @focus_buf.mark_dirty
    221       end
    222       @focus_buf.mode.handle_input c
    223     end
    224   end
    225 
    226   def exists? n; @name_map.member? n; end
    227   def [] n; @name_map[n]; end
    228   def []= n, b
    229     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
    230     raise ArgumentError, "title must be a string" unless n.is_a? String
    231     @name_map[n] = b
    232   end
    233 
    234   def completely_redraw_screen
    235     return if @shelled
    236 
    237     ## this magic makes Ncurses get the new size of the screen
    238     Ncurses.endwin
    239     Ncurses.stdscr.keypad 1
    240     Ncurses.curs_set 0
    241     Ncurses.refresh
    242     @sigwinch_mutex.synchronize { @sigwinch_happened = false }
    243     debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
    244 
    245     status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
    246 
    247     Ncurses.sync do
    248       @dirty = true
    249       Ncurses.clear
    250       draw_screen :sync => false, :status => status, :title => title
    251     end
    252   end
    253 
    254   def draw_screen opts={}
    255     return if @shelled
    256 
    257     status, title =
    258       if opts.member? :status
    259         [opts[:status], opts[:title]]
    260       else
    261         raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
    262         get_status_and_title @focus_buf # must be called outside of the ncurses lock
    263       end
    264 
    265     ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
    266     print "\033]0;#{title}\07" if title && @in_x
    267 
    268     Ncurses.mutex.lock unless opts[:sync] == false
    269 
    270     ## disabling this for the time being, to help with debugging
    271     ## (currently we only have one buffer visible at a time).
    272     ## TODO: reenable this if we allow multiple buffers
    273     false && @buffers.inject(@dirty) do |dirty, buf|
    274       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
    275       #dirty ? buf.draw : buf.redraw
    276       buf.draw status
    277       dirty
    278     end
    279 
    280     ## quick hack
    281     if true
    282       buf = @buffers.last
    283       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
    284       @dirty ? buf.draw(status) : buf.redraw(status)
    285     end
    286 
    287     draw_minibuf :sync => false unless opts[:skip_minibuf]
    288 
    289     @dirty = false
    290     Ncurses.doupdate
    291     Ncurses.refresh if opts[:refresh]
    292     Ncurses.mutex.unlock unless opts[:sync] == false
    293   end
    294 
    295   ## if the named buffer already exists, pops it to the front without
    296   ## calling the block. otherwise, gets the mode from the block and
    297   ## creates a new buffer. returns two things: the buffer, and a boolean
    298   ## indicating whether it's a new buffer or not.
    299   def spawn_unless_exists title, opts={}
    300     new =
    301       if @name_map.member? title
    302         raise_to_front @name_map[title] unless opts[:hidden]
    303         false
    304       else
    305         mode = yield
    306         spawn title, mode, opts
    307         true
    308       end
    309     [@name_map[title], new]
    310   end
    311 
    312   def spawn title, mode, opts={}
    313     raise ArgumentError, "title must be a string" unless title.is_a? String
    314     realtitle = title
    315     num = 2
    316     while @name_map.member? realtitle
    317       realtitle = "#{title} <#{num}>"
    318       num += 1
    319     end
    320 
    321     width = opts[:width] || Ncurses.cols
    322     height = opts[:height] || Ncurses.rows - 1
    323 
    324     ## since we are currently only doing multiple full-screen modes,
    325     ## use stdscr for each window. once we become more sophisticated,
    326     ## we may need to use a new Ncurses::WINDOW
    327     ##
    328     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
    329     ## (opts[:left] || 0))
    330     w = Ncurses.stdscr
    331     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
    332     mode.buffer = b
    333     mode.spawned
    334     @name_map[realtitle] = b
    335 
    336     @buffers.unshift b
    337     if opts[:hidden]
    338       focus_on b unless @focus_buf
    339     else
    340       raise_to_front b
    341     end
    342     b
    343   end
    344 
    345   ## requires the mode to have #done? and #value methods
    346   def spawn_modal title, mode, opts={}
    347     b = spawn title, mode, opts
    348     draw_screen
    349 
    350     until mode.done?
    351       c = Ncurses::CharCode.get
    352       next unless c.present? # getch timeout
    353       break if c.is_keycode? Ncurses::KEY_CANCEL
    354       begin
    355         mode.handle_input c
    356       rescue InputSequenceAborted # do nothing
    357       end
    358       draw_screen
    359       erase_flash
    360     end
    361 
    362     kill_buffer b
    363     mode.value
    364   end
    365 
    366   def kill_all_buffers_safely
    367     until @buffers.empty?
    368       ## inbox mode always claims it's unkillable. we'll ignore it.
    369       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
    370       kill_buffer @buffers.last
    371     end
    372     true
    373   end
    374 
    375   def kill_buffer_safely buf
    376     return false unless buf.mode.killable?
    377     kill_buffer buf
    378     true
    379   end
    380 
    381   def kill_all_buffers
    382     kill_buffer @buffers.first until @buffers.empty?
    383   end
    384 
    385   def kill_buffer buf
    386     raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
    387 
    388     buf.mode.cleanup
    389     @buffers.delete buf
    390     @name_map.delete buf.title
    391     @focus_buf = nil if @focus_buf == buf
    392     if @buffers.empty?
    393       ## TODO: something intelligent here
    394       ## for now I will simply prohibit killing the inbox buffer.
    395     else
    396       raise_to_front @buffers.last
    397     end
    398   end
    399 
    400   ## ask* functions. these functions display a one-line text field with
    401   ## a prompt at the bottom of the screen. answers typed or choosen by
    402   ## tab-completion
    403   ##
    404   ## common arguments are:
    405   ##
    406   ## domain:      token used as key for @textfields, which seems to be a
    407   ##              dictionary of input field objects
    408   ## question:    string used as prompt
    409   ## completions: array of possible answers, that can be completed by using
    410   ##              the tab key
    411   ## default:     default value to return
    412   def ask_with_completions domain, question, completions, default=nil
    413     ask domain, question, default do |s|
    414       s.fix_encoding!
    415       completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] }
    416     end
    417   end
    418 
    419   def ask_many_with_completions domain, question, completions, default=nil
    420     ask domain, question, default do |partial|
    421       prefix, target =
    422         case partial
    423         when /^\s*$/
    424           ["", ""]
    425         when /^(.*\s+)?(.*?)$/
    426           [$1 || "", $2]
    427         else
    428           raise "william screwed up completion: #{partial.inspect}"
    429         end
    430 
    431       prefix.fix_encoding!
    432       target.fix_encoding!
    433       completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.map { |x| [prefix + x, x] }
    434     end
    435   end
    436 
    437   def ask_many_emails_with_completions domain, question, completions, default=nil
    438     ask domain, question, default do |partial|
    439       prefix, target = partial.split_on_commas_with_remainder
    440       target ||= prefix.pop || ""
    441       target.fix_encoding!
    442 
    443       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
    444       prefix.fix_encoding!
    445 
    446       completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
    447     end
    448   end
    449 
    450   def ask_for_filename domain, question, default=nil, allow_directory=false
    451     answer = ask domain, question, default do |s|
    452       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
    453         full = $1
    454         name = $2.empty? ? Etc.getlogin : $2
    455         dir = Etc.getpwnam(name).dir rescue nil
    456         if dir
    457           [[s.sub(full, dir), "~#{name}"]]
    458         else
    459           users.select { |u| u =~ /^#{Regexp::escape name}/u }.map do |u|
    460             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
    461           end
    462         end
    463       else # regular filename completion
    464         Dir["#{s}*"].sort.map do |fn|
    465           suffix = File.directory?(fn) ? "/" : ""
    466           [fn + suffix, File.basename(fn) + suffix]
    467         end
    468       end
    469     end
    470 
    471     if answer
    472       answer =
    473         if answer.empty?
    474           spawn_modal "file browser", FileBrowserMode.new
    475         elsif File.directory?(answer) && !allow_directory
    476           spawn_modal "file browser", FileBrowserMode.new(answer)
    477         else
    478           File.expand_path answer
    479         end
    480     end
    481 
    482     answer
    483   end
    484 
    485   ## returns an array of labels
    486   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
    487     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
    488     default = default_labels.to_a.join(" ")
    489     default += " " unless default.empty?
    490 
    491     # here I would prefer to give more control and allow all_labels instead of
    492     # user_defined_labels only
    493     applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
    494 
    495     answer = ask_many_with_completions domain, question, applyable_labels, default
    496 
    497     return unless answer
    498 
    499     user_labels = answer.to_set_of_symbols
    500     user_labels.each do |l|
    501       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
    502         BufferManager.flash "'#{l}' is a reserved label!"
    503         return
    504       end
    505     end
    506     user_labels
    507   end
    508 
    509   def ask_for_contacts domain, question, default_contacts=[]
    510     default = default_contacts.is_a?(String) ? default_contacts : default_contacts.map { |s| s.to_s }.join(", ")
    511     default += " " unless default.empty?
    512 
    513     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
    514     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
    515 
    516     completions = (recent + contacts).flatten.uniq
    517     completions += HookManager.run("extra-contact-addresses") || []
    518 
    519     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
    520 
    521     if answer
    522       answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
    523     end
    524   end
    525 
    526   def ask_for_account domain, question
    527     completions = AccountManager.user_emails
    528     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, ""
    529     answer = AccountManager.default_account.email if answer == ""
    530     AccountManager.account_for Person.from_address(answer).email if answer
    531   end
    532 
    533   ## for simplicitly, we always place the question at the very bottom of the
    534   ## screen
    535   def ask domain, question, default=nil, &block
    536     raise "impossible!" if @asking
    537     raise "Question too long" if Ncurses.cols <= question.length
    538     @asking = true
    539 
    540     @textfields[domain] ||= TextField.new
    541     tf = @textfields[domain]
    542     completion_buf = nil
    543 
    544     status, title = get_status_and_title @focus_buf
    545 
    546     Ncurses.sync do
    547       tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
    548       @dirty = true # for some reason that blanks the whole fucking screen
    549       draw_screen :sync => false, :status => status, :title => title
    550       tf.position_cursor
    551       Ncurses.refresh
    552     end
    553 
    554     while true
    555       c = Ncurses::CharCode.get
    556       next unless c.present? # getch timeout
    557       break unless tf.handle_input c # process keystroke
    558 
    559       if tf.new_completions?
    560         kill_buffer completion_buf if completion_buf
    561 
    562         shorts = tf.completions.map { |full, short| short }
    563         prefix_len = shorts.shared_prefix(caseless=true).length
    564 
    565         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
    566         completion_buf = spawn "<completions>", mode, :height => 10
    567 
    568         draw_screen :skip_minibuf => true
    569         tf.position_cursor
    570       elsif tf.roll_completions?
    571         completion_buf.mode.roll
    572         draw_screen :skip_minibuf => true
    573         tf.position_cursor
    574       end
    575 
    576       Ncurses.sync { Ncurses.refresh }
    577     end
    578 
    579     kill_buffer completion_buf if completion_buf
    580 
    581     @dirty = true
    582     @asking = false
    583     Ncurses.sync do
    584       tf.deactivate
    585       draw_screen :sync => false, :status => status, :title => title
    586     end
    587     tf.value.tap { |x| x }
    588   end
    589 
    590   def ask_getch question, accept=nil
    591     raise "impossible!" if @asking
    592 
    593     accept = accept.split(//).map { |x| x.ord } if accept
    594 
    595     status, title = get_status_and_title @focus_buf
    596     Ncurses.sync do
    597       draw_screen :sync => false, :status => status, :title => title
    598       Ncurses.mvaddstr Ncurses.rows - 1, 0, question
    599       Ncurses.move Ncurses.rows - 1, question.length + 1
    600       Ncurses.curs_set 1
    601       Ncurses.refresh
    602     end
    603 
    604     @asking = true
    605     ret = nil
    606     done = false
    607     until done
    608       key = Ncurses::CharCode.get
    609       next if key.empty?
    610       if key.is_keycode? Ncurses::KEY_CANCEL
    611         done = true
    612       elsif accept.nil? || accept.empty? || accept.member?(key.code)
    613         ret = key
    614         done = true
    615       end
    616     end
    617 
    618     @asking = false
    619     Ncurses.sync do
    620       Ncurses.curs_set 0
    621       draw_screen :sync => false, :status => status, :title => title
    622     end
    623 
    624     ret
    625   end
    626 
    627   ## returns true (y), false (n), or nil (ctrl-g / cancel)
    628   def ask_yes_or_no question
    629     case(r = ask_getch question, "ynYN")
    630     when ?y, ?Y
    631       true
    632     when nil
    633       nil
    634     else
    635       false
    636     end
    637   end
    638 
    639   ## turns an input keystroke into an action symbol. returns the action
    640   ## if found, nil if not found, and throws InputSequenceAborted if
    641   ## the user aborted a multi-key sequence. (Because each of those cases
    642   ## should be handled differently.)
    643   ##
    644   ## this is in BufferManager because multi-key sequences require prompting.
    645   def resolve_input_with_keymap c, keymap
    646     action, text = keymap.action_for c
    647     while action.is_a? Keymap # multi-key commands, prompt
    648       key = BufferManager.ask_getch text
    649       unless key # user canceled, abort
    650         erase_flash
    651         raise InputSequenceAborted
    652       end
    653       action, text = action.action_for(key) if action.has_key?(key)
    654     end
    655     action
    656   end
    657 
    658   def minibuf_lines
    659     @minibuf_mutex.synchronize do
    660       [(@flash ? 1 : 0) +
    661        (@asking ? 1 : 0) +
    662        @minibuf_stack.compact.size, 1].max
    663     end
    664   end
    665 
    666   def draw_minibuf opts={}
    667     m = nil
    668     @minibuf_mutex.synchronize do
    669       m = @minibuf_stack.compact
    670       m << @flash if @flash
    671       m << "" if m.empty? unless @asking # to clear it
    672     end
    673 
    674     Ncurses.mutex.lock unless opts[:sync] == false
    675     Ncurses.attrset Colormap.color_for(:text_color)
    676     adj = @asking ? 2 : 1
    677     m.each_with_index do |s, i|
    678       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
    679     end
    680     Ncurses.refresh if opts[:refresh]
    681     Ncurses.mutex.unlock unless opts[:sync] == false
    682   end
    683 
    684   def say s, id=nil
    685     new_id = nil
    686 
    687     @minibuf_mutex.synchronize do
    688       new_id = id.nil?
    689       id ||= @minibuf_stack.length
    690       @minibuf_stack[id] = s
    691     end
    692 
    693     if new_id
    694       draw_screen :refresh => true
    695     else
    696       draw_minibuf :refresh => true
    697     end
    698 
    699     if block_given?
    700       begin
    701         yield id
    702       ensure
    703         clear id
    704       end
    705     end
    706     id
    707   end
    708 
    709   def erase_flash; @flash = nil; end
    710 
    711   def flash s
    712     @flash = s
    713     draw_screen :refresh => true
    714   end
    715 
    716   ## a little tricky because we can't just delete_at id because ids
    717   ## are relative (they're positions into the array).
    718   def clear id
    719     @minibuf_mutex.synchronize do
    720       @minibuf_stack[id] = nil
    721       if id == @minibuf_stack.length - 1
    722         id.downto(0) do |i|
    723           break if @minibuf_stack[i]
    724           @minibuf_stack.delete_at i
    725         end
    726       end
    727     end
    728 
    729     draw_screen :refresh => true
    730   end
    731 
    732   def shell_out command
    733     @shelled = true
    734     Ncurses.sync do
    735       Ncurses.endwin
    736       system command
    737       Ncurses.stdscr.keypad 1
    738       Ncurses.refresh
    739       Ncurses.curs_set 0
    740     end
    741     @shelled = false
    742   end
    743 
    744 private
    745 
    746   def default_status_bar buf
    747     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
    748   end
    749 
    750   def default_terminal_title buf
    751     "Sup #{Redwood::VERSION} :: #{buf.title}"
    752   end
    753 
    754   def get_status_and_title buf
    755     opts = {
    756       :num_inbox => lambda { Index.num_results_for :label => :inbox },
    757       :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
    758       :num_total => lambda { Index.size },
    759       :num_spam => lambda { Index.num_results_for :label => :spam },
    760       :title => buf.title,
    761       :mode => buf.mode.name,
    762       :status => buf.mode.status
    763     }
    764 
    765     statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
    766     term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
    767 
    768     [statusbar_text, term_title_text]
    769   end
    770 
    771   def users
    772     unless @users
    773       @users = []
    774       while(u = Etc.getpwent)
    775         @users << u.name
    776       end
    777     end
    778     @users
    779   end
    780 end
    781 end