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 ## pretends ctrl-c's are ctrl-g's
41 def safe_nonblocking_getch
47 module_function :rows, :cols, :curx, :nonblocking_getch, :safe_nonblocking_getch, :mutex, :sync
49 remove_const :KEY_ENTER
50 remove_const :KEY_CANCEL
53 KEY_CANCEL = 7 # ctrl-g
60 class InputSequenceAborted < StandardError; end
63 attr_reader :mode, :x, :y, :width, :height, :title, :atime
64 bool_reader :dirty, :system
65 bool_accessor :force_to_top
67 def initialize window, mode, width, height, opts={}
72 @title = opts[:title] || ""
73 @force_to_top = opts[:force_to_top] || false
74 @x, @y, @width, @height = 0, 0, width, height
76 @system = opts[:system] || false
79 def content_height; @height - 1; end
80 def content_width; @width; end
83 return if cols == @width && rows == @height
87 mode.resize rows, cols
100 def mark_dirty; @dirty = true; end
114 ## s nil means a blank line!
115 def write y, x, s, opts={}
116 return if x >= @width || y >= @height
118 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
120 maxl = @width - x # maximum display width width
121 stringl = maxl # string "length"
122 ## the next horribleness is thanks to ruby's lack of widechar support
123 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
124 @w.mvaddstr y, x, s[0 ... stringl]
125 unless opts[:no_fill]
128 @w.mvaddstr(y, x + l, " " * (maxl - l))
137 def draw_status status
138 write @height - 1, 0, status, :color => :status_color
157 attr_reader :focus_buf
159 ## we have to define the key used to continue in-buffer search here, because
160 ## it has special semantics that BufferManager deals with---current searches
161 ## are canceled by any keypress except this one.
162 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
164 HookManager.register "status-bar-text", <<EOS
165 Sets the status bar. The default status bar contains the mode name, the buffer
166 title, and the mode status. Note that this will be called at least once per
167 keystroke, so excessive computation is discouraged.
170 num_inbox: number of messages in inbox
171 num_inbox_unread: total number of messages marked as unread
172 num_total: total number of messages in the index
173 num_spam: total number of messages marked as spam
174 title: title of the current buffer
175 mode: current mode name (string)
176 status: current mode status (string)
177 Return value: a string to be used as the status bar.
180 HookManager.register "terminal-title-text", <<EOS
181 Sets the title of the current terminal, if applicable. Note that this will be
182 called at least once per keystroke, so excessive computation is discouraged.
184 Variables: the same as status-bar-text hook.
185 Return value: a string to be used as the terminal title.
188 HookManager.register "extra-contact-addresses", <<EOS
189 A list of extra addresses to propose for tab completion, etc. when the
190 user is entering an email address. Can be plain email addresses or can
191 be full "User Name <email@domain.tld>" entries.
194 Return value: an array of email address strings.
203 @minibuf_mutex = Mutex.new
206 @shelled = @asking = false
207 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
208 @sigwinch_happened = false
209 @sigwinch_mutex = Mutex.new
212 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
213 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
215 def buffers; @name_map.to_a; end
218 return unless @buffers.member? buf
219 return if buf == @focus_buf
220 @focus_buf.blur if @focus_buf
225 def raise_to_front buf
226 @buffers.delete(buf) or return
227 if @buffers.length > 0 && @buffers.last.force_to_top?
228 @buffers.insert(-2, buf)
232 focus_on @buffers.last
236 ## we reset force_to_top when rolling buffers. this is so that the
237 ## human can actually still move buffers around, while still
238 ## programmatically being able to pop stuff up in the middle of
239 ## drawing a window without worrying about covering it up.
241 ## if we ever start calling roll_buffers programmatically, we will
242 ## have to change this. but it's not clear that we will ever actually
245 bufs = rollable_buffers
246 bufs.last.force_to_top = false
247 raise_to_front bufs.first
250 def roll_buffers_backwards
251 bufs = rollable_buffers
252 return unless bufs.length > 1
253 bufs.last.force_to_top = false
254 raise_to_front bufs[bufs.length - 2]
258 @buffers.select { |b| !b.system? || @buffers.last == b }
263 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
264 @focus_buf.mode.cancel_search!
265 @focus_buf.mark_dirty
267 @focus_buf.mode.handle_input c
271 def exists? n; @name_map.member? n; end
272 def [] n; @name_map[n]; end
274 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
275 raise ArgumentError, "title must be a string" unless n.is_a? String
279 def completely_redraw_screen
282 ## this magic makes Ncurses get the new size of the screen
284 Ncurses.stdscr.keypad 1
287 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
288 debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
290 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
295 draw_screen :sync => false, :status => status, :title => title
299 def draw_screen opts={}
303 if opts.member? :status
304 [opts[:status], opts[:title]]
306 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
307 get_status_and_title @focus_buf # must be called outside of the ncurses lock
310 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
311 print "\033]0;#{title}\07" if title && @in_x
313 Ncurses.mutex.lock unless opts[:sync] == false
315 ## disabling this for the time being, to help with debugging
316 ## (currently we only have one buffer visible at a time).
317 ## TODO: reenable this if we allow multiple buffers
318 false && @buffers.inject(@dirty) do |dirty, buf|
319 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
320 #dirty ? buf.draw : buf.redraw
328 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
329 @dirty ? buf.draw(status) : buf.redraw(status)
332 draw_minibuf :sync => false unless opts[:skip_minibuf]
336 Ncurses.refresh if opts[:refresh]
337 Ncurses.mutex.unlock unless opts[:sync] == false
340 ## if the named buffer already exists, pops it to the front without
341 ## calling the block. otherwise, gets the mode from the block and
342 ## creates a new buffer. returns two things: the buffer, and a boolean
343 ## indicating whether it's a new buffer or not.
344 def spawn_unless_exists title, opts={}
346 if @name_map.member? title
347 raise_to_front @name_map[title] unless opts[:hidden]
351 spawn title, mode, opts
354 [@name_map[title], new]
357 def spawn title, mode, opts={}
358 raise ArgumentError, "title must be a string" unless title.is_a? String
361 while @name_map.member? realtitle
362 realtitle = "#{title} <#{num}>"
366 width = opts[:width] || Ncurses.cols
367 height = opts[:height] || Ncurses.rows - 1
369 ## since we are currently only doing multiple full-screen modes,
370 ## use stdscr for each window. once we become more sophisticated,
371 ## we may need to use a new Ncurses::WINDOW
373 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
374 ## (opts[:left] || 0))
376 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
378 @name_map[realtitle] = b
382 focus_on b unless @focus_buf
389 ## requires the mode to have #done? and #value methods
390 def spawn_modal title, mode, opts={}
391 b = spawn title, mode, opts
395 c = Ncurses.safe_nonblocking_getch
396 next unless c # getch timeout
397 break if c == Ncurses::KEY_CANCEL
400 rescue InputSequenceAborted # do nothing
410 def kill_all_buffers_safely
411 until @buffers.empty?
412 ## inbox mode always claims it's unkillable. we'll ignore it.
413 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
414 kill_buffer @buffers.last
419 def kill_buffer_safely buf
420 return false unless buf.mode.killable?
426 kill_buffer @buffers.first until @buffers.empty?
430 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
434 @name_map.delete buf.title
435 @focus_buf = nil if @focus_buf == buf
437 ## TODO: something intelligent here
438 ## for now I will simply prohibit killing the inbox buffer.
440 raise_to_front @buffers.last
444 def ask_with_completions domain, question, completions, default=nil
445 ask domain, question, default do |s|
446 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
450 def ask_many_with_completions domain, question, completions, default=nil
451 ask domain, question, default do |partial|
456 when /^(.*\s+)?(.*?)$/
459 raise "william screwed up completion: #{partial.inspect}"
462 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
466 def ask_many_emails_with_completions domain, question, completions, default=nil
467 ask domain, question, default do |partial|
468 prefix, target = partial.split_on_commas_with_remainder
469 target ||= prefix.pop || ""
470 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
471 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
475 def ask_for_filename domain, question, default=nil
476 answer = ask domain, question, default do |s|
477 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
479 name = $2.empty? ? Etc.getlogin : $2
480 dir = Etc.getpwnam(name).dir rescue nil
482 [[s.sub(full, dir), "~#{name}"]]
484 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
485 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
488 else # regular filename completion
489 Dir["#{s}*"].sort.map do |fn|
490 suffix = File.directory?(fn) ? "/" : ""
491 [fn + suffix, File.basename(fn) + suffix]
499 spawn_modal "file browser", FileBrowserMode.new
500 elsif File.directory?(answer)
501 spawn_modal "file browser", FileBrowserMode.new(answer)
503 File.expand_path answer
510 ## returns an array of labels
511 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
512 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
513 default = default_labels.to_a.join(" ")
514 default += " " unless default.empty?
516 # here I would prefer to give more control and allow all_labels instead of
517 # user_defined_labels only
518 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
520 answer = ask_many_with_completions domain, question, applyable_labels, default
524 user_labels = answer.to_set_of_symbols
525 user_labels.each do |l|
526 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
527 BufferManager.flash "'#{l}' is a reserved label!"
534 def ask_for_contacts domain, question, default_contacts=[]
535 default = default_contacts.map { |s| s.to_s }.join(" ")
536 default += " " unless default.empty?
538 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
539 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
541 completions = (recent + contacts).flatten.uniq
542 completions += HookManager.run("extra-contact-addresses") || []
543 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
546 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
550 ## for simplicitly, we always place the question at the very bottom of the
552 def ask domain, question, default=nil, &block
553 raise "impossible!" if @asking
556 @textfields[domain] ||= TextField.new
557 tf = @textfields[domain]
560 status, title = get_status_and_title @focus_buf
563 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
564 @dirty = true # for some reason that blanks the whole fucking screen
565 draw_screen :sync => false, :status => status, :title => title
571 c = Ncurses.safe_nonblocking_getch
572 next unless c # getch timeout
573 break unless tf.handle_input c # process keystroke
575 if tf.new_completions?
576 kill_buffer completion_buf if completion_buf
578 shorts = tf.completions.map { |full, short| short }
579 prefix_len = shorts.shared_prefix.length
581 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
582 completion_buf = spawn "<completions>", mode, :height => 10
584 draw_screen :skip_minibuf => true
586 elsif tf.roll_completions?
587 completion_buf.mode.roll
588 draw_screen :skip_minibuf => true
592 Ncurses.sync { Ncurses.refresh }
595 kill_buffer completion_buf if completion_buf
601 draw_screen :sync => false, :status => status, :title => title
606 def ask_getch question, accept=nil
607 raise "impossible!" if @asking
609 accept = accept.split(//).map { |x| x[0] } if accept
611 status, title = get_status_and_title @focus_buf
613 draw_screen :sync => false, :status => status, :title => title
614 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
615 Ncurses.move Ncurses.rows - 1, question.length + 1
624 key = Ncurses.safe_nonblocking_getch or next
625 if key == Ncurses::KEY_CANCEL
627 elsif accept.nil? || accept.empty? || accept.member?(key)
636 draw_screen :sync => false, :status => status, :title => title
642 ## returns true (y), false (n), or nil (ctrl-g / cancel)
643 def ask_yes_or_no question
644 case(r = ask_getch question, "ynYN")
654 ## turns an input keystroke into an action symbol. returns the action
655 ## if found, nil if not found, and throws InputSequenceAborted if
656 ## the user aborted a multi-key sequence. (Because each of those cases
657 ## should be handled differently.)
659 ## this is in BufferManager because multi-key sequences require prompting.
660 def resolve_input_with_keymap c, keymap
661 action, text = keymap.action_for c
662 while action.is_a? Keymap # multi-key commands, prompt
663 key = BufferManager.ask_getch text
664 unless key # user canceled, abort
666 raise InputSequenceAborted
668 action, text = action.action_for(key) if action.has_key?(key)
674 @minibuf_mutex.synchronize do
677 @minibuf_stack.compact.size, 1].max
681 def draw_minibuf opts={}
683 @minibuf_mutex.synchronize do
684 m = @minibuf_stack.compact
685 m << @flash if @flash
686 m << "" if m.empty? unless @asking # to clear it
689 Ncurses.mutex.lock unless opts[:sync] == false
690 Ncurses.attrset Colormap.color_for(:none)
691 adj = @asking ? 2 : 1
692 m.each_with_index do |s, i|
693 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
695 Ncurses.refresh if opts[:refresh]
696 Ncurses.mutex.unlock unless opts[:sync] == false
702 @minibuf_mutex.synchronize do
704 id ||= @minibuf_stack.length
705 @minibuf_stack[id] = s
709 draw_screen :refresh => true
711 draw_minibuf :refresh => true
724 def erase_flash; @flash = nil; end
728 draw_screen :refresh => true
731 ## a little tricky because we can't just delete_at id because ids
732 ## are relative (they're positions into the array).
734 @minibuf_mutex.synchronize do
735 @minibuf_stack[id] = nil
736 if id == @minibuf_stack.length - 1
738 break if @minibuf_stack[i]
739 @minibuf_stack.delete_at i
744 draw_screen :refresh => true
747 def shell_out command
752 Ncurses.stdscr.keypad 1
760 def default_status_bar buf
761 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
764 def default_terminal_title buf
765 "Sup #{Redwood::VERSION} :: #{buf.title}"
768 def get_status_and_title buf
770 :num_inbox => lambda { Index.num_results_for :label => :inbox },
771 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
772 :num_total => lambda { Index.size },
773 :num_spam => lambda { Index.num_results_for :label => :spam },
775 :mode => buf.mode.name,
776 :status => buf.mode.status
779 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
780 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
782 [statusbar_text, term_title_text]
788 while(u = Etc.getpwent)