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 ## pretends ctrl-c's are ctrl-g's
39 def safe_nonblocking_getch
45 module_function :rows, :cols, :curx, :nonblocking_getch, :safe_nonblocking_getch, :mutex, :sync
47 remove_const :KEY_ENTER
48 remove_const :KEY_CANCEL
51 KEY_CANCEL = 7 # ctrl-g
58 class InputSequenceAborted < StandardError; end
61 attr_reader :mode, :x, :y, :width, :height, :title, :atime
62 bool_reader :dirty, :system
63 bool_accessor :force_to_top
65 def initialize window, mode, width, height, opts={}
70 @title = opts[:title] || ""
71 @force_to_top = opts[:force_to_top] || false
72 @x, @y, @width, @height = 0, 0, width, height
74 @system = opts[:system] || false
77 def content_height; @height - 1; end
78 def content_width; @width; end
81 return if cols == @width && rows == @height
85 mode.resize rows, cols
98 def mark_dirty; @dirty = true; end
112 ## s nil means a blank line!
113 def write y, x, s, opts={}
114 return if x >= @width || y >= @height
116 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
118 maxl = @width - x # maximum display width width
119 stringl = maxl # string "length"
120 ## the next horribleness is thanks to ruby's lack of widechar support
121 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
122 @w.mvaddstr y, x, s[0 ... stringl]
123 unless opts[:no_fill]
126 @w.mvaddstr(y, x + l, " " * (maxl - l))
135 def draw_status status
136 write @height - 1, 0, status, :color => :status_color
155 attr_reader :focus_buf
157 ## we have to define the key used to continue in-buffer search here, because
158 ## it has special semantics that BufferManager deals with---current searches
159 ## are canceled by any keypress except this one.
160 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
162 HookManager.register "status-bar-text", <<EOS
163 Sets the status bar. The default status bar contains the mode name, the buffer
164 title, and the mode status. Note that this will be called at least once per
165 keystroke, so excessive computation is discouraged.
168 num_inbox: number of messages in inbox
169 num_inbox_unread: total number of messages marked as unread
170 num_total: total number of messages in the index
171 num_spam: total number of messages marked as spam
172 title: title of the current buffer
173 mode: current mode name (string)
174 status: current mode status (string)
175 Return value: a string to be used as the status bar.
178 HookManager.register "terminal-title-text", <<EOS
179 Sets the title of the current terminal, if applicable. Note that this will be
180 called at least once per keystroke, so excessive computation is discouraged.
182 Variables: the same as status-bar-text hook.
183 Return value: a string to be used as the terminal title.
186 HookManager.register "extra-contact-addresses", <<EOS
187 A list of extra addresses to propose for tab completion, etc. when the
188 user is entering an email address. Can be plain email addresses or can
189 be full "User Name <email@domain.tld>" entries.
192 Return value: an array of email address strings.
201 @minibuf_mutex = Mutex.new
204 @shelled = @asking = false
205 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
206 @sigwinch_happened = false
207 @sigwinch_mutex = Mutex.new
210 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
211 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
213 def buffers; @name_map.to_a; end
216 return unless @buffers.member? buf
217 return if buf == @focus_buf
218 @focus_buf.blur if @focus_buf
223 def raise_to_front buf
224 @buffers.delete(buf) or return
225 if @buffers.length > 0 && @buffers.last.force_to_top?
226 @buffers.insert(-2, buf)
230 focus_on @buffers.last
234 ## we reset force_to_top when rolling buffers. this is so that the
235 ## human can actually still move buffers around, while still
236 ## programmatically being able to pop stuff up in the middle of
237 ## drawing a window without worrying about covering it up.
239 ## if we ever start calling roll_buffers programmatically, we will
240 ## have to change this. but it's not clear that we will ever actually
243 bufs = rollable_buffers
244 bufs.last.force_to_top = false
245 raise_to_front bufs.first
248 def roll_buffers_backwards
249 bufs = rollable_buffers
250 return unless bufs.length > 1
251 bufs.last.force_to_top = false
252 raise_to_front bufs[bufs.length - 2]
256 @buffers.select { |b| !b.system? || @buffers.last == b }
261 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
262 @focus_buf.mode.cancel_search!
263 @focus_buf.mark_dirty
265 @focus_buf.mode.handle_input c
269 def exists? n; @name_map.member? n; end
270 def [] n; @name_map[n]; end
272 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
273 raise ArgumentError, "title must be a string" unless n.is_a? String
277 def completely_redraw_screen
280 ## this magic makes Ncurses get the new size of the screen
282 Ncurses.stdscr.keypad 1
285 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
286 debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
288 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
293 draw_screen :sync => false, :status => status, :title => title
297 def draw_screen opts={}
301 if opts.member? :status
302 [opts[:status], opts[:title]]
304 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
305 get_status_and_title @focus_buf # must be called outside of the ncurses lock
308 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
309 print "\033]0;#{title}\07" if title && @in_x
311 Ncurses.mutex.lock unless opts[:sync] == false
313 ## disabling this for the time being, to help with debugging
314 ## (currently we only have one buffer visible at a time).
315 ## TODO: reenable this if we allow multiple buffers
316 false && @buffers.inject(@dirty) do |dirty, buf|
317 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
318 #dirty ? buf.draw : buf.redraw
326 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
327 @dirty ? buf.draw(status) : buf.redraw(status)
330 draw_minibuf :sync => false unless opts[:skip_minibuf]
334 Ncurses.refresh if opts[:refresh]
335 Ncurses.mutex.unlock unless opts[:sync] == false
338 ## if the named buffer already exists, pops it to the front without
339 ## calling the block. otherwise, gets the mode from the block and
340 ## creates a new buffer. returns two things: the buffer, and a boolean
341 ## indicating whether it's a new buffer or not.
342 def spawn_unless_exists title, opts={}
344 if @name_map.member? title
345 raise_to_front @name_map[title] unless opts[:hidden]
349 spawn title, mode, opts
352 [@name_map[title], new]
355 def spawn title, mode, opts={}
356 raise ArgumentError, "title must be a string" unless title.is_a? String
359 while @name_map.member? realtitle
360 realtitle = "#{title} <#{num}>"
364 width = opts[:width] || Ncurses.cols
365 height = opts[:height] || Ncurses.rows - 1
367 ## since we are currently only doing multiple full-screen modes,
368 ## use stdscr for each window. once we become more sophisticated,
369 ## we may need to use a new Ncurses::WINDOW
371 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
372 ## (opts[:left] || 0))
374 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
376 @name_map[realtitle] = b
380 focus_on b unless @focus_buf
387 ## requires the mode to have #done? and #value methods
388 def spawn_modal title, mode, opts={}
389 b = spawn title, mode, opts
393 c = Ncurses.safe_nonblocking_getch
394 next unless c # getch timeout
395 break if c == Ncurses::KEY_CANCEL
398 rescue InputSequenceAborted # do nothing
408 def kill_all_buffers_safely
409 until @buffers.empty?
410 ## inbox mode always claims it's unkillable. we'll ignore it.
411 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
412 kill_buffer @buffers.last
417 def kill_buffer_safely buf
418 return false unless buf.mode.killable?
424 kill_buffer @buffers.first until @buffers.empty?
428 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
432 @name_map.delete buf.title
433 @focus_buf = nil if @focus_buf == buf
435 ## TODO: something intelligent here
436 ## for now I will simply prohibit killing the inbox buffer.
438 raise_to_front @buffers.last
442 def ask_with_completions domain, question, completions, default=nil
443 ask domain, question, default do |s|
444 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
448 def ask_many_with_completions domain, question, completions, default=nil
449 ask domain, question, default do |partial|
454 when /^(.*\s+)?(.*?)$/
457 raise "william screwed up completion: #{partial.inspect}"
460 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
464 def ask_many_emails_with_completions domain, question, completions, default=nil
465 ask domain, question, default do |partial|
466 prefix, target = partial.split_on_commas_with_remainder
467 target ||= prefix.pop || ""
468 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
469 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
473 def ask_for_filename domain, question, default=nil
474 answer = ask domain, question, default do |s|
475 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
477 name = $2.empty? ? Etc.getlogin : $2
478 dir = Etc.getpwnam(name).dir rescue nil
480 [[s.sub(full, dir), "~#{name}"]]
482 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
483 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
486 else # regular filename completion
487 Dir["#{s}*"].sort.map do |fn|
488 suffix = File.directory?(fn) ? "/" : ""
489 [fn + suffix, File.basename(fn) + suffix]
497 spawn_modal "file browser", FileBrowserMode.new
498 elsif File.directory?(answer)
499 spawn_modal "file browser", FileBrowserMode.new(answer)
501 File.expand_path answer
508 ## returns an array of labels
509 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
510 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
511 default = default_labels.to_a.join(" ")
512 default += " " unless default.empty?
514 # here I would prefer to give more control and allow all_labels instead of
515 # user_defined_labels only
516 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
518 answer = ask_many_with_completions domain, question, applyable_labels, default
522 user_labels = answer.to_set_of_symbols
523 user_labels.each do |l|
524 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
525 BufferManager.flash "'#{l}' is a reserved label!"
532 def ask_for_contacts domain, question, default_contacts=[]
533 default = default_contacts.map { |s| s.to_s }.join(" ")
534 default += " " unless default.empty?
536 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
537 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
539 completions = (recent + contacts).flatten.uniq
540 completions += HookManager.run("extra-contact-addresses") || []
541 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
544 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
548 ## for simplicitly, we always place the question at the very bottom of the
550 def ask domain, question, default=nil, &block
551 raise "impossible!" if @asking
554 @textfields[domain] ||= TextField.new
555 tf = @textfields[domain]
558 status, title = get_status_and_title @focus_buf
561 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
562 @dirty = true # for some reason that blanks the whole fucking screen
563 draw_screen :sync => false, :status => status, :title => title
569 c = Ncurses.safe_nonblocking_getch
570 next unless c # getch timeout
571 break unless tf.handle_input c # process keystroke
573 if tf.new_completions?
574 kill_buffer completion_buf if completion_buf
576 shorts = tf.completions.map { |full, short| short }
577 prefix_len = shorts.shared_prefix.length
579 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
580 completion_buf = spawn "<completions>", mode, :height => 10
582 draw_screen :skip_minibuf => true
584 elsif tf.roll_completions?
585 completion_buf.mode.roll
586 draw_screen :skip_minibuf => true
590 Ncurses.sync { Ncurses.refresh }
593 kill_buffer completion_buf if completion_buf
599 draw_screen :sync => false, :status => status, :title => title
604 def ask_getch question, accept=nil
605 raise "impossible!" if @asking
607 accept = accept.split(//).map { |x| x[0] } if accept
609 status, title = get_status_and_title @focus_buf
611 draw_screen :sync => false, :status => status, :title => title
612 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
613 Ncurses.move Ncurses.rows - 1, question.length + 1
622 key = Ncurses.safe_nonblocking_getch or next
623 if key == Ncurses::KEY_CANCEL
625 elsif accept.nil? || accept.empty? || accept.member?(key)
634 draw_screen :sync => false, :status => status, :title => title
640 ## returns true (y), false (n), or nil (ctrl-g / cancel)
641 def ask_yes_or_no question
642 case(r = ask_getch question, "ynYN")
652 ## turns an input keystroke into an action symbol. returns the action
653 ## if found, nil if not found, and throws InputSequenceAborted if
654 ## the user aborted a multi-key sequence. (Because each of those cases
655 ## should be handled differently.)
657 ## this is in BufferManager because multi-key sequences require prompting.
658 def resolve_input_with_keymap c, keymap
659 action, text = keymap.action_for c
660 while action.is_a? Keymap # multi-key commands, prompt
661 key = BufferManager.ask_getch text
662 unless key # user canceled, abort
664 raise InputSequenceAborted
666 action, text = action.action_for(key) if action.has_key?(key)
672 @minibuf_mutex.synchronize do
675 @minibuf_stack.compact.size, 1].max
679 def draw_minibuf opts={}
681 @minibuf_mutex.synchronize do
682 m = @minibuf_stack.compact
683 m << @flash if @flash
684 m << "" if m.empty? unless @asking # to clear it
687 Ncurses.mutex.lock unless opts[:sync] == false
688 Ncurses.attrset Colormap.color_for(:none)
689 adj = @asking ? 2 : 1
690 m.each_with_index do |s, i|
691 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
693 Ncurses.refresh if opts[:refresh]
694 Ncurses.mutex.unlock unless opts[:sync] == false
700 @minibuf_mutex.synchronize do
702 id ||= @minibuf_stack.length
703 @minibuf_stack[id] = s
707 draw_screen :refresh => true
709 draw_minibuf :refresh => true
722 def erase_flash; @flash = nil; end
726 draw_screen :refresh => true
729 ## a little tricky because we can't just delete_at id because ids
730 ## are relative (they're positions into the array).
732 @minibuf_mutex.synchronize do
733 @minibuf_stack[id] = nil
734 if id == @minibuf_stack.length - 1
736 break if @minibuf_stack[i]
737 @minibuf_stack.delete_at i
742 draw_screen :refresh => true
745 def shell_out command
750 Ncurses.stdscr.keypad 1
758 def default_status_bar buf
759 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
762 def default_terminal_title buf
763 "Sup #{Redwood::VERSION} :: #{buf.title}"
766 def get_status_and_title buf
768 :num_inbox => lambda { Index.num_results_for :label => :inbox },
769 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
770 :num_total => lambda { Index.size },
771 :num_spam => lambda { Index.num_results_for :label => :spam },
773 :mode => buf.mode.name,
774 :status => buf.mode.status
777 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
778 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
780 [statusbar_text, term_title_text]
786 while(u = Etc.getpwent)