]> git.notmuchmail.org Git - sup/blobdiff - lib/sup/buffer.rb
refactor label tab-completion code to buffer.rb
[sup] / lib / sup / buffer.rb
index 0e2b85bce1bad619e41dd84144b901d41db57fad..62b582e958c565d0eb4736486f34d9eb3bf037a5 100644 (file)
@@ -1,3 +1,4 @@
+require 'etc'
 require 'thread'
 
 module Ncurses
@@ -13,36 +14,30 @@ 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
 
-  ## aaahhh, user input. who would have though that such a simple
-  ## idea would be SO FUCKING COMPLICATED?! because apparently
-  ## Ncurses.getch (and Curses.getch), even in cbreak mode, BLOCKS
-  ## ALL THREAD ACTIVITY. as in, no threads anywhere will run while
-  ## it's waiting for input. ok, fine, so we wrap it in a select. Of
-  ## course we also rely on Ncurses.getch to tell us when an xterm
-  ## resize has occurred, which select won't catch, so we won't
-  ## resize outselves after a sigwinch until the user hits a key.
-  ## and installing our own sigwinch handler means that the screen
-  ## size returned by getmaxyx() DOESN'T UPDATE! and Kernel#trap
-  ## RETURNS NIL as the previous handler! 
-  ##
-  ## so basically, resizing with multi-threaded ruby Ncurses
-  ## applications will always be broken.
-  ##
-  ## i've coined a new word for this: lametarded.
+  ## magically, this stuff seems to work now. i could swear it didn't
+  ## before. hm.
   def nonblocking_getch
-    if IO.select([$stdin], nil, nil, nil)
+    if IO.select([$stdin], nil, nil, 1)
       Ncurses.getch
     else
       nil
     end
   end
 
-  module_function :rows, :cols, :nonblocking_getch, :mutex, :sync
+  module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
 
-  KEY_CANCEL = "\a"[0] # ctrl-g
+  KEY_ENTER = 10
+  KEY_CANCEL = ?\a # ctrl-g
+  KEY_TAB = 9
 end
 
 module Redwood
@@ -50,6 +45,7 @@ module Redwood
 class Buffer
   attr_reader :mode, :x, :y, :width, :height, :title
   bool_reader :dirty
+  bool_accessor :force_to_top
 
   def initialize window, mode, width, height, opts={}
     @w = window
@@ -57,6 +53,7 @@ class Buffer
     @dirty = true
     @focus = false
     @title = opts[:title] || ""
+    @force_to_top = opts[:force_to_top] || false
     @x, @y, @width, @height = 0, 0, width, height
   end
 
@@ -156,18 +153,33 @@ class BufferManager
 
   def raise_to_front buf
     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
+
     @buffers.delete buf
-    @buffers.push buf
-    focus_on buf
+    if @buffers.length > 0 && @buffers.last.force_to_top?
+      @buffers.insert(-2, buf)
+    else
+      @buffers.push buf
+      focus_on buf
+    end
     @dirty = true
   end
 
+  ## we reset force_to_top when rolling buffers. this is so that the
+  ## human can actually still move buffers around, while still
+  ## programmatically being able to pop stuff up in the middle of
+  ## drawing a window without worrying about covering it up.
+  ##
+  ## if we ever start calling roll_buffers programmatically, we will
+  ## have to change this. but it's not clear that we will ever actually
+  ## do that.
   def roll_buffers
+    @buffers.last.force_to_top = false
     raise_to_front @buffers.first
   end
 
   def roll_buffers_backwards
     return unless @buffers.length > 1
+    @buffers.last.force_to_top = false
     raise_to_front @buffers[@buffers.length - 2]
   end
 
@@ -179,6 +191,7 @@ class BufferManager
   def [] n; @name_map[n]; end
   def []= n, b
     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
+    raise ArgumentError, "title must be a string" unless n.is_a? String
     @name_map[n] = b
   end
 
@@ -192,14 +205,6 @@ class BufferManager
     end
   end
 
-  def handle_resize
-    return if @shelled
-    rows, cols = Ncurses.rows, Ncurses.cols
-    @buffers.each { |b| b.resize rows - minibuf_lines, cols }
-    completely_redraw_screen
-    flash "Resized to #{rows}x#{cols}"
-  end
-
   def draw_screen opts={}
     return if @shelled
 
@@ -210,7 +215,9 @@ class BufferManager
     ## TODO: reenable this if we allow multiple buffers
     false && @buffers.inject(@dirty) do |dirty, buf|
       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
-      @dirty ? buf.draw : buf.redraw
+      #dirty ? buf.draw : buf.redraw
+      buf.draw
+      dirty
     end
 
     ## quick hack
@@ -221,6 +228,7 @@ class BufferManager
     end
 
     draw_minibuf :sync => false unless opts[:skip_minibuf]
+
     @dirty = false
     Ncurses.doupdate
     Ncurses.refresh if opts[:refresh]
@@ -241,6 +249,7 @@ class BufferManager
   end
 
   def spawn title, mode, opts={}
+    raise ArgumentError, "title must be a string" unless title.is_a? String
     realtitle = title
     num = 2
     while @name_map.member? realtitle
