+require 'etc'
require 'thread'
+require 'ncurses'
+if defined? Ncurses
module Ncurses
def rows
lame, lamer = [], []
lamer.first
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.
+ def curx
+ lame, lamer = [], []
+ stdscr.getyx lame, lamer
+ lamer.first
+ end
+
+ def mutex; @mutex ||= Mutex.new; end
+ def sync &b; mutex.synchronize(&b); end
+
+ ## 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
+ module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
+
+ remove_const :KEY_ENTER
+ remove_const :KEY_CANCEL
- KEY_CANCEL = "\a"[0] # ctrl-g
+ KEY_ENTER = 10
+ KEY_CANCEL = 7 # ctrl-g
+ KEY_TAB = 9
+end
end
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
@dirty = true
@focus = false
@title = opts[:title] || ""
+ @force_to_top = opts[:force_to_top] || false
@x, @y, @width, @height = 0, 0, width, height
end
def content_height; @height - 1; end
def content_width; @width; end
- def resize rows, cols
+ def resize rows, cols
+ return if cols == @width && rows == @height
@width = cols
@height = rows
+ @dirty = true
mode.resize rows, cols
end
draw_status
commit
end
+
def mark_dirty; @dirty = true; end
def commit
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"
+
def initialize
@name_map = {}
@buffers = []
@focus_buf = nil
@dirty = true
@minibuf_stack = []
+ @minibuf_mutex = Mutex.new
@textfields = {}
@flash = nil
- @freeze = false
+ @shelled = @asking = false
self.class.i_am_the_instance self
end
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
end
def raise_to_front buf
- raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless
- @buffers.member? buf
+ return 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
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
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
def completely_redraw_screen
- return if @freeze
- Ncurses.clear
- @dirty = true
- draw_screen
- end
+ return if @shelled
- def handle_resize
- return if @freeze
- rows, cols = Ncurses.rows, Ncurses.cols
- @buffers.each { |b| b.resize rows - 1, cols }
- completely_redraw_screen
- flash "resized to #{rows}x#{cols}"
+ Ncurses.sync do
+ @dirty = true
+ Ncurses.clear
+ draw_screen :sync => false
+ end
end
- def draw_screen skip_minibuf=false
- return if @freeze
+ def draw_screen opts={}
+ return if @shelled
+
+ Ncurses.mutex.lock unless opts[:sync] == false
## disabling this for the time being, to help with debugging
## (currently we only have one buffer visible at a time).
## TODO: reenable this if we allow multiple buffers
false && @buffers.inject(@dirty) do |dirty, buf|
- dirty ? buf.draw : buf.redraw
- dirty || buf.dirty?
+ buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
+ #dirty ? buf.draw : buf.redraw
+ buf.draw
+ dirty
end
+
## quick hack
- true && (@dirty ? @buffers.last.draw : @buffers.last.redraw)
-
- draw_minibuf unless skip_minibuf
+ if true
+ buf = @buffers.last
+ buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
+ @dirty ? buf.draw : buf.redraw
+ end
+
+ draw_minibuf :sync => false unless opts[:skip_minibuf]
+
@dirty = false
Ncurses.doupdate
+ Ncurses.refresh if opts[:refresh]
+ Ncurses.mutex.unlock unless opts[:sync] == false
end
## gets the mode from the block, which is only called if the buffer
## the mode is expensive, as it often is.
def spawn_unless_exists title, opts={}
if @name_map.member? title
- Redwood::log "buffer '#{title}' already exists, raising to front"
raise_to_front @name_map[title] unless opts[:hidden]
+ nil
else
mode = yield
spawn title, mode, opts
+ @name_map[title]
end
- @name_map[title]
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
- realtitle = "#{title} #{num}"
+ realtitle = "#{title} <#{num}>"
num += 1
end
- Redwood::log "spawning buffer \"#{realtitle}\""
width = opts[:width] || Ncurses.cols
height = opts[:height] || Ncurses.rows - 1
## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
## (opts[:left] || 0))
w = Ncurses.stdscr
- raise "nil window" unless w
-
- 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
def kill_buffer buf
raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
- Redwood::log "killing buffer \"#{buf.title}\""
buf.mode.cleanup
@buffers.delete buf
## TODO: something intelligent here
## for now I will simply prohibit killing the inbox buffer.
else
- raise_to_front @buffers.last
+ last = @buffers.last
+ @focus_buf ||= last
+ raise_to_front last
end
end
- def ask domain, question, default=nil
- @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0,
- Ncurses.cols
- tf = @textfields[domain]
+ def ask_with_completions domain, question, completions, default=nil
+ ask domain, question, default do |s|
+ completions.select { |x| x =~ /^#{s}/i }.map { |x| [x, x] }
+ end
+ end
- ## 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.
- tf.activate question, default
- @dirty = true
- draw_screen true
+ 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 =~ /^#{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
+ Redwood::log "before: prefix #{prefix.inspect}, target #{target.inspect}"
+ target ||= prefix.pop || ""
+ prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
+ Redwood::log "after: prefix #{prefix.inspect}, target #{target.inspect}"
+ completions.select { |x| x =~ /^#{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
+ 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_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
+ 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
+
+
+ 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.
+ Ncurses.sync do
+ tf.activate question, default, &block
+ @dirty = true
+ #draw_screen :skip_minibuf => true, :sync => false
+ draw_screen :sync => false
+ end
ret = nil
- @freeze = true
tf.position_cursor
- Ncurses.refresh
- while tf.handle_input(Ncurses.nonblocking_getch); end
- @freeze = false
+ Ncurses.sync { Ncurses.refresh }
+
+ 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
- tf.deactivate
+ 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!
accept = accept.split(//).map { |x| x[0] } if accept
flash question
- Ncurses.curs_set 1
- Ncurses.move Ncurses.rows - 1, question.length + 1
- Ncurses.refresh
+ Ncurses.sync do
+ Ncurses.curs_set 1
+ Ncurses.move Ncurses.rows - 1, question.length + 1
+ Ncurses.refresh
+ end
ret = nil
done = false
- @freeze = true
+ @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
done = true
end
end
- @freeze = false
- Ncurses.curs_set 0
- erase_flash
- draw_screen
- Ncurses.curs_set 0
+
+ @shelled = false
+
+ Ncurses.sync do
+ Ncurses.curs_set 0
+ erase_flash
+ draw_screen :sync => false
+ Ncurses.curs_set 0
+ end
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
end
end
- def draw_minibuf
- s = @flash || @minibuf_stack.reverse.find { |x| x } || ""
+ def minibuf_lines
+ @minibuf_mutex.synchronize do
+ [(@flash ? 1 : 0) +
+ (@asking ? 1 : 0) +
+ @minibuf_stack.compact.size, 1].max
+ end
+ end
+
+ def draw_minibuf opts={}
+ m = nil
+ @minibuf_mutex.synchronize do
+ m = @minibuf_stack.compact
+ m << @flash if @flash
+ m << "" if m.empty?
+ end
+ Ncurses.mutex.lock unless opts[:sync] == false
Ncurses.attrset Colormap.color_for(:none)
- Ncurses.mvaddstr Ncurses.rows - 1, 0, s + (" " * [Ncurses.cols - s.length,
- 0].max)
+ adj = @asking ? 2 : 1
+ m.each_with_index do |s, i|
+ Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
+ end
+ Ncurses.refresh if opts[:refresh]
+ Ncurses.mutex.unlock unless opts[:sync] == false
end
def say s, id=nil
- id ||= @minibuf_stack.length
- @minibuf_stack[id] = s
- unless @freeze
- draw_screen
- Ncurses.refresh
+ new_id = nil
+
+ @minibuf_mutex.synchronize do
+ new_id = id.nil?
+ id ||= @minibuf_stack.length
+ @minibuf_stack[id] = s
+ end
+
+ if new_id
+ draw_screen :refresh => true
+ else
+ draw_minibuf :refresh => true
end
+
if block_given?
- yield
- clear id
- return
+ begin
+ yield id
+ ensure
+ clear id
+ end
end
id
end
def flash s
@flash = s
- unless @freeze
- draw_screen
- Ncurses.refresh
- end
+ draw_screen :refresh => true
end
+ ## a little tricky because we can't just delete_at id because ids
+ ## are relative (they're positions into the array).
def clear id
- @minibuf_stack[id] = nil
- if id == @minibuf_stack.length - 1
- id.downto(0) do |i|
- break unless @minibuf_stack[i].nil?
- @minibuf_stack.delete_at i
+ @minibuf_mutex.synchronize do
+ @minibuf_stack[id] = nil
+ if id == @minibuf_stack.length - 1
+ id.downto(0) do |i|
+ break if @minibuf_stack[i]
+ @minibuf_stack.delete_at i
+ end
end
end
- unless @freeze
- draw_screen
+
+ draw_screen :refresh => true
+ end
+
+ def shell_out command
+ @shelled = true
+ Ncurses.sync do
+ Ncurses.endwin
+ system command
Ncurses.refresh
+ Ncurses.curs_set 0
end
+ @shelled = false
end
- def shell_out command
- @freeze = true
- Ncurses.endwin
- system command
- Ncurses.refresh
- Ncurses.curs_set 0
- @freeze = false
+private
+
+ def users
+ unless @users
+ @users = []
+ while(u = Etc.getpwent)
+ @users << u.name
+ end
+ end
+ @users
end
end
end