]> git.notmuchmail.org Git - sup/blobdiff - lib/sup/buffer.rb
bugfix: BufferManager#ask_getch not clearing the flash message
[sup] / lib / sup / buffer.rb
index e83ae15019f44b4ef1b0333f8ae5ee6e1ac8575b..7c2c0ae10c015e8960dc815425c62cc322ef4ee0 100644 (file)
@@ -1,6 +1,8 @@
 require 'etc'
 require 'thread'
+require 'ncurses'
 
+if defined? Ncurses
 module Ncurses
   def rows
     lame, lamer = [], []
@@ -14,6 +16,12 @@ module Ncurses
     lamer.first
   end
 
+  def curx
+    lame, lamer = [], []
+    stdscr.getyx lame, lamer
+    lamer.first
+  end
+
   def mutex; @mutex ||= Mutex.new; end
   def sync &b; mutex.synchronize(&b); end
 
@@ -27,15 +35,21 @@ module Ncurses
     end
   end
 
-  module_function :rows, :cols, :nonblocking_getch, :mutex, :sync
+  module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
+
+  remove_const :KEY_ENTER
+  remove_const :KEY_CANCEL
 
   KEY_ENTER = 10
-  KEY_CANCEL = ?\a # ctrl-g
+  KEY_CANCEL = 7 # ctrl-g
   KEY_TAB = 9
 end
+end
 
 module Redwood
 
+class InputSequenceAborted < StandardError; end
+
 class Buffer
   attr_reader :mode, :x, :y, :width, :height, :title
   bool_reader :dirty
@@ -62,9 +76,13 @@ class Buffer
     mode.resize rows, cols
   end
 
-  def redraw
-    draw if @dirty
-    draw_status
+  def redraw status
+    if @dirty
+      draw status 
+    else
+      draw_status status
+    end
+
     commit
   end
 
@@ -75,9 +93,9 @@ class Buffer
     @w.noutrefresh
   end
 
-  def draw
+  def draw status
     @mode.draw
-    draw_status
+    draw_status status
     commit
   end
 
@@ -98,9 +116,8 @@ class Buffer
     @w.clear
   end
 
-  def draw_status
-    write @height - 1, 0, " [#{mode.name}] #{title}   #{mode.status}",
-      :color => :status_color
+  def draw_status status
+    write @height - 1, 0, status, :color => :status_color
   end
 
   def focus
@@ -121,6 +138,35 @@ class BufferManager
 
   attr_reader :focus_buf
 
+  ## we have to define the key used to continue in-buffer search here, because
+  ## it has special semantics that BufferManager deals with---current searches
+  ## are canceled by any keypress except this one.
+  CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
+
+  HookManager.register "status-bar-text", <<EOS
+Sets the status bar. The default status bar contains the mode name, the buffer
+title, and the mode status. Note that this will be called at least once per
+keystroke, so excessive computation is discouraged.
+
+Variables:
+         num_inbox: number of messages in inbox
+  num_inbox_unread: total number of messages marked as unread
+         num_total: total number of messages in the index
+          num_spam: total number of messages marked as spam
+             title: title of the current buffer
+              mode: current mode name (string)
+            status: current mode status (string)
+Return value: a string to be used as the status bar.
+EOS
+
+  HookManager.register "terminal-title-text", <<EOS
+Sets the title of the current terminal, if applicable. Note that this will be
+called at least once per keystroke, so excessive computation is discouraged.
+
+Variables: the same as status-bar-text hook.
+Return value: a string to be used as the terminal title.
+EOS
+
   def initialize
     @name_map = {}
     @buffers = []
@@ -131,6 +177,7 @@ class BufferManager
     @textfields = {}
     @flash = nil
     @shelled = @asking = false
+    @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
 
     self.class.i_am_the_instance self
   end
@@ -138,7 +185,7 @@ class BufferManager
   def buffers; @name_map.to_a; end
 
   def focus_on buf
-    raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
+    return unless @buffers.member? buf
     return if buf == @focus_buf 
     @focus_buf.blur if @focus_buf
     @focus_buf = buf
