9 stdscr.getmaxyx lame, lamer
15 stdscr.getmaxyx lame, lamer
21 stdscr.getyx lame, lamer
25 def mutex; @mutex ||= Mutex.new; end
26 def sync &b; mutex.synchronize(&b); end
30 ## it is NECESSARY to wrap Ncurses.getch in a select() otherwise all
31 ## background threads will be BLOCKED. (except in very modern versions
32 ## of libncurses-ruby. the current one on ubuntu seems to work well.)
33 if IO.select([$stdin], nil, nil, 0.5)
38 module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
40 remove_const :KEY_ENTER
41 remove_const :KEY_CANCEL
44 KEY_CANCEL = 7 # ctrl-g
51 class InputSequenceAborted < StandardError; end
54 attr_reader :mode, :x, :y, :width, :height, :title, :atime
55 bool_reader :dirty, :system
56 bool_accessor :force_to_top
58 def initialize window, mode, width, height, opts={}
63 @title = opts[:title] || ""
64 @force_to_top = opts[:force_to_top] || false
65 @x, @y, @width, @height = 0, 0, width, height
67 @system = opts[:system] || false
70 def content_height; @height - 1; end
71 def content_width; @width; end
74 return if cols == @width && rows == @height
78 mode.resize rows, cols
91 def mark_dirty; @dirty = true; end
105 ## s nil means a blank line!
106 def write y, x, s, opts={}
107 return if x >= @width || y >= @height
109 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
111 maxl = @width - x # maximum display width width
112 stringl = maxl # string "length"
113 ## the next horribleness is thanks to ruby's lack of widechar support
114 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
115 @w.mvaddstr y, x, s[0 ... stringl]
116 unless opts[:no_fill]
119 @w.mvaddstr(y, x + l, " " * (maxl - l))
128 def draw_status status
129 write @height - 1, 0, status, :color => :status_color
148 attr_reader :focus_buf
150 ## we have to define the key used to continue in-buffer search here, because
151 ## it has special semantics that BufferManager deals with---current searches
152 ## are canceled by any keypress except this one.
153 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
155 HookManager.register "status-bar-text", <<EOS
156 Sets the status bar. The default status bar contains the mode name, the buffer
157 title, and the mode status. Note that this will be called at least once per
158 keystroke, so excessive computation is discouraged.
161 num_inbox: number of messages in inbox
162 num_inbox_unread: total number of messages marked as unread
163 num_total: total number of messages in the index
164 num_spam: total number of messages marked as spam
165 title: title of the current buffer
166 mode: current mode name (string)
167 status: current mode status (string)
168 Return value: a string to be used as the status bar.
171 HookManager.register "terminal-title-text", <<EOS
172 Sets the title of the current terminal, if applicable. Note that this will be
173 called at least once per keystroke, so excessive computation is discouraged.
175 Variables: the same as status-bar-text hook.
176 Return value: a string to be used as the terminal title.
179 HookManager.register "extra-contact-addresses", <<EOS
180 A list of extra addresses to propose for tab completion, etc. when the
181 user is entering an email address. Can be plain email addresses or can
182 be full "User Name <email@domain.tld>" entries.
185 Return value: an array of email address strings.
194 @minibuf_mutex = Mutex.new
197 @shelled = @asking = false
198 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
199 @sigwinch_happened = false
200 @sigwinch_mutex = Mutex.new
203 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
204 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
206 def buffers; @name_map.to_a; end
209 return unless @buffers.member? buf
210 return if buf == @focus_buf
211 @focus_buf.blur if @focus_buf
216 def raise_to_front buf
217 @buffers.delete(buf) or return
218 if @buffers.length > 0 && @buffers.last.force_to_top?
219 @buffers.insert(-2, buf)
223 focus_on @buffers.last
227 ## we reset force_to_top when rolling buffers. this is so that the
228 ## human can actually still move buffers around, while still
229 ## programmatically being able to pop stuff up in the middle of
230 ## drawing a window without worrying about covering it up.
232 ## if we ever start calling roll_buffers programmatically, we will
233 ## have to change this. but it's not clear that we will ever actually
236 bufs = rollable_buffers
237 bufs.last.force_to_top = false
238 raise_to_front bufs.first
241 def roll_buffers_backwards
242 bufs = rollable_buffers
243 return unless bufs.length > 1
244 bufs.last.force_to_top = false
245 raise_to_front bufs[bufs.length - 2]
249 @buffers.select { |b| !b.system? || @buffers.last == b }
254 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
255 @focus_buf.mode.cancel_search!
256 @focus_buf.mark_dirty
258 @focus_buf.mode.handle_input c
262 def exists? n; @name_map.member? n; end
263 def [] n; @name_map[n]; end
265 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
266 raise ArgumentError, "title must be a string" unless n.is_a? String
270 def completely_redraw_screen
273 ## this magic makes Ncurses get the new size of the screen
275 Ncurses.stdscr.keypad 1
278 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
279 debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
281 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
286 draw_screen :sync => false, :status => status, :title => title
290 def draw_screen opts={}
294 if opts.member? :status
295 [opts[:status], opts[:title]]
297 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
298 get_status_and_title @focus_buf # must be called outside of the ncurses lock
301 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
302 print "\033]0;#{title}\07" if title && @in_x
304 Ncurses.mutex.lock unless opts[:sync] == false
306 ## disabling this for the time being, to help with debugging
307 ## (currently we only have one buffer visible at a time).
308 ## TODO: reenable this if we allow multiple buffers
309 false && @buffers.inject(@dirty) do |dirty, buf|
310 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
311 #dirty ? buf.draw : buf.redraw
319 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
320 @dirty ? buf.draw(status) : buf.redraw(status)
323 draw_minibuf :sync => false unless opts[:skip_minibuf]
327 Ncurses.refresh if opts[:refresh]
328 Ncurses.mutex.unlock unless opts[:sync] == false
331 ## if the named buffer already exists, pops it to the front without
332 ## calling the block. otherwise, gets the mode from the block and
333 ## creates a new buffer. returns two things: the buffer, and a boolean
334 ## indicating whether it's a new buffer or not.
335 def spawn_unless_exists title, opts={}
337 if @name_map.member? title
338 raise_to_front @name_map[title] unless opts[:hidden]
342 spawn title, mode, opts
345 [@name_map[title], new]
348 def spawn title, mode, opts={}
349 raise ArgumentError, "title must be a string" unless title.is_a? String
352 while @name_map.member? realtitle
353 realtitle = "#{title} <#{num}>"
357 width = opts[:width] || Ncurses.cols
358 height = opts[:height] || Ncurses.rows - 1
360 ## since we are currently only doing multiple full-screen modes,
361 ## use stdscr for each window. once we become more sophisticated,
362 ## we may need to use a new Ncurses::WINDOW
364 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
365 ## (opts[:left] || 0))
367 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
369 @name_map[realtitle] = b
373 focus_on b unless @focus_buf
380 ## requires the mode to have #done? and #value methods
381 def spawn_modal title, mode, opts={}
382 b = spawn title, mode, opts
386 c = Ncurses.nonblocking_getch
387 next unless c # getch timeout
388 break if c == Ncurses::KEY_CANCEL
391 rescue InputSequenceAborted # do nothing
401 def kill_all_buffers_safely
402 until @buffers.empty?
403 ## inbox mode always claims it's unkillable. we'll ignore it.
404 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
405 kill_buffer @buffers.last
410 def kill_buffer_safely buf
411 return false unless buf.mode.killable?
417 kill_buffer @buffers.first until @buffers.empty?
421 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
425 @name_map.delete buf.title
426 @focus_buf = nil if @focus_buf == buf
428 ## TODO: something intelligent here
429 ## for now I will simply prohibit killing the inbox buffer.
431 raise_to_front @buffers.last
435 def ask_with_completions domain, question, completions, default=nil
436 ask domain, question, default do |s|
437 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
441 def ask_many_with_completions domain, question, completions, default=nil
442 ask domain, question, default do |partial|
447 when /^(.*\s+)?(.*?)$/
450 raise "william screwed up completion: #{partial.inspect}"
453 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
457 def ask_many_emails_with_completions domain, question, completions, default=nil
458 ask domain, question, default do |partial|
459 prefix, target = partial.split_on_commas_with_remainder
460 target ||= prefix.pop || ""
461 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
462 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
466 def ask_for_filename domain, question, default=nil
467 answer = ask domain, question, default do |s|
468 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
470 name = $2.empty? ? Etc.getlogin : $2
471 dir = Etc.getpwnam(name).dir rescue nil
473 [[s.sub(full, dir), "~#{name}"]]
475 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
476 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
479 else # regular filename completion
480 Dir["#{s}*"].sort.map do |fn|
481 suffix = File.directory?(fn) ? "/" : ""
482 [fn + suffix, File.basename(fn) + suffix]
490 spawn_modal "file browser", FileBrowserMode.new
491 elsif File.directory?(answer)
492 spawn_modal "file browser", FileBrowserMode.new(answer)
494 File.expand_path answer
501 ## returns an array of labels
502 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
503 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
504 default = default_labels.to_a.join(" ")
505 default += " " unless default.empty?
507 # here I would prefer to give more control and allow all_labels instead of
508 # user_defined_labels only
509 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
511 answer = ask_many_with_completions domain, question, applyable_labels, default
515 user_labels = answer.to_set_of_symbols
516 user_labels.each do |l|
517 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
518 BufferManager.flash "'#{l}' is a reserved label!"
525 def ask_for_contacts domain, question, default_contacts=[]
526 default = default_contacts.map { |s| s.to_s }.join(" ")
527 default += " " unless default.empty?
529 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
530 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
532 completions = (recent + contacts).flatten.uniq
533 completions += HookManager.run("extra-contact-addresses") || []
534 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
537 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
541 ## for simplicitly, we always place the question at the very bottom of the
543 def ask domain, question, default=nil, &block
544 raise "impossible!" if @asking
547 @textfields[domain] ||= TextField.new
548 tf = @textfields[domain]
551 status, title = get_status_and_title @focus_buf
554 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
555 @dirty = true # for some reason that blanks the whole fucking screen
556 draw_screen :sync => false, :status => status, :title => title
562 c = Ncurses.nonblocking_getch
563 next unless c # getch timeout
564 break unless tf.handle_input c # process keystroke
566 if tf.new_completions?
567 kill_buffer completion_buf if completion_buf
569 shorts = tf.completions.map { |full, short| short }
570 prefix_len = shorts.shared_prefix.length
572 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
573 completion_buf = spawn "<completions>", mode, :height => 10
575 draw_screen :skip_minibuf => true
577 elsif tf.roll_completions?
578 completion_buf.mode.roll
579 draw_screen :skip_minibuf => true
583 Ncurses.sync { Ncurses.refresh }
586 kill_buffer completion_buf if completion_buf
592 draw_screen :sync => false, :status => status, :title => title
597 def ask_getch question, accept=nil
598 raise "impossible!" if @asking
600 accept = accept.split(//).map { |x| x[0] } if accept
602 status, title = get_status_and_title @focus_buf
604 draw_screen :sync => false, :status => status, :title => title
605 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
606 Ncurses.move Ncurses.rows - 1, question.length + 1
615 key = Ncurses.nonblocking_getch or next
616 if key == Ncurses::KEY_CANCEL
618 elsif accept.nil? || accept.empty? || accept.member?(key)
627 draw_screen :sync => false, :status => status, :title => title
633 ## returns true (y), false (n), or nil (ctrl-g / cancel)
634 def ask_yes_or_no question
635 case(r = ask_getch question, "ynYN")
645 ## turns an input keystroke into an action symbol. returns the action
646 ## if found, nil if not found, and throws InputSequenceAborted if
647 ## the user aborted a multi-key sequence. (Because each of those cases
648 ## should be handled differently.)
650 ## this is in BufferManager because multi-key sequences require prompting.
651 def resolve_input_with_keymap c, keymap
652 action, text = keymap.action_for c
653 while action.is_a? Keymap # multi-key commands, prompt
654 key = BufferManager.ask_getch text
655 unless key # user canceled, abort
657 raise InputSequenceAborted
659 action, text = action.action_for(key) if action.has_key?(key)
665 @minibuf_mutex.synchronize do
668 @minibuf_stack.compact.size, 1].max
672 def draw_minibuf opts={}
674 @minibuf_mutex.synchronize do
675 m = @minibuf_stack.compact
676 m << @flash if @flash
677 m << "" if m.empty? unless @asking # to clear it
680 Ncurses.mutex.lock unless opts[:sync] == false
681 Ncurses.attrset Colormap.color_for(:none)
682 adj = @asking ? 2 : 1
683 m.each_with_index do |s, i|
684 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
686 Ncurses.refresh if opts[:refresh]
687 Ncurses.mutex.unlock unless opts[:sync] == false
693 @minibuf_mutex.synchronize do
695 id ||= @minibuf_stack.length
696 @minibuf_stack[id] = s
700 draw_screen :refresh => true
702 draw_minibuf :refresh => true
715 def erase_flash; @flash = nil; end
719 draw_screen :refresh => true
722 ## a little tricky because we can't just delete_at id because ids
723 ## are relative (they're positions into the array).
725 @minibuf_mutex.synchronize do
726 @minibuf_stack[id] = nil
727 if id == @minibuf_stack.length - 1
729 break if @minibuf_stack[i]
730 @minibuf_stack.delete_at i
735 draw_screen :refresh => true
738 def shell_out command
743 Ncurses.stdscr.keypad 1
751 def default_status_bar buf
752 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
755 def default_terminal_title buf
756 "Sup #{Redwood::VERSION} :: #{buf.title}"
759 def get_status_and_title buf
761 :num_inbox => lambda { Index.num_results_for :label => :inbox },
762 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
763 :num_total => lambda { Index.size },
764 :num_spam => lambda { Index.num_results_for :label => :spam },
766 :mode => buf.mode.name,
767 :status => buf.mode.status
770 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
771 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
773 [statusbar_text, term_title_text]
779 while(u = Etc.getpwent)