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
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
68 def content_height; @height - 1; end
69 def content_width; @width; end
72 return if cols == @width && rows == @height
76 mode.resize rows, cols
89 def mark_dirty; @dirty = true; end
102 ## s nil means a blank line!
103 def write y, x, s, opts={}
104 return if x >= @width || y >= @height
106 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
109 @w.mvaddstr y, x, s[0 ... maxl]
110 unless s.length >= maxl || opts[:no_fill]
111 @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
119 def draw_status status
120 write @height - 1, 0, status, :color => :status_color
139 attr_reader :focus_buf
141 ## we have to define the key used to continue in-buffer search here, because
142 ## it has special semantics that BufferManager deals with---current searches
143 ## are canceled by any keypress except this one.
144 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
146 HookManager.register "status-bar-text", <<EOS
147 Sets the status bar. The default status bar contains the mode name, the buffer
148 title, and the mode status. Note that this will be called at least once per
149 keystroke, so excessive computation is discouraged.
152 num_inbox: number of messages in inbox
153 num_inbox_unread: total number of messages marked as unread
154 num_total: total number of messages in the index
155 num_spam: total number of messages marked as spam
156 title: title of the current buffer
157 mode: current mode name (string)
158 status: current mode status (string)
159 Return value: a string to be used as the status bar.
162 HookManager.register "terminal-title-text", <<EOS
163 Sets the title of the current terminal, if applicable. Note that this will be
164 called at least once per keystroke, so excessive computation is discouraged.
166 Variables: the same as status-bar-text hook.
167 Return value: a string to be used as the terminal title.
170 HookManager.register "extra-contact-addresses", <<EOS
171 A list of extra addresses to propose for tab completion, etc. when the
172 user is entering an email address. Can be plain email addresses or can
173 be full "User Name <email@domain.tld>" entries.
176 Return value: an array of email address strings.
185 @minibuf_mutex = Mutex.new
188 @shelled = @asking = false
189 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
191 self.class.i_am_the_instance self
194 def buffers; @name_map.to_a; end
197 return unless @buffers.member? buf
198 return if buf == @focus_buf
199 @focus_buf.blur if @focus_buf
204 def raise_to_front buf
205 @buffers.delete(buf) or return
206 if @buffers.length > 0 && @buffers.last.force_to_top?
207 @buffers.insert(-2, buf)
211 focus_on @buffers.last
215 ## we reset force_to_top when rolling buffers. this is so that the
216 ## human can actually still move buffers around, while still
217 ## programmatically being able to pop stuff up in the middle of
218 ## drawing a window without worrying about covering it up.
220 ## if we ever start calling roll_buffers programmatically, we will
221 ## have to change this. but it's not clear that we will ever actually
224 @buffers.last.force_to_top = false
225 raise_to_front @buffers.first
228 def roll_buffers_backwards
229 return unless @buffers.length > 1
230 @buffers.last.force_to_top = false
231 raise_to_front @buffers[@buffers.length - 2]
236 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
237 @focus_buf.mode.cancel_search!
238 @focus_buf.mark_dirty
240 @focus_buf.mode.handle_input c
244 def exists? n; @name_map.member? n; end
245 def [] n; @name_map[n]; end
247 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
248 raise ArgumentError, "title must be a string" unless n.is_a? String
252 def completely_redraw_screen
255 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
260 draw_screen :sync => false, :status => status, :title => title
264 def draw_screen opts={}
268 if opts.member? :status
269 [opts[:status], opts[:title]]
271 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
272 get_status_and_title @focus_buf # must be called outside of the ncurses lock
275 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
276 print "\033]0;#{title}\07" if title && @in_x
278 Ncurses.mutex.lock unless opts[:sync] == false
280 ## disabling this for the time being, to help with debugging
281 ## (currently we only have one buffer visible at a time).
282 ## TODO: reenable this if we allow multiple buffers
283 false && @buffers.inject(@dirty) do |dirty, buf|
284 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
285 #dirty ? buf.draw : buf.redraw
293 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
294 @dirty ? buf.draw(status) : buf.redraw(status)
297 draw_minibuf :sync => false unless opts[:skip_minibuf]
301 Ncurses.refresh if opts[:refresh]
302 Ncurses.mutex.unlock unless opts[:sync] == false
305 ## if the named buffer already exists, pops it to the front without
306 ## calling the block. otherwise, gets the mode from the block and
307 ## creates a new buffer. returns two things: the buffer, and a boolean
308 ## indicating whether it's a new buffer or not.
309 def spawn_unless_exists title, opts={}
311 if @name_map.member? title
312 raise_to_front @name_map[title] unless opts[:hidden]
316 spawn title, mode, opts
319 [@name_map[title], new]
322 def spawn title, mode, opts={}
323 raise ArgumentError, "title must be a string" unless title.is_a? String
326 while @name_map.member? realtitle
327 realtitle = "#{title} <#{num}>"
331 width = opts[:width] || Ncurses.cols
332 height = opts[:height] || Ncurses.rows - 1
334 ## since we are currently only doing multiple full-screen modes,
335 ## use stdscr for each window. once we become more sophisticated,
336 ## we may need to use a new Ncurses::WINDOW
338 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
339 ## (opts[:left] || 0))
341 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
343 @name_map[realtitle] = b
347 focus_on b unless @focus_buf
354 ## requires the mode to have #done? and #value methods
355 def spawn_modal title, mode, opts={}
356 b = spawn title, mode, opts
360 c = Ncurses.nonblocking_getch
361 next unless c # getch timeout
362 break if c == Ncurses::KEY_CANCEL
365 rescue InputSequenceAborted # do nothing
375 def kill_all_buffers_safely
376 until @buffers.empty?
377 ## inbox mode always claims it's unkillable. we'll ignore it.
378 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
379 kill_buffer @buffers.last
384 def kill_buffer_safely buf
385 return false unless buf.mode.killable?
391 kill_buffer @buffers.first until @buffers.empty?
395 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
399 @name_map.delete buf.title
400 @focus_buf = nil if @focus_buf == buf
402 ## TODO: something intelligent here
403 ## for now I will simply prohibit killing the inbox buffer.
405 raise_to_front @buffers.last
409 def ask_with_completions domain, question, completions, default=nil
410 ask domain, question, default do |s|
411 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
415 def ask_many_with_completions domain, question, completions, default=nil
416 ask domain, question, default do |partial|
421 when /^(.*\s+)?(.*?)$/
424 raise "william screwed up completion: #{partial.inspect}"
427 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
431 def ask_many_emails_with_completions domain, question, completions, default=nil
432 ask domain, question, default do |partial|
433 prefix, target = partial.split_on_commas_with_remainder
434 target ||= prefix.pop || ""
435 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
436 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
440 def ask_for_filename domain, question, default=nil
441 answer = ask domain, question, default do |s|
442 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
444 name = $2.empty? ? Etc.getlogin : $2
445 dir = Etc.getpwnam(name).dir rescue nil
447 [[s.sub(full, dir), "~#{name}"]]
449 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
450 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
453 else # regular filename completion
454 Dir["#{s}*"].sort.map do |fn|
455 suffix = File.directory?(fn) ? "/" : ""
456 [fn + suffix, File.basename(fn) + suffix]
464 spawn_modal "file browser", FileBrowserMode.new
465 elsif File.directory?(answer)
466 spawn_modal "file browser", FileBrowserMode.new(answer)
468 File.expand_path answer
475 ## returns an array of labels
476 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
477 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
478 default = default_labels.join(" ")
479 default += " " unless default.empty?
481 # here I would prefer to give more control and allow all_labels instead of
482 # user_defined_labels only
483 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
485 answer = ask_many_with_completions domain, question, applyable_labels, default
489 user_labels = answer.split(/\s+/).map { |l| l.intern }
490 user_labels.each do |l|
491 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
492 BufferManager.flash "'#{l}' is a reserved label!"
499 def ask_for_contacts domain, question, default_contacts=[]
500 default = default_contacts.map { |s| s.to_s }.join(" ")
501 default += " " unless default.empty?
503 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
504 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
506 completions = (recent + contacts).flatten.uniq
507 completions += HookManager.run("extra-contact-addresses") || []
508 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
511 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || PersonManager.person_for(x) }
515 ## for simplicitly, we always place the question at the very bottom of the
517 def ask domain, question, default=nil, &block
518 raise "impossible!" if @asking
521 @textfields[domain] ||= TextField.new
522 tf = @textfields[domain]
525 status, title = get_status_and_title @focus_buf
528 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
529 @dirty = true # for some reason that blanks the whole fucking screen
530 draw_screen :sync => false, :status => status, :title => title
536 c = Ncurses.nonblocking_getch
537 next unless c # getch timeout
538 break unless tf.handle_input c # process keystroke
540 if tf.new_completions?
541 kill_buffer completion_buf if completion_buf
543 shorts = tf.completions.map { |full, short| short }
544 prefix_len = shorts.shared_prefix.length
546 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
547 completion_buf = spawn "<completions>", mode, :height => 10
549 draw_screen :skip_minibuf => true
551 elsif tf.roll_completions?
552 completion_buf.mode.roll
553 draw_screen :skip_minibuf => true
557 Ncurses.sync { Ncurses.refresh }
560 kill_buffer completion_buf if completion_buf
566 draw_screen :sync => false, :status => status, :title => title
571 def ask_getch question, accept=nil
572 raise "impossible!" if @asking
574 accept = accept.split(//).map { |x| x[0] } if accept
576 status, title = get_status_and_title @focus_buf
578 draw_screen :sync => false, :status => status, :title => title
579 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
580 Ncurses.move Ncurses.rows - 1, question.length + 1
589 key = Ncurses.nonblocking_getch or next
590 if key == Ncurses::KEY_CANCEL
592 elsif accept.nil? || accept.empty? || accept.member?(key)
601 draw_screen :sync => false, :status => status, :title => title
607 ## returns true (y), false (n), or nil (ctrl-g / cancel)
608 def ask_yes_or_no question
609 case(r = ask_getch question, "ynYN")
619 ## turns an input keystroke into an action symbol. returns the action
620 ## if found, nil if not found, and throws InputSequenceAborted if
621 ## the user aborted a multi-key sequence. (Because each of those cases
622 ## should be handled differently.)
624 ## this is in BufferManager because multi-key sequences require prompting.
625 def resolve_input_with_keymap c, keymap
626 action, text = keymap.action_for c
627 while action.is_a? Keymap # multi-key commands, prompt
628 key = BufferManager.ask_getch text
629 unless key # user canceled, abort
631 raise InputSequenceAborted
633 action, text = action.action_for(key) if action.has_key?(key)
639 @minibuf_mutex.synchronize do
642 @minibuf_stack.compact.size, 1].max
646 def draw_minibuf opts={}
648 @minibuf_mutex.synchronize do
649 m = @minibuf_stack.compact
650 m << @flash if @flash
651 m << "" if m.empty? unless @asking # to clear it
654 Ncurses.mutex.lock unless opts[:sync] == false
655 Ncurses.attrset Colormap.color_for(:none)
656 adj = @asking ? 2 : 1
657 m.each_with_index do |s, i|
658 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
660 Ncurses.refresh if opts[:refresh]
661 Ncurses.mutex.unlock unless opts[:sync] == false
667 @minibuf_mutex.synchronize do
669 id ||= @minibuf_stack.length
670 @minibuf_stack[id] = s
674 draw_screen :refresh => true
676 draw_minibuf :refresh => true
689 def erase_flash; @flash = nil; end
693 draw_screen :refresh => true
696 ## a little tricky because we can't just delete_at id because ids
697 ## are relative (they're positions into the array).
699 @minibuf_mutex.synchronize do
700 @minibuf_stack[id] = nil
701 if id == @minibuf_stack.length - 1
703 break if @minibuf_stack[i]
704 @minibuf_stack.delete_at i
709 draw_screen :refresh => true
712 def shell_out command
724 def default_status_bar buf
725 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
728 def default_terminal_title buf
729 "Sup #{Redwood::VERSION} :: #{buf.title}"
732 def get_status_and_title buf
734 :num_inbox => lambda { Index.num_results_for :label => :inbox },
735 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
736 :num_total => lambda { Index.size },
737 :num_spam => lambda { Index.num_results_for :label => :spam },
739 :mode => buf.mode.name,
740 :status => buf.mode.status
743 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
744 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
746 [statusbar_text, term_title_text]
752 while(u = Etc.getpwent)