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
28 ## magically, this stuff seems to work now. i could swear it didn't
31 if IO.select([$stdin], nil, nil, 1)
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])
112 @w.mvaddstr y, x, s[0 ... maxl]
113 unless s.length >= maxl || opts[:no_fill]
114 @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
122 def draw_status status
123 write @height - 1, 0, status, :color => :status_color
142 attr_reader :focus_buf
144 ## we have to define the key used to continue in-buffer search here, because
145 ## it has special semantics that BufferManager deals with---current searches
146 ## are canceled by any keypress except this one.
147 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
149 HookManager.register "status-bar-text", <<EOS
150 Sets the status bar. The default status bar contains the mode name, the buffer
151 title, and the mode status. Note that this will be called at least once per
152 keystroke, so excessive computation is discouraged.
155 num_inbox: number of messages in inbox
156 num_inbox_unread: total number of messages marked as unread
157 num_total: total number of messages in the index
158 num_spam: total number of messages marked as spam
159 title: title of the current buffer
160 mode: current mode name (string)
161 status: current mode status (string)
162 Return value: a string to be used as the status bar.
165 HookManager.register "terminal-title-text", <<EOS
166 Sets the title of the current terminal, if applicable. Note that this will be
167 called at least once per keystroke, so excessive computation is discouraged.
169 Variables: the same as status-bar-text hook.
170 Return value: a string to be used as the terminal title.
173 HookManager.register "extra-contact-addresses", <<EOS
174 A list of extra addresses to propose for tab completion, etc. when the
175 user is entering an email address. Can be plain email addresses or can
176 be full "User Name <email@domain.tld>" entries.
179 Return value: an array of email address strings.
188 @minibuf_mutex = Mutex.new
191 @shelled = @asking = false
192 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
194 self.class.i_am_the_instance self
197 def buffers; @name_map.to_a; end
200 return unless @buffers.member? buf
201 return if buf == @focus_buf
202 @focus_buf.blur if @focus_buf
207 def raise_to_front buf
208 @buffers.delete(buf) or return
209 if @buffers.length > 0 && @buffers.last.force_to_top?
210 @buffers.insert(-2, buf)
214 focus_on @buffers.last
218 ## we reset force_to_top when rolling buffers. this is so that the
219 ## human can actually still move buffers around, while still
220 ## programmatically being able to pop stuff up in the middle of
221 ## drawing a window without worrying about covering it up.
223 ## if we ever start calling roll_buffers programmatically, we will
224 ## have to change this. but it's not clear that we will ever actually
227 @buffers.last.force_to_top = false
228 raise_to_front @buffers.first
231 def roll_buffers_backwards
232 return unless @buffers.length > 1
233 @buffers.last.force_to_top = false
234 raise_to_front @buffers[@buffers.length - 2]
239 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
240 @focus_buf.mode.cancel_search!
241 @focus_buf.mark_dirty
243 @focus_buf.mode.handle_input c
247 def exists? n; @name_map.member? n; end
248 def [] n; @name_map[n]; end
250 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
251 raise ArgumentError, "title must be a string" unless n.is_a? String
255 def completely_redraw_screen
258 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
263 draw_screen :sync => false, :status => status, :title => title
267 def draw_screen opts={}
271 if opts.member? :status
272 [opts[:status], opts[:title]]
274 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
275 get_status_and_title @focus_buf # must be called outside of the ncurses lock
278 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
279 print "\033]0;#{title}\07" if title && @in_x
281 Ncurses.mutex.lock unless opts[:sync] == false
283 ## disabling this for the time being, to help with debugging
284 ## (currently we only have one buffer visible at a time).
285 ## TODO: reenable this if we allow multiple buffers
286 false && @buffers.inject(@dirty) do |dirty, buf|
287 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
288 #dirty ? buf.draw : buf.redraw
296 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
297 @dirty ? buf.draw(status) : buf.redraw(status)
300 draw_minibuf :sync => false unless opts[:skip_minibuf]
304 Ncurses.refresh if opts[:refresh]
305 Ncurses.mutex.unlock unless opts[:sync] == false
308 ## if the named buffer already exists, pops it to the front without
309 ## calling the block. otherwise, gets the mode from the block and
310 ## creates a new buffer. returns two things: the buffer, and a boolean
311 ## indicating whether it's a new buffer or not.
312 def spawn_unless_exists title, opts={}
314 if @name_map.member? title
315 raise_to_front @name_map[title] unless opts[:hidden]
319 spawn title, mode, opts
322 [@name_map[title], new]
325 def spawn title, mode, opts={}
326 raise ArgumentError, "title must be a string" unless title.is_a? String
329 while @name_map.member? realtitle
330 realtitle = "#{title} <#{num}>"
334 width = opts[:width] || Ncurses.cols
335 height = opts[:height] || Ncurses.rows - 1
337 ## since we are currently only doing multiple full-screen modes,
338 ## use stdscr for each window. once we become more sophisticated,
339 ## we may need to use a new Ncurses::WINDOW
341 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
342 ## (opts[:left] || 0))
344 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
346 @name_map[realtitle] = b
350 focus_on b unless @focus_buf
357 ## requires the mode to have #done? and #value methods
358 def spawn_modal title, mode, opts={}
359 b = spawn title, mode, opts
363 c = Ncurses.nonblocking_getch
364 next unless c # getch timeout
365 break if c == Ncurses::KEY_CANCEL
368 rescue InputSequenceAborted # do nothing
378 def kill_all_buffers_safely
379 until @buffers.empty?
380 ## inbox mode always claims it's unkillable. we'll ignore it.
381 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
382 kill_buffer @buffers.last
387 def kill_buffer_safely buf
388 return false unless buf.mode.killable?
394 kill_buffer @buffers.first until @buffers.empty?
398 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
402 @name_map.delete buf.title
403 @focus_buf = nil if @focus_buf == buf
405 ## TODO: something intelligent here
406 ## for now I will simply prohibit killing the inbox buffer.
408 raise_to_front @buffers.last
412 def ask_with_completions domain, question, completions, default=nil
413 ask domain, question, default do |s|
414 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
418 def ask_many_with_completions domain, question, completions, default=nil
419 ask domain, question, default do |partial|
424 when /^(.*\s+)?(.*?)$/
427 raise "william screwed up completion: #{partial.inspect}"
430 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
434 def ask_many_emails_with_completions domain, question, completions, default=nil
435 ask domain, question, default do |partial|
436 prefix, target = partial.split_on_commas_with_remainder
437 target ||= prefix.pop || ""
438 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
439 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
443 def ask_for_filename domain, question, default=nil
444 answer = ask domain, question, default do |s|
445 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
447 name = $2.empty? ? Etc.getlogin : $2
448 dir = Etc.getpwnam(name).dir rescue nil
450 [[s.sub(full, dir), "~#{name}"]]
452 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
453 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
456 else # regular filename completion
457 Dir["#{s}*"].sort.map do |fn|
458 suffix = File.directory?(fn) ? "/" : ""
459 [fn + suffix, File.basename(fn) + suffix]
467 spawn_modal "file browser", FileBrowserMode.new
468 elsif File.directory?(answer)
469 spawn_modal "file browser", FileBrowserMode.new(answer)
471 File.expand_path answer
478 ## returns an array of labels
479 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
480 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
481 default = default_labels.join(" ")
482 default += " " unless default.empty?
484 # here I would prefer to give more control and allow all_labels instead of
485 # user_defined_labels only
486 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
488 answer = ask_many_with_completions domain, question, applyable_labels, default
492 user_labels = answer.symbolistize
493 user_labels.each do |l|
494 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
495 BufferManager.flash "'#{l}' is a reserved label!"
502 def ask_for_contacts domain, question, default_contacts=[]
503 default = default_contacts.map { |s| s.to_s }.join(" ")
504 default += " " unless default.empty?
506 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
507 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
509 completions = (recent + contacts).flatten.uniq
510 completions += HookManager.run("extra-contact-addresses") || []
511 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
514 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
518 ## for simplicitly, we always place the question at the very bottom of the
520 def ask domain, question, default=nil, &block
521 raise "impossible!" if @asking
524 @textfields[domain] ||= TextField.new
525 tf = @textfields[domain]
528 status, title = get_status_and_title @focus_buf
531 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
532 @dirty = true # for some reason that blanks the whole fucking screen
533 draw_screen :sync => false, :status => status, :title => title
539 c = Ncurses.nonblocking_getch
540 next unless c # getch timeout
541 break unless tf.handle_input c # process keystroke
543 if tf.new_completions?
544 kill_buffer completion_buf if completion_buf
546 shorts = tf.completions.map { |full, short| short }
547 prefix_len = shorts.shared_prefix.length
549 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
550 completion_buf = spawn "<completions>", mode, :height => 10
552 draw_screen :skip_minibuf => true
554 elsif tf.roll_completions?
555 completion_buf.mode.roll
556 draw_screen :skip_minibuf => true
560 Ncurses.sync { Ncurses.refresh }
563 kill_buffer completion_buf if completion_buf
569 draw_screen :sync => false, :status => status, :title => title
574 def ask_getch question, accept=nil
575 raise "impossible!" if @asking
577 accept = accept.split(//).map { |x| x[0] } if accept
579 status, title = get_status_and_title @focus_buf
581 draw_screen :sync => false, :status => status, :title => title
582 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
583 Ncurses.move Ncurses.rows - 1, question.length + 1
592 key = Ncurses.nonblocking_getch or next
593 if key == Ncurses::KEY_CANCEL
595 elsif accept.nil? || accept.empty? || accept.member?(key)
604 draw_screen :sync => false, :status => status, :title => title
610 ## returns true (y), false (n), or nil (ctrl-g / cancel)
611 def ask_yes_or_no question
612 case(r = ask_getch question, "ynYN")
622 ## turns an input keystroke into an action symbol. returns the action
623 ## if found, nil if not found, and throws InputSequenceAborted if
624 ## the user aborted a multi-key sequence. (Because each of those cases
625 ## should be handled differently.)
627 ## this is in BufferManager because multi-key sequences require prompting.
628 def resolve_input_with_keymap c, keymap
629 action, text = keymap.action_for c
630 while action.is_a? Keymap # multi-key commands, prompt
631 key = BufferManager.ask_getch text
632 unless key # user canceled, abort
634 raise InputSequenceAborted
636 action, text = action.action_for(key) if action.has_key?(key)
642 @minibuf_mutex.synchronize do
645 @minibuf_stack.compact.size, 1].max
649 def draw_minibuf opts={}
651 @minibuf_mutex.synchronize do
652 m = @minibuf_stack.compact
653 m << @flash if @flash
654 m << "" if m.empty? unless @asking # to clear it
657 Ncurses.mutex.lock unless opts[:sync] == false
658 Ncurses.attrset Colormap.color_for(:none)
659 adj = @asking ? 2 : 1
660 m.each_with_index do |s, i|
661 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
663 Ncurses.refresh if opts[:refresh]
664 Ncurses.mutex.unlock unless opts[:sync] == false
670 @minibuf_mutex.synchronize do
672 id ||= @minibuf_stack.length
673 @minibuf_stack[id] = s
677 draw_screen :refresh => true
679 draw_minibuf :refresh => true
692 def erase_flash; @flash = nil; end
696 draw_screen :refresh => true
699 ## a little tricky because we can't just delete_at id because ids
700 ## are relative (they're positions into the array).
702 @minibuf_mutex.synchronize do
703 @minibuf_stack[id] = nil
704 if id == @minibuf_stack.length - 1
706 break if @minibuf_stack[i]
707 @minibuf_stack.delete_at i
712 draw_screen :refresh => true
715 def shell_out command
727 def default_status_bar buf
728 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
731 def default_terminal_title buf
732 "Sup #{Redwood::VERSION} :: #{buf.title}"
735 def get_status_and_title buf
737 :num_inbox => lambda { Index.num_results_for :label => :inbox },
738 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
739 :num_total => lambda { Index.size },
740 :num_spam => lambda { Index.num_results_for :label => :spam },
742 :mode => buf.mode.name,
743 :status => buf.mode.status
746 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
747 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
749 [statusbar_text, term_title_text]
755 while(u = Etc.getpwent)