@@ -146,15 +193,13 @@ class BufferManager
   end
 
   def raise_to_front buf
-    raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
-
-    @buffers.delete buf
+    @buffers.delete(buf) or return
     if @buffers.length > 0 && @buffers.last.force_to_top?
       @buffers.insert(-2, buf)
     else
       @buffers.push buf
-      focus_on buf
     end
+    focus_on @buffers.last
     @dirty = true
   end
 
@@ -178,7 +223,13 @@ class BufferManager
   end
 
   def handle_input c
-    @focus_buf && @focus_buf.mode.handle_input(c)
+    if @focus_buf
+      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
+        @focus_buf.mode.cancel_search!
+        @focus_buf.mark_dirty
+      end
+      @focus_buf.mode.handle_input c
+    end
   end
 
   def exists? n; @name_map.member? n; end
@@ -192,16 +243,28 @@ class BufferManager
   def completely_redraw_screen
     return if @shelled
 
+    status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
+
     Ncurses.sync do
       @dirty = true
       Ncurses.clear
-      draw_screen :sync => false
+      draw_screen :sync => false, :status => status, :title => title
     end
   end
 
   def draw_screen opts={}
     return if @shelled
 
+    status, title =
+      if opts.member? :status
+        [opts[:status], opts[:title]]
+      else
+        raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
+        get_status_and_title @focus_buf # must be called outside of the ncurses lock
+      end
+
+    print "\033]2;#{title}\07" if title && @in_x
+
     Ncurses.mutex.lock unless opts[:sync] == false
 
     ## disabling this for the time being, to help with debugging
@@ -210,7 +273,7 @@ class BufferManager
     false && @buffers.inject(@dirty) do |dirty, buf|
       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
       #dirty ? buf.draw : buf.redraw
-      buf.draw
+      buf.draw status
       dirty
     end
 
@@ -218,7 +281,7 @@ class BufferManager
     if true
       buf = @buffers.last
       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
-      @dirty ? buf.draw : buf.redraw
+      @dirty ? buf.draw(status) : buf.redraw(status)
     end
 
     draw_minibuf :sync => false unless opts[:skip_minibuf]
@@ -229,17 +292,21 @@ class BufferManager
     Ncurses.mutex.unlock unless opts[:sync] == false
   end
 
-  ## gets the mode from the block, which is only called if the buffer
-  ## doesn't already exist. this is useful in the case that generating
-  ## the mode is expensive, as it often is.
+  ## if the named buffer already exists, pops it to the front without
+  ## calling the block. otherwise, gets the mode from the block and
+  ## creates a new buffer. returns two things: the buffer, and a boolean
+  ## indicating whether it's a new buffer or not.
   def spawn_unless_exists title, opts={}
-    if @name_map.member? title
-      raise_to_front @name_map[title] unless opts[:hidden]
-    else
-      mode = yield
-      spawn title, mode, opts
-    end
-    @name_map[title]
+    new = 
+      if @name_map.member? title
+        raise_to_front @name_map[title] unless opts[:hidden]
+        false
+      else
+        mode = yield
+        spawn title, mode, opts
+        true
+      end
+    [@name_map[title], new]
   end
 
   def spawn title, mode, opts={}
@@ -283,7 +350,10 @@ class BufferManager
       c = Ncurses.nonblocking_getch
       next unless c # getch timeout
       break if c == Ncurses::KEY_CANCEL
-      mode.handle_input c
+      begin
+        mode.handle_input c
+      rescue InputSequenceAborted # do nothing
+      end
       draw_screen
       erase_flash
     end
@@ -312,7 +382,7 @@ class BufferManager
   end
 
   def kill_buffer buf
-    raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
+    raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
 
     buf.mode.cleanup
     @buffers.delete buf
@@ -326,8 +396,38 @@ class BufferManager
     end
   end
 
