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
32 ## it is NECESSARY to wrap Ncurses.getch in a select() otherwise all
33 ## background threads will be BLOCKED. (except in very modern versions
34 ## of libncurses-ruby. the current one on ubuntu seems to work well.)
35 if IO.select([$stdin], nil, nil, 0.5)
40 module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
42 remove_const :KEY_ENTER
43 remove_const :KEY_CANCEL
46 KEY_CANCEL = 7 # ctrl-g
53 class InputSequenceAborted < StandardError; end
56 attr_reader :mode, :x, :y, :width, :height, :title, :atime
57 bool_reader :dirty, :system
58 bool_accessor :force_to_top
60 def initialize window, mode, width, height, opts={}
65 @title = opts[:title] || ""
66 @force_to_top = opts[:force_to_top] || false
67 @x, @y, @width, @height = 0, 0, width, height
69 @system = opts[:system] || false
72 def content_height; @height - 1; end
73 def content_width; @width; end
76 return if cols == @width && rows == @height
80 mode.resize rows, cols
93 def mark_dirty; @dirty = true; end
107 ## s nil means a blank line!
108 def write y, x, s, opts={}
109 return if x >= @width || y >= @height
111 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
113 maxl = @width - x # maximum display width width
114 stringl = maxl # string "length"
115 ## the next horribleness is thanks to ruby's lack of widechar support
116 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
117 @w.mvaddstr y, x, s[0 ... stringl]
118 unless opts[:no_fill]
121 @w.mvaddstr(y, x + l, " " * (maxl - l))
130 def draw_status status
131 write @height - 1, 0, status, :color => :status_color
150 attr_reader :focus_buf
152 ## we have to define the key used to continue in-buffer search here, because
153 ## it has special semantics that BufferManager deals with---current searches
154 ## are canceled by any keypress except this one.
155 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
157 HookManager.register "status-bar-text", <<EOS
158 Sets the status bar. The default status bar contains the mode name, the buffer
159 title, and the mode status. Note that this will be called at least once per
160 keystroke, so excessive computation is discouraged.
163 num_inbox: number of messages in inbox
164 num_inbox_unread: total number of messages marked as unread
165 num_total: total number of messages in the index
166 num_spam: total number of messages marked as spam
167 title: title of the current buffer
168 mode: current mode name (string)
169 status: current mode status (string)
170 Return value: a string to be used as the status bar.
173 HookManager.register "terminal-title-text", <<EOS
174 Sets the title of the current terminal, if applicable. Note that this will be
175 called at least once per keystroke, so excessive computation is discouraged.
177 Variables: the same as status-bar-text hook.
178 Return value: a string to be used as the terminal title.
181 HookManager.register "extra-contact-addresses", <<EOS
182 A list of extra addresses to propose for tab completion, etc. when the
183 user is entering an email address. Can be plain email addresses or can
184 be full "User Name <email@domain.tld>" entries.
187 Return value: an array of email address strings.
196 @minibuf_mutex = Mutex.new
199 @shelled = @asking = false
200 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
201 @sigwinch_happened = false
202 @sigwinch_mutex = Mutex.new
205 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
206 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
208 def buffers; @name_map.to_a; end
211 return unless @buffers.member? buf
212 return if buf == @focus_buf
213 @focus_buf.blur if @focus_buf
218 def raise_to_front buf
219 @buffers.delete(buf) or return
220 if @buffers.length > 0 && @buffers.last.force_to_top?
221 @buffers.insert(-2, buf)
225 focus_on @buffers.last
229 ## we reset force_to_top when rolling buffers. this is so that the
230 ## human can actually still move buffers around, while still
231 ## programmatically being able to pop stuff up in the middle of
232 ## drawing a window without worrying about covering it up.
234 ## if we ever start calling roll_buffers programmatically, we will
235 ## have to change this. but it's not clear that we will ever actually
238 bufs = rollable_buffers
239 bufs.last.force_to_top = false
240 raise_to_front bufs.first
243 def roll_buffers_backwards
244 bufs = rollable_buffers
245 return unless bufs.length > 1
246 bufs.last.force_to_top = false
247 raise_to_front bufs[bufs.length - 2]
251 @buffers.select { |b| !b.system? || @buffers.last == b }
256 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
257 @focus_buf.mode.cancel_search!
258 @focus_buf.mark_dirty
260 @focus_buf.mode.handle_input c
264 def exists? n; @name_map.member? n; end
265 def [] n; @name_map[n]; end
267 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
268 raise ArgumentError, "title must be a string" unless n.is_a? String
272 def completely_redraw_screen
275 ## this magic makes Ncurses get the new size of the screen
277 Ncurses.stdscr.keypad 1
280 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
281 debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
283 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
288 draw_screen :sync => false, :status => status, :title => title
292 def draw_screen opts={}
296 if opts.member? :status
297 [opts[:status], opts[:title]]
299 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
300 get_status_and_title @focus_buf # must be called outside of the ncurses lock
303 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
304 print "\033]0;#{title}\07" if title && @in_x
306 Ncurses.mutex.lock unless opts[:sync] == false
308 ## disabling this for the time being, to help with debugging
309 ## (currently we only have one buffer visible at a time).
310 ## TODO: reenable this if we allow multiple buffers
311 false && @buffers.inject(@dirty) do |dirty, buf|
312 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
313 #dirty ? buf.draw : buf.redraw
321 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
322 @dirty ? buf.draw(status) : buf.redraw(status)
325 draw_minibuf :sync => false unless opts[:skip_minibuf]
329 Ncurses.refresh if opts[:refresh]
330 Ncurses.mutex.unlock unless opts[:sync] == false
333 ## if the named buffer already exists, pops it to the front without
334 ## calling the block. otherwise, gets the mode from the block and
335 ## creates a new buffer. returns two things: the buffer, and a boolean
336 ## indicating whether it's a new buffer or not.
337 def spawn_unless_exists title, opts={}
339 if @name_map.member? title
340 raise_to_front @name_map[title] unless opts[:hidden]
344 spawn title, mode, opts
347 [@name_map[title], new]
350 def spawn title, mode, opts={}
351 raise ArgumentError, "title must be a string" unless title.is_a? String
354 while @name_map.member? realtitle
355 realtitle = "#{title} <#{num}>"
359 width = opts[:width] || Ncurses.cols
360 height = opts[:height] || Ncurses.rows - 1
362 ## since we are currently only doing multiple full-screen modes,
363 ## use stdscr for each window. once we become more sophisticated,
364 ## we may need to use a new Ncurses::WINDOW
366 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
367 ## (opts[:left] || 0))
369 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
371 @name_map[realtitle] = b
375 focus_on b unless @focus_buf
382 ## requires the mode to have #done? and #value methods
383 def spawn_modal title, mode, opts={}
384 b = spawn title, mode, opts
388 c = Ncurses.nonblocking_getch
389 next unless c # getch timeout
390 break if c == Ncurses::KEY_CANCEL
393 rescue InputSequenceAborted # do nothing
403 def kill_all_buffers_safely
404 until @buffers.empty?
405 ## inbox mode always claims it's unkillable. we'll ignore it.
406 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
407 kill_buffer @buffers.last
412 def kill_buffer_safely buf
413 return false unless buf.mode.killable?
419 kill_buffer @buffers.first until @buffers.empty?
423 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
427 @name_map.delete buf.title
428 @focus_buf = nil if @focus_buf == buf
430 ## TODO: something intelligent here
431 ## for now I will simply prohibit killing the inbox buffer.
433 raise_to_front @buffers.last
437 def ask_with_completions domain, question, completions, default=nil
438 ask domain, question, default do |s|
439 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
443 def ask_many_with_completions domain, question, completions, default=nil
444 ask domain, question, default do |partial|
449 when /^(.*\s+)?(.*?)$/
452 raise "william screwed up completion: #{partial.inspect}"
455 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
459 def ask_many_emails_with_completions domain, question, completions, default=nil
460 ask domain, question, default do |partial|
461 prefix, target = partial.split_on_commas_with_remainder
462 target ||= prefix.pop || ""
463 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
464 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
468 def ask_for_filename domain, question, default=nil
469 answer = ask domain, question, default do |s|
470 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
472 name = $2.empty? ? Etc.getlogin : $2
473 dir = Etc.getpwnam(name).dir rescue nil
475 [[s.sub(full, dir), "~#{name}"]]
477 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
478 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
481 else # regular filename completion
482 Dir["#{s}*"].sort.map do |fn|
483 suffix = File.directory?(fn) ? "/" : ""
484 [fn + suffix, File.basename(fn) + suffix]
492 spawn_modal "file browser", FileBrowserMode.new
493 elsif File.directory?(answer)
494 spawn_modal "file browser", FileBrowserMode.new(answer)
496 File.expand_path answer
503 ## returns an array of labels
504 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
505 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
506 default = default_labels.to_a.join(" ")
507 default += " " unless default.empty?
509 # here I would prefer to give more control and allow all_labels instead of
510 # user_defined_labels only
511 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
513 answer = ask_many_with_completions domain, question, applyable_labels, default
517 user_labels = answer.to_set_of_symbols
518 user_labels.each do |l|
519 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
520 BufferManager.flash "'#{l}' is a reserved label!"
527 def ask_for_contacts domain, question, default_contacts=[]
528 default = default_contacts.map { |s| s.to_s }.join(" ")
529 default += " " unless default.empty?
531 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
532 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
534 completions = (recent + contacts).flatten.uniq
535 completions += HookManager.run("extra-contact-addresses") || []
536 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
539 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
543 ## for simplicitly, we always place the question at the very bottom of the
545 def ask domain, question, default=nil, &block
546 raise "impossible!" if @asking
549 @textfields[domain] ||= TextField.new
550 tf = @textfields[domain]
553 status, title = get_status_and_title @focus_buf
556 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
557 @dirty = true # for some reason that blanks the whole fucking screen
558 draw_screen :sync => false, :status => status, :title => title
564 c = Ncurses.nonblocking_getch
565 next unless c # getch timeout
566 break unless tf.handle_input c # process keystroke
568 if tf.new_completions?
569 kill_buffer completion_buf if completion_buf
571 shorts = tf.completions.map { |full, short| short }
572 prefix_len = shorts.shared_prefix.length
574 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
575 completion_buf = spawn "<completions>", mode, :height => 10
577 draw_screen :skip_minibuf => true
579 elsif tf.roll_completions?
580 completion_buf.mode.roll
581 draw_screen :skip_minibuf => true
585 Ncurses.sync { Ncurses.refresh }
588 kill_buffer completion_buf if completion_buf
594 draw_screen :sync => false, :status => status, :title => title
599 def ask_getch question, accept=nil
600 raise "impossible!" if @asking
602 accept = accept.split(//).map { |x| x[0] } if accept
604 status, title = get_status_and_title @focus_buf
606 draw_screen :sync => false, :status => status, :title => title
607 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
608 Ncurses.move Ncurses.rows - 1, question.length + 1
617 key = Ncurses.nonblocking_getch or next
618 if key == Ncurses::KEY_CANCEL
620 elsif accept.nil? || accept.empty? || accept.member?(key)
629 draw_screen :sync => false, :status => status, :title => title
635 ## returns true (y), false (n), or nil (ctrl-g / cancel)
636 def ask_yes_or_no question
637 case(r = ask_getch question, "ynYN")
647 ## turns an input keystroke into an action symbol. returns the action
648 ## if found, nil if not found, and throws InputSequenceAborted if
649 ## the user aborted a multi-key sequence. (Because each of those cases
650 ## should be handled differently.)
652 ## this is in BufferManager because multi-key sequences require prompting.
653 def resolve_input_with_keymap c, keymap
654 action, text = keymap.action_for c
655 while action.is_a? Keymap # multi-key commands, prompt
656 key = BufferManager.ask_getch text
657 unless key # user canceled, abort
659 raise InputSequenceAborted
661 action, text = action.action_for(key) if action.has_key?(key)
667 @minibuf_mutex.synchronize do
670 @minibuf_stack.compact.size, 1].max
674 def draw_minibuf opts={}
676 @minibuf_mutex.synchronize do
677 m = @minibuf_stack.compact
678 m << @flash if @flash
679 m << "" if m.empty? unless @asking # to clear it
682 Ncurses.mutex.lock unless opts[:sync] == false
683 Ncurses.attrset Colormap.color_for(:none)
684 adj = @asking ? 2 : 1
685 m.each_with_index do |s, i|
686 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
688 Ncurses.refresh if opts[:refresh]
689 Ncurses.mutex.unlock unless opts[:sync] == false
695 @minibuf_mutex.synchronize do
697 id ||= @minibuf_stack.length
698 @minibuf_stack[id] = s
702 draw_screen :refresh => true
704 draw_minibuf :refresh => true
717 def erase_flash; @flash = nil; end
721 draw_screen :refresh => true
724 ## a little tricky because we can't just delete_at id because ids
725 ## are relative (they're positions into the array).
727 @minibuf_mutex.synchronize do
728 @minibuf_stack[id] = nil
729 if id == @minibuf_stack.length - 1
731 break if @minibuf_stack[i]
732 @minibuf_stack.delete_at i
737 draw_screen :refresh => true
740 def shell_out command
745 Ncurses.stdscr.keypad 1
753 def default_status_bar buf
754 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
757 def default_terminal_title buf
758 "Sup #{Redwood::VERSION} :: #{buf.title}"
761 def get_status_and_title buf
763 :num_inbox => lambda { Index.num_results_for :label => :inbox },
764 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
765 :num_total => lambda { Index.size },
766 :num_spam => lambda { Index.num_results_for :label => :spam },
768 :mode => buf.mode.name,
769 :status => buf.mode.status
772 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
773 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
775 [statusbar_text, term_title_text]
781 while(u = Etc.getpwent)