@@ -258,19 +267,52 @@ 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
+    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
     mode.buffer = b
     @name_map[realtitle] = b
+
+    @buffers.unshift b
     if opts[:hidden]
-      @buffers.unshift b
       focus_on b unless @focus_buf
     else
-      @buffers.push b
       raise_to_front b
     end
     b
   end
 
+  ## requires the mode to have #done? and #value methods
+  def spawn_modal title, mode, opts={}
+    b = spawn title, mode, opts
+    draw_screen
+
+    until mode.done?
+      c = Ncurses.nonblocking_getch
+      next unless c # getch timeout
+      break if c == Ncurses::KEY_CANCEL
+      mode.handle_input c
+      draw_screen
+      erase_flash
+    end
+
+    kill_buffer b
+    mode.value
+  end
+
+  def kill_all_buffers_safely
+    until @buffers.empty?
+      ## inbox mode always claims it's unkillable. we'll ignore it.
+      return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
+      kill_buffer @buffers.last
+    end
+    true
+  end
+
+  def kill_buffer_safely buf
+    return false unless buf.mode.killable?
+    kill_buffer buf
+    true
+  end
+
   def kill_all_buffers
     kill_buffer @buffers.first until @buffers.empty?
   end
@@ -290,20 +332,103 @@ class BufferManager
     end
   end
 
-  ## not really thread safe.
-  def ask domain, question, default=nil
+  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] }
+    end
+  end
+
+  def ask_many_with_completions domain, question, completions, default=nil, sep=" "
+    ask domain, question, default do |partial|
+      prefix, target = 
+        case partial.gsub(/#{sep}+/, sep)
+        when /^\s*$/
+          ["", ""]
+        when /^(.+#{sep})$/
+          [$1, ""]
+        when /^(.*#{sep})?(.+?)$/
+          [$1 || "", $2]
+        else
+          raise "william screwed up completion: #{partial.inspect}"
+        end
+
+      completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
+    end
+  end
+
+  ## returns an ARRAY of filenames!
+  def ask_for_filenames domain, question, default=nil
+    answer = ask domain, question, default do |s|
+      if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
+        full = $1
+        name = $2.empty? ? Etc.getlogin : $2
+        dir = Etc.getpwnam(name).dir rescue nil
+        if dir
+          [[s.sub(full, dir), "~#{name}"]]
+        else
+          users.select { |u| u =~ /^#{name}/ }.map do |u|
+            [s.sub("~#{name}", "~#{u}"), "~#{u}"]
+          end
+        end
+      else # regular filename completion
+        Dir["#{s}*"].sort.map do |fn|
+          suffix = File.directory?(fn) ? "/" : ""
+          [fn + suffix, File.basename(fn) + suffix]
+        end
+      end
+    end
+
+    if 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 = 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 = BufferManager.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
+    end
+    user_labels
+  end
+
+
+  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
     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.
+    ## 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.
     Ncurses.sync do
-      tf.activate question, default
+      tf.activate question, default, &block
       @dirty = true
       draw_screen :skip_minibuf => true, :sync => false
     end
@@ -312,15 +437,37 @@ class BufferManager
     tf.position_cursor
     Ncurses.sync { Ncurses.refresh }
 
-    @asking = true
-    while tf.handle_input(Ncurses.nonblocking_getch); end
-    @asking = false
+    while true
+      c = Ncurses.nonblocking_getch
+      next unless c # getch timeout
+      break unless tf.handle_input c # process keystroke
+
+      if tf.new_completions?
+        kill_buffer completion_buf if completion_buf
+        
+        shorts = tf.completions.map { |full, short| short }
+        prefix_len = shorts.shared_prefix.length
+
+        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
 
-    ret = tf.value
+      Ncurses.sync { Ncurses.refresh }
+    end
+    
     Ncurses.sync { tf.deactivate }
+    kill_buffer completion_buf if completion_buf
     @dirty = true
-
-    ret
+    @asking = false
+    draw_screen
+    tf.value
   end
 
   ## some pretty lame code in here!
@@ -338,7 +485,7 @@ class BufferManager
     done = false
     @shelled = true
     until done
-      key = Ncurses.nonblocking_getch
+      key = Ncurses.nonblocking_getch or next
       if key == Ncurses::KEY_CANCEL
         done = true
       elsif (accept && accept.member?(key)) || !accept
@@ -359,9 +506,9 @@ class BufferManager
     ret
   end
 
+  ## returns true (y), false (n), or nil (ctrl-g / cancel)
   def ask_yes_or_no question
-    r = ask_getch(question, "ynYN")
-    case r
+    case(r = ask_getch question, "ynYN")
     when ?y, ?Y
       true
     when nil
@@ -399,6 +546,7 @@ class BufferManager
 
   def say s, id=nil
     new_id = nil
+
     @minibuf_mutex.synchronize do
       new_id = id.nil?
       id ||= @minibuf_stack.length
@@ -454,5 +602,17 @@ class BufferManager
     end
     @shelled = false
   end
+
+private
+
+  def users
+    unless @users
+      @users = []
+      while(u = Etc.getpwent)
+        @users << u.name
+      end
+    end
+    @users
+  end
 end
 end