-  ## returns an ARRAY of filenames!
-  def ask_for_filenames domain, question, default=nil
+  def ask_with_completions domain, question, completions, default=nil
+    ask domain, question, default do |s|
+      completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
+    end
+  end
+
+  def ask_many_with_completions domain, question, completions, default=nil
+    ask domain, question, default do |partial|
+      prefix, target = 
+        case partial
+        when /^\s*$/
+          ["", ""]
+        when /^(.*\s+)?(.*?)$/
+          [$1 || "", $2]
+        else
+          raise "william screwed up completion: #{partial.inspect}"
+        end
+
+      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+    end
+  end
+
+  def ask_many_emails_with_completions domain, question, completions, default=nil
+    ask domain, question, default do |partial|
+      prefix, target = partial.split_on_commas_with_remainder
+      target ||= prefix.pop || ""
+      prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
+      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+    end
+  end
+
+  def ask_for_filename domain, question, default=nil
     answer = ask domain, question, default do |s|
       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
         full = $1
@@ -336,7 +436,7 @@ class BufferManager
         if dir
           [[s.sub(full, dir), "~#{name}"]]
         else
-          users.select { |u| u =~ /^#{name}/ }.map do |u|
+          users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
           end
         end
@@ -349,64 +449,94 @@ class BufferManager
     end
 
     if answer
-      if answer.empty?
-        spawn_modal "file browser", FileBrowserMode.new
-      elsif File.directory?(answer)
-        spawn_modal "file browser", FileBrowserMode.new(answer)
-      else
-        [answer]
+      answer = 
+        if answer.empty?
+          spawn_modal "file browser", FileBrowserMode.new
+        elsif File.directory?(answer)
+          spawn_modal "file browser", FileBrowserMode.new(answer)
+        else
+          answer
+        end
+    end
+
+    answer
+  end
+
+  ## returns an array of labels
+  def ask_for_labels domain, question, default_labels, forbidden_labels=[]
+    default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
+    default = default_labels.join(" ")
+    default += " " unless default.empty?
+
+    applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
+
+    answer = ask_many_with_completions domain, question, applyable_labels, default
+
+    return unless answer
+
+    user_labels = answer.split(/\s+/).map { |l| l.intern }
+    user_labels.each do |l|
+      if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
+        BufferManager.flash "'#{l}' is a reserved label!"
+        return
       end
-    else
-      []
+    end
+    user_labels
+  end
+
+  def ask_for_contacts domain, question, default_contacts=[]
+    default = default_contacts.map { |s| s.to_s }.join(" ")
+    default += " " unless default.empty?
+    
+    recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
+    contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
+
+    completions = (recent + contacts).flatten.uniq.sort
+    answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
+
+    if answer
+      answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
     end
   end
 
+  ## for simplicitly, we always place the question at the very bottom of the
+  ## screen
   def ask domain, question, default=nil, &block
     raise "impossible!" if @asking
     @asking = true
 
-    @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
+    @textfields[domain] ||= TextField.new
     tf = @textfields[domain]
     completion_buf = nil
 
-    ## this goddamn ncurses form shit is a fucking 1970's nightmare.
-    ## jesus christ. the exact sequence of ncurses events that needs
-    ## to happen in order to display a form and have the entire screen
-    ## not disappear and have the cursor in the right place is TOO
-    ## FUCKING COMPLICATED.
+    status, title = get_status_and_title @focus_buf
+
     Ncurses.sync do
-      tf.activate question, default, &block
-      @dirty = true
-      draw_screen :skip_minibuf => true, :sync => false
+      tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
+      @dirty = true # for some reason that blanks the whole fucking screen
+      draw_screen :sync => false, :status => status, :title => title
+      tf.position_cursor
+      Ncurses.refresh
     end
 
-    ret = nil
-    tf.position_cursor
-    Ncurses.sync { Ncurses.refresh }
-
     while true
       c = Ncurses.nonblocking_getch
-      next unless c  # getch timeout
+      next unless c # getch timeout
       break unless tf.handle_input c # process keystroke
 
       if tf.new_completions?
         kill_buffer completion_buf if completion_buf
         
-        prefix_len =
-          if tf.value =~ /\/$/
-            0
-          else
-            File.basename(tf.value).length
-          end
+        shorts = tf.completions.map { |full, short| short }
+        prefix_len = shorts.shared_prefix.length
 
-        mode = CompletionMode.new tf.completions.map { |full, short| short }, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
+        mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
         completion_buf = spawn "<completions>", mode, :height => 10
 
         draw_screen :skip_minibuf => true
         tf.position_cursor
       elsif tf.roll_completions?
         completion_buf.mode.roll
-
         draw_screen :skip_minibuf => true
         tf.position_cursor
       end
@@ -414,45 +544,48 @@ class BufferManager
       Ncurses.sync { Ncurses.refresh }
     end
     
-    Ncurses.sync { tf.deactivate }
     kill_buffer completion_buf if completion_buf
+
     @dirty = true
     @asking = false
-    draw_screen
+    Ncurses.sync do
+      tf.deactivate
+      draw_screen :sync => false, :status => status, :title => title
+    end
     tf.value
   end
 
-  ## some pretty lame code in here!
   def ask_getch question, accept=nil
+    raise "impossible!" if @asking
+
     accept = accept.split(//).map { |x| x[0] } if accept
 
-    flash question
+    status, title = get_status_and_title @focus_buf
     Ncurses.sync do
-      Ncurses.curs_set 1
+      draw_screen :sync => false, :status => status, :title => title
+      Ncurses.mvaddstr Ncurses.rows - 1, 0, question
       Ncurses.move Ncurses.rows - 1, question.length + 1
+      Ncurses.curs_set 1
       Ncurses.refresh
     end
 
+    @asking = true
     ret = nil
     done = false
-    @shelled = true
     until done
       key = Ncurses.nonblocking_getch or next
       if key == Ncurses::KEY_CANCEL
         done = true
-      elsif (accept && accept.member?(key)) || !accept
+      elsif accept.nil? || accept.empty? || accept.member?(key)
         ret = key
         done = true
       end
     end
 
-    @shelled = false
-
+    @asking = false
     Ncurses.sync do
       Ncurses.curs_set 0
-      erase_flash
-      draw_screen :sync => false
-      Ncurses.curs_set 0
+      draw_screen :sync => false, :status => status, :title => title
     end
 
     ret
@@ -470,6 +603,25 @@ class BufferManager
     end
   end
 
+  ## turns an input keystroke into an action symbol. returns the action
+  ## if found, nil if not found, and throws InputSequenceAborted if
+  ## the user aborted a multi-key sequence. (Because each of those cases
+  ## should be handled differently.)
+  ##
+  ## this is in BufferManager because multi-key sequences require prompting.
+  def resolve_input_with_keymap c, keymap
+    action, text = keymap.action_for c
+    while action.is_a? Keymap # multi-key commands, prompt
+      key = BufferManager.ask_getch text
+      unless key # user canceled, abort
+        erase_flash
+        raise InputSequenceAborted
+      end
+      action, text = action.action_for(key) if action.has_key?(key)
+    end
+    action
+  end
+
   def minibuf_lines
     @minibuf_mutex.synchronize do
       [(@flash ? 1 : 0) + 
@@ -483,7 +635,7 @@ class BufferManager
     @minibuf_mutex.synchronize do
       m = @minibuf_stack.compact
       m << @flash if @flash
-      m << "" if m.empty?
+      m << "" if m.empty? unless @asking # to clear it
     end
 
     Ncurses.mutex.lock unless opts[:sync] == false
@@ -556,6 +708,30 @@ class BufferManager
   end
 
 private
+  def default_status_bar buf
+    " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
+  end
+
+  def default_terminal_title buf
+    "Sup #{Redwood::VERSION} :: #{buf.title}"
+  end
+
+  def get_status_and_title buf
+    opts = {
+      :num_inbox => lambda { Index.num_results_for :label => :inbox },
+      :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
+      :num_total => lambda { Index.size },
+      :num_spam => lambda { Index.num_results_for :label => :spam },
+      :title => buf.title,
+      :mode => buf.mode.name,
+      :status => buf.mode.status
+    }
+
+    statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
+    term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
+    
+    [statusbar_text, term_title_text]
+  end
 
   def users
     unless @users