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={}
@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
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
@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!
@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
@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
## 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 = []
@textfields = {}
@flash = nil
@shelled = @asking = false
+ @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
self.class.i_am_the_instance self
end
def focus_on 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
- return 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
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
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
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]
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]
- nil
- else
- mode = yield
- spawn title, mode, opts
- @name_map[title]
- end
+ 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={}
## 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
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
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
## TODO: something intelligent here
## for now I will simply prohibit killing the inbox buffer.
else
- last = @buffers.last
- @focus_buf ||= last
- raise_to_front last
+ raise_to_front @buffers.last
end
end
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] }
+ completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
end
end
raise "william screwed up completion: #{partial.inspect}"
end
- completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
+ 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
- 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] }
+ 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
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
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
## 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 = default_labels.to_a.join(" ")
default += " " unless default.empty?
- applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
+ # 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.split(/\s+/).map { |l| l.intern }
+ 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!"
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
+ 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.downcase) || PersonManager.person_for(x) }
+ 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
- draw_screen :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
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
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) +
@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
Ncurses.sync do
Ncurses.endwin
system command
+ Ncurses.stdscr.keypad 1
Ncurses.refresh
Ncurses.curs_set 0
end
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