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
274 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
275 Redwood::log "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
277 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
282 draw_screen :sync => false, :status => status, :title => title
286 def draw_screen opts={}
290 if opts.member? :status
291 [opts[:status], opts[:title]]
293 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
294 get_status_and_title @focus_buf # must be called outside of the ncurses lock
297 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
298 print "\033]0;#{title}\07" if title && @in_x
300 Ncurses.mutex.lock unless opts[:sync] == false
302 ## disabling this for the time being, to help with debugging
303 ## (currently we only have one buffer visible at a time).
304 ## TODO: reenable this if we allow multiple buffers
305 false && @buffers.inject(@dirty) do |dirty, buf|
306 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
307 #dirty ? buf.draw : buf.redraw
315 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
316 @dirty ? buf.draw(status) : buf.redraw(status)
319 draw_minibuf :sync => false unless opts[:skip_minibuf]
323 Ncurses.refresh if opts[:refresh]
324 Ncurses.mutex.unlock unless opts[:sync] == false
327 ## if the named buffer already exists, pops it to the front without
328 ## calling the block. otherwise, gets the mode from the block and
329 ## creates a new buffer. returns two things: the buffer, and a boolean
330 ## indicating whether it's a new buffer or not.
331 def spawn_unless_exists title, opts={}
333 if @name_map.member? title
334 raise_to_front @name_map[title] unless opts[:hidden]
338 spawn title, mode, opts
341 [@name_map[title], new]
344 def spawn title, mode, opts={}
345 raise ArgumentError, "title must be a string" unless title.is_a? String
348 while @name_map.member? realtitle
349 realtitle = "#{title} <#{num}>"
353 width = opts[:width] || Ncurses.cols
354 height = opts[:height] || Ncurses.rows - 1
356 ## since we are currently only doing multiple full-screen modes,
357 ## use stdscr for each window. once we become more sophisticated,
358 ## we may need to use a new Ncurses::WINDOW
360 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
361 ## (opts[:left] || 0))
363 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
365 @name_map[realtitle] = b
369 focus_on b unless @focus_buf
376 ## requires the mode to have #done? and #value methods
377 def spawn_modal title, mode, opts={}
378 b = spawn title, mode, opts
382 c = Ncurses.nonblocking_getch
383 next unless c # getch timeout
384 break if c == Ncurses::KEY_CANCEL
387 rescue InputSequenceAborted # do nothing
397 def kill_all_buffers_safely
398 until @buffers.empty?
399 ## inbox mode always claims it's unkillable. we'll ignore it.
400 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
401 kill_buffer @buffers.last
406 def kill_buffer_safely buf
407 return false unless buf.mode.killable?
413 kill_buffer @buffers.first until @buffers.empty?
417 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
421 @name_map.delete buf.title
422 @focus_buf = nil if @focus_buf == buf
424 ## TODO: something intelligent here
425 ## for now I will simply prohibit killing the inbox buffer.
427 raise_to_front @buffers.last
431 def ask_with_completions domain, question, completions, default=nil
432 ask domain, question, default do |s|
433 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
437 def ask_many_with_completions domain, question, completions, default=nil
438 ask domain, question, default do |partial|
443 when /^(.*\s+)?(.*?)$/
446 raise "william screwed up completion: #{partial.inspect}"
449 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
453 def ask_many_emails_with_completions domain, question, completions, default=nil
454 ask domain, question, default do |partial|
455 prefix, target = partial.split_on_commas_with_remainder
456 target ||= prefix.pop || ""
457 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
458 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
462 def ask_for_filename domain, question, default=nil
463 answer = ask domain, question, default do |s|
464 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
466 name = $2.empty? ? Etc.getlogin : $2
467 dir = Etc.getpwnam(name).dir rescue nil
469 [[s.sub(full, dir), "~#{name}"]]
471 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
472 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
475 else # regular filename completion
476 Dir["#{s}*"].sort.map do |fn|
477 suffix = File.directory?(fn) ? "/" : ""
478 [fn + suffix, File.basename(fn) + suffix]
486 spawn_modal "file browser", FileBrowserMode.new
487 elsif File.directory?(answer)
488 spawn_modal "file browser", FileBrowserMode.new(answer)
490 File.expand_path answer
497 ## returns an array of labels
498 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
499 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
500 default = default_labels.join(" ")
501 default += " " unless default.empty?
503 # here I would prefer to give more control and allow all_labels instead of
504 # user_defined_labels only
505 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
507 answer = ask_many_with_completions domain, question, applyable_labels, default
511 user_labels = answer.symbolistize
512 user_labels.each do |l|
513 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
514 BufferManager.flash "'#{l}' is a reserved label!"
521 def ask_for_contacts domain, question, default_contacts=[]
522 default = default_contacts.map { |s| s.to_s }.join(" ")
523 default += " " unless default.empty?
525 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
526 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
528 completions = (recent + contacts).flatten.uniq
529 completions += HookManager.run("extra-contact-addresses") || []
530 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
533 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
537 ## for simplicitly, we always place the question at the very bottom of the
539 def ask domain, question, default=nil, &block
540 raise "impossible!" if @asking
543 @textfields[domain] ||= TextField.new
544 tf = @textfields[domain]
547 status, title = get_status_and_title @focus_buf
550 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
551 @dirty = true # for some reason that blanks the whole fucking screen
552 draw_screen :sync => false, :status => status, :title => title
558 c = Ncurses.nonblocking_getch
559 next unless c # getch timeout
560 break unless tf.handle_input c # process keystroke
562 if tf.new_completions?
563 kill_buffer completion_buf if completion_buf
565 shorts = tf.completions.map { |full, short| short }
566 prefix_len = shorts.shared_prefix.length
568 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
569 completion_buf = spawn "<completions>", mode, :height => 10
571 draw_screen :skip_minibuf => true
573 elsif tf.roll_completions?
574 completion_buf.mode.roll
575 draw_screen :skip_minibuf => true
579 Ncurses.sync { Ncurses.refresh }
582 kill_buffer completion_buf if completion_buf
588 draw_screen :sync => false, :status => status, :title => title
593 def ask_getch question, accept=nil
594 raise "impossible!" if @asking
596 accept = accept.split(//).map { |x| x[0] } if accept
598 status, title = get_status_and_title @focus_buf
600 draw_screen :sync => false, :status => status, :title => title
601 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
602 Ncurses.move Ncurses.rows - 1, question.length + 1
611 key = Ncurses.nonblocking_getch or next
612 if key == Ncurses::KEY_CANCEL
614 elsif accept.nil? || accept.empty? || accept.member?(key)
623 draw_screen :sync => false, :status => status, :title => title
629 ## returns true (y), false (n), or nil (ctrl-g / cancel)
630 def ask_yes_or_no question
631 case(r = ask_getch question, "ynYN")
641 ## turns an input keystroke into an action symbol. returns the action
642 ## if found, nil if not found, and throws InputSequenceAborted if
643 ## the user aborted a multi-key sequence. (Because each of those cases
644 ## should be handled differently.)
646 ## this is in BufferManager because multi-key sequences require prompting.
647 def resolve_input_with_keymap c, keymap
648 action, text = keymap.action_for c
649 while action.is_a? Keymap # multi-key commands, prompt
650 key = BufferManager.ask_getch text
651 unless key # user canceled, abort
653 raise InputSequenceAborted
655 action, text = action.action_for(key) if action.has_key?(key)
661 @minibuf_mutex.synchronize do
664 @minibuf_stack.compact.size, 1].max
668 def draw_minibuf opts={}
670 @minibuf_mutex.synchronize do
671 m = @minibuf_stack.compact
672 m << @flash if @flash
673 m << "" if m.empty? unless @asking # to clear it
676 Ncurses.mutex.lock unless opts[:sync] == false
677 Ncurses.attrset Colormap.color_for(:none)
678 adj = @asking ? 2 : 1
679 m.each_with_index do |s, i|
680 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
682 Ncurses.refresh if opts[:refresh]
683 Ncurses.mutex.unlock unless opts[:sync] == false
689 @minibuf_mutex.synchronize do
691 id ||= @minibuf_stack.length
692 @minibuf_stack[id] = s
696 draw_screen :refresh => true
698 draw_minibuf :refresh => true
711 def erase_flash; @flash = nil; end
715 draw_screen :refresh => true
718 ## a little tricky because we can't just delete_at id because ids
719 ## are relative (they're positions into the array).
721 @minibuf_mutex.synchronize do
722 @minibuf_stack[id] = nil
723 if id == @minibuf_stack.length - 1
725 break if @minibuf_stack[i]
726 @minibuf_stack.delete_at i
731 draw_screen :refresh => true
734 def shell_out command
739 Ncurses.stdscr.keypad 1
747 def default_status_bar buf
748 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
751 def default_terminal_title buf
752 "Sup #{Redwood::VERSION} :: #{buf.title}"
755 def get_status_and_title buf
757 :num_inbox => lambda { Index.num_results_for :label => :inbox },
758 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
759 :num_total => lambda { Index.size },
760 :num_spam => lambda { Index.num_results_for :label => :spam },
762 :mode => buf.mode.name,
763 :status => buf.mode.status
766 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
767 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
769 [statusbar_text, term_title_text]
775 while(u = Etc.getpwent)