]> git.cworth.org Git - sup/blobdiff - lib/sup/buffer.rb
convert a couple of arrays to sets for labels
[sup] / lib / sup / buffer.rb
index dc3b44e6e385cfe1bf7709c347542d53a95c402e..2f2dbdaf2f5f3894a4b60728486574c4ce325769 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,18 +35,24 @@ 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
+  attr_reader :mode, :x, :y, :width, :height, :title, :atime
+  bool_reader :dirty, :system
   bool_accessor :force_to_top
 
   def initialize window, mode, width, height, opts={}
@@ -49,6 +63,8 @@ class Buffer
     @title = opts[:title] || ""
     @force_to_top = opts[:force_to_top] || false
     @x, @y, @width, @height = 0, 0, width, height
+    @atime = Time.at 0
+    @system = opts[:system] || false
   end
 
   def content_height; @height - 1; end
@@ -62,9 +78,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,10 +95,11 @@ class Buffer
     @w.noutrefresh
   end
 
-  def draw
+  def draw status
     @mode.draw
-    draw_status
+    draw_status status
     commit
+    @atime = Time.now
   end
 
   ## s nil means a blank line!
@@ -87,10 +108,16 @@ class Buffer
 
     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
     s ||= ""
-    maxl = @width - x
-    @w.mvaddstr y, x, s[0 ... maxl]
-    unless s.length >= maxl || opts[:no_fill]
-      @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
+    maxl = @width - x # maximum display width width
+    stringl = maxl    # string "length"
+    ## the next horribleness is thanks to ruby's lack of widechar support
+    stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
+    @w.mvaddstr y, x, s[0 ... stringl]
+    unless opts[:no_fill]
+      l = s.display_length
+      unless l >= maxl
+        @w.mvaddstr(y, x + l, " " * (maxl - l))
+      end
     end
   end
 
@@ -98,9 +125,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 +147,44 @@ 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
+
+  HookManager.register "extra-contact-addresses", <<EOS
+A list of extra addresses to propose for tab completion, etc. when the
+user is entering an email address. Can be plain email addresses or can
+be full "User Name <email@domain.tld>" entries.
+
+Variables: none
+Return value: an array of email address strings.
+EOS
+
   def initialize
     @name_map = {}
     @buffers = []
@@ -131,6 +195,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 +203,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 +211,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 +241,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 +261,29 @@ 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
+
+    ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
+    print "\033]0;#{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 +292,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 +300,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 +311,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={}
@@ -261,7 +347,7 @@ class BufferManager
     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
     ## (opts[:left] || 0))
     w = Ncurses.stdscr
-    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
+    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
     mode.buffer = b
     @name_map[realtitle] = b
 
@@ -283,7 +369,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 +401,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
@@ -328,12 +417,36 @@ class BufferManager
 
   def ask_with_completions domain, question, completions, default=nil
     ask domain, question, default do |s|
-      completions.select { |x| x =~ /^#{s}/i }.map { |x| [x.downcase, x] }
+      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
 
-  ## returns an ARRAY of filenames!
-  def ask_for_filenames domain, question, default=nil
+  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 }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.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
@@ -342,7 +455,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
@@ -355,42 +468,79 @@ class BufferManager
     end
 
     if answer
-      answer = 
+      answer =
         if answer.empty?
           spawn_modal "file browser", FileBrowserMode.new
         elsif File.directory?(answer)
           spawn_modal "file browser", FileBrowserMode.new(answer)
         else
-          [answer]
+          File.expand_path answer
         end
     end
 
-    answer || []
+    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.to_a.join(" ")
+    default += " " unless default.empty?
+
+    # here I would prefer to give more control and allow all_labels instead of
+    # user_defined_labels only
+    applyable_labels = (LabelManager.user_defined_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.to_set_of_symbols
+    user_labels.each do |l|
+      if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
+        BufferManager.flash "'#{l}' is a reserved label!"
+        return
+      end
+    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
+    completions += HookManager.run("extra-contact-addresses") || []
+    answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
+
+    if answer
+      answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(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
@@ -409,7 +559,6 @@ class BufferManager
         tf.position_cursor
       elsif tf.roll_completions?
         completion_buf.mode.roll
-
         draw_screen :skip_minibuf => true
         tf.position_cursor
       end
@@ -417,45 +566,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
@@ -473,6 +625,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) + 
@@ -486,7 +657,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
@@ -552,6 +723,7 @@ class BufferManager
     Ncurses.sync do
       Ncurses.endwin
       system command
+      Ncurses.stdscr.keypad 1
       Ncurses.refresh
       Ncurses.curs_set 0
     end
@@ -559,6 +731,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