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
204 self.class.i_am_the_instance self
207 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
208 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
210 def buffers; @name_map.to_a; end
213 return unless @buffers.member? buf
214 return if buf == @focus_buf
215 @focus_buf.blur if @focus_buf
220 def raise_to_front buf
221 @buffers.delete(buf) or return
222 if @buffers.length > 0 && @buffers.last.force_to_top?
223 @buffers.insert(-2, buf)
227 focus_on @buffers.last
231 ## we reset force_to_top when rolling buffers. this is so that the
232 ## human can actually still move buffers around, while still
233 ## programmatically being able to pop stuff up in the middle of
234 ## drawing a window without worrying about covering it up.
236 ## if we ever start calling roll_buffers programmatically, we will
237 ## have to change this. but it's not clear that we will ever actually
240 @buffers.last.force_to_top = false
241 raise_to_front @buffers.first
244 def roll_buffers_backwards
245 return unless @buffers.length > 1
246 @buffers.last.force_to_top = false
247 raise_to_front @buffers[@buffers.length - 2]
252 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
253 @focus_buf.mode.cancel_search!
254 @focus_buf.mark_dirty
256 @focus_buf.mode.handle_input c
260 def exists? n; @name_map.member? n; end
261 def [] n; @name_map[n]; end
263 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
264 raise ArgumentError, "title must be a string" unless n.is_a? String
268 def completely_redraw_screen
271 ## this magic makes Ncurses get the new size of the screen
273 Ncurses.stdscr.keypad 1
275 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
276 Redwood::log "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
278 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
283 draw_screen :sync => false, :status => status, :title => title
287 def draw_screen opts={}
291 if opts.member? :status
292 [opts[:status], opts[:title]]
294 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
295 get_status_and_title @focus_buf # must be called outside of the ncurses lock
298 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
299 print "\033]0;#{title}\07" if title && @in_x
301 Ncurses.mutex.lock unless opts[:sync] == false
303 ## disabling this for the time being, to help with debugging
304 ## (currently we only have one buffer visible at a time).
305 ## TODO: reenable this if we allow multiple buffers
306 false && @buffers.inject(@dirty) do |dirty, buf|
307 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
308 #dirty ? buf.draw : buf.redraw
316 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
317 @dirty ? buf.draw(status) : buf.redraw(status)
320 draw_minibuf :sync => false unless opts[:skip_minibuf]
324 Ncurses.refresh if opts[:refresh]
325 Ncurses.mutex.unlock unless opts[:sync] == false
328 ## if the named buffer already exists, pops it to the front without
329 ## calling the block. otherwise, gets the mode from the block and
330 ## creates a new buffer. returns two things: the buffer, and a boolean
331 ## indicating whether it's a new buffer or not.
332 def spawn_unless_exists title, opts={}
334 if @name_map.member? title
335 raise_to_front @name_map[title] unless opts[:hidden]
339 spawn title, mode, opts
342 [@name_map[title], new]
345 def spawn title, mode, opts={}
346 raise ArgumentError, "title must be a string" unless title.is_a? String
349 while @name_map.member? realtitle
350 realtitle = "#{title} <#{num}>"
354 width = opts[:width] || Ncurses.cols
355 height = opts[:height] || Ncurses.rows - 1
357 ## since we are currently only doing multiple full-screen modes,
358 ## use stdscr for each window. once we become more sophisticated,
359 ## we may need to use a new Ncurses::WINDOW
361 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
362 ## (opts[:left] || 0))
364 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
366 @name_map[realtitle] = b
370 focus_on b unless @focus_buf
377 ## requires the mode to have #done? and #value methods
378 def spawn_modal title, mode, opts={}
379 b = spawn title, mode, opts
383 c = Ncurses.nonblocking_getch
384 next unless c # getch timeout
385 break if c == Ncurses::KEY_CANCEL
388 rescue InputSequenceAborted # do nothing
398 def kill_all_buffers_safely
399 until @buffers.empty?
400 ## inbox mode always claims it's unkillable. we'll ignore it.
401 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
402 kill_buffer @buffers.last
407 def kill_buffer_safely buf
408 return false unless buf.mode.killable?
414 kill_buffer @buffers.first until @buffers.empty?
418 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
422 @name_map.delete buf.title
423 @focus_buf = nil if @focus_buf == buf
425 ## TODO: something intelligent here
426 ## for now I will simply prohibit killing the inbox buffer.
428 raise_to_front @buffers.last
432 def ask_with_completions domain, question, completions, default=nil
433 ask domain, question, default do |s|
434 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
438 def ask_many_with_completions domain, question, completions, default=nil
439 ask domain, question, default do |partial|
444 when /^(.*\s+)?(.*?)$/
447 raise "william screwed up completion: #{partial.inspect}"
450 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
454 def ask_many_emails_with_completions domain, question, completions, default=nil
455 ask domain, question, default do |partial|
456 prefix, target = partial.split_on_commas_with_remainder
457 target ||= prefix.pop || ""
458 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
459 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
463 def ask_for_filename domain, question, default=nil
464 answer = ask domain, question, default do |s|
465 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
467 name = $2.empty? ? Etc.getlogin : $2
468 dir = Etc.getpwnam(name).dir rescue nil
470 [[s.sub(full, dir), "~#{name}"]]
472 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
473 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
476 else # regular filename completion
477 Dir["#{s}*"].sort.map do |fn|
478 suffix = File.directory?(fn) ? "/" : ""
479 [fn + suffix, File.basename(fn) + suffix]
487 spawn_modal "file browser", FileBrowserMode.new
488 elsif File.directory?(answer)
489 spawn_modal "file browser", FileBrowserMode.new(answer)
491 File.expand_path answer
498 ## returns an array of labels
499 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
500 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
501 default = default_labels.join(" ")
502 default += " " unless default.empty?
504 # here I would prefer to give more control and allow all_labels instead of
505 # user_defined_labels only
506 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
508 answer = ask_many_with_completions domain, question, applyable_labels, default
512 user_labels = answer.symbolistize
513 user_labels.each do |l|
514 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
515 BufferManager.flash "'#{l}' is a reserved label!"
522 def ask_for_contacts domain, question, default_contacts=[]
523 default = default_contacts.map { |s| s.to_s }.join(" ")
524 default += " " unless default.empty?
526 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
527 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
529 completions = (recent + contacts).flatten.uniq
530 completions += HookManager.run("extra-contact-addresses") || []
531 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
534 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
538 ## for simplicitly, we always place the question at the very bottom of the
540 def ask domain, question, default=nil, &block
541 raise "impossible!" if @asking
544 @textfields[domain] ||= TextField.new
545 tf = @textfields[domain]
548 status, title = get_status_and_title @focus_buf
551 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
552 @dirty = true # for some reason that blanks the whole fucking screen
553 draw_screen :sync => false, :status => status, :title => title
559 c = Ncurses.nonblocking_getch
560 next unless c # getch timeout
561 break unless tf.handle_input c # process keystroke
563 if tf.new_completions?
564 kill_buffer completion_buf if completion_buf
566 shorts = tf.completions.map { |full, short| short }
567 prefix_len = shorts.shared_prefix.length
569 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
570 completion_buf = spawn "<completions>", mode, :height => 10
572 draw_screen :skip_minibuf => true
574 elsif tf.roll_completions?
575 completion_buf.mode.roll
576 draw_screen :skip_minibuf => true
580 Ncurses.sync { Ncurses.refresh }
583 kill_buffer completion_buf if completion_buf
589 draw_screen :sync => false, :status => status, :title => title
594 def ask_getch question, accept=nil
595 raise "impossible!" if @asking
597 accept = accept.split(//).map { |x| x[0] } if accept
599 status, title = get_status_and_title @focus_buf
601 draw_screen :sync => false, :status => status, :title => title
602 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
603 Ncurses.move Ncurses.rows - 1, question.length + 1
612 key = Ncurses.nonblocking_getch or next
613 if key == Ncurses::KEY_CANCEL
615 elsif accept.nil? || accept.empty? || accept.member?(key)
624 draw_screen :sync => false, :status => status, :title => title
630 ## returns true (y), false (n), or nil (ctrl-g / cancel)
631 def ask_yes_or_no question
632 case(r = ask_getch question, "ynYN")
642 ## turns an input keystroke into an action symbol. returns the action
643 ## if found, nil if not found, and throws InputSequenceAborted if
644 ## the user aborted a multi-key sequence. (Because each of those cases
645 ## should be handled differently.)
647 ## this is in BufferManager because multi-key sequences require prompting.
648 def resolve_input_with_keymap c, keymap
649 action, text = keymap.action_for c
650 while action.is_a? Keymap # multi-key commands, prompt
651 key = BufferManager.ask_getch text
652 unless key # user canceled, abort
654 raise InputSequenceAborted
656 action, text = action.action_for(key) if action.has_key?(key)
662 @minibuf_mutex.synchronize do
665 @minibuf_stack.compact.size, 1].max
669 def draw_minibuf opts={}
671 @minibuf_mutex.synchronize do
672 m = @minibuf_stack.compact
673 m << @flash if @flash
674 m << "" if m.empty? unless @asking # to clear it
677 Ncurses.mutex.lock unless opts[:sync] == false
678 Ncurses.attrset Colormap.color_for(:none)
679 adj = @asking ? 2 : 1
680 m.each_with_index do |s, i|
681 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
683 Ncurses.refresh if opts[:refresh]
684 Ncurses.mutex.unlock unless opts[:sync] == false
690 @minibuf_mutex.synchronize do
692 id ||= @minibuf_stack.length
693 @minibuf_stack[id] = s
697 draw_screen :refresh => true
699 draw_minibuf :refresh => true
712 def erase_flash; @flash = nil; end
716 draw_screen :refresh => true
719 ## a little tricky because we can't just delete_at id because ids
720 ## are relative (they're positions into the array).
722 @minibuf_mutex.synchronize do
723 @minibuf_stack[id] = nil
724 if id == @minibuf_stack.length - 1
726 break if @minibuf_stack[i]
727 @minibuf_stack.delete_at i
732 draw_screen :refresh => true
735 def shell_out command
740 Ncurses.stdscr.keypad 1
748 def default_status_bar buf
749 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
752 def default_terminal_title buf
753 "Sup #{Redwood::VERSION} :: #{buf.title}"
756 def get_status_and_title buf
758 :num_inbox => lambda { Index.num_results_for :label => :inbox },
759 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
760 :num_total => lambda { Index.size },
761 :num_spam => lambda { Index.num_results_for :label => :spam },
763 :mode => buf.mode.name,
764 :status => buf.mode.status
767 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
768 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
770 [statusbar_text, term_title_text]
776 while(u = Etc.getpwent)