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
31 if IO.select([$stdin], nil, nil, 1)
38 module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
40 remove_const :KEY_ENTER
41 remove_const :KEY_CANCEL
44 KEY_CANCEL = 7 # ctrl-g
51 class InputSequenceAborted < StandardError; end
54 attr_reader :mode, :x, :y, :width, :height, :title, :atime
55 bool_reader :dirty, :system
56 bool_accessor :force_to_top
58 def initialize window, mode, width, height, opts={}
63 @title = opts[:title] || ""
64 @force_to_top = opts[:force_to_top] || false
65 @x, @y, @width, @height = 0, 0, width, height
67 @system = opts[:system] || false
70 def content_height; @height - 1; end
71 def content_width; @width; end
74 return if cols == @width && rows == @height
78 mode.resize rows, cols
91 def mark_dirty; @dirty = true; end
105 ## s nil means a blank line!
106 def write y, x, s, opts={}
107 return if x >= @width || y >= @height
109 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
111 maxl = @width - x # maximum display width width
112 stringl = maxl # string "length"
113 ## the next horribleness is thanks to ruby's lack of widechar support
114 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
115 @w.mvaddstr y, x, s[0 ... stringl]
116 unless opts[:no_fill]
119 @w.mvaddstr(y, x + l, " " * (maxl - l))
128 def draw_status status
129 write @height - 1, 0, status, :color => :status_color
148 attr_reader :focus_buf
150 ## we have to define the key used to continue in-buffer search here, because
151 ## it has special semantics that BufferManager deals with---current searches
152 ## are canceled by any keypress except this one.
153 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
155 HookManager.register "status-bar-text", <<EOS
156 Sets the status bar. The default status bar contains the mode name, the buffer
157 title, and the mode status. Note that this will be called at least once per
158 keystroke, so excessive computation is discouraged.
161 num_inbox: number of messages in inbox
162 num_inbox_unread: total number of messages marked as unread
163 num_total: total number of messages in the index
164 num_spam: total number of messages marked as spam
165 title: title of the current buffer
166 mode: current mode name (string)
167 status: current mode status (string)
168 Return value: a string to be used as the status bar.
171 HookManager.register "terminal-title-text", <<EOS
172 Sets the title of the current terminal, if applicable. Note that this will be
173 called at least once per keystroke, so excessive computation is discouraged.
175 Variables: the same as status-bar-text hook.
176 Return value: a string to be used as the terminal title.
179 HookManager.register "extra-contact-addresses", <<EOS
180 A list of extra addresses to propose for tab completion, etc. when the
181 user is entering an email address. Can be plain email addresses or can
182 be full "User Name <email@domain.tld>" entries.
185 Return value: an array of email address strings.
194 @minibuf_mutex = Mutex.new
197 @shelled = @asking = false
198 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
201 def buffers; @name_map.to_a; end
204 return unless @buffers.member? buf
205 return if buf == @focus_buf
206 @focus_buf.blur if @focus_buf
211 def raise_to_front buf
212 @buffers.delete(buf) or return
213 if @buffers.length > 0 && @buffers.last.force_to_top?
214 @buffers.insert(-2, buf)
218 focus_on @buffers.last
222 ## we reset force_to_top when rolling buffers. this is so that the
223 ## human can actually still move buffers around, while still
224 ## programmatically being able to pop stuff up in the middle of
225 ## drawing a window without worrying about covering it up.
227 ## if we ever start calling roll_buffers programmatically, we will
228 ## have to change this. but it's not clear that we will ever actually
231 @buffers.last.force_to_top = false
232 raise_to_front @buffers.first
235 def roll_buffers_backwards
236 return unless @buffers.length > 1
237 @buffers.last.force_to_top = false
238 raise_to_front @buffers[@buffers.length - 2]
243 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
244 @focus_buf.mode.cancel_search!
245 @focus_buf.mark_dirty
247 @focus_buf.mode.handle_input c
251 def exists? n; @name_map.member? n; end
252 def [] n; @name_map[n]; end
254 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
255 raise ArgumentError, "title must be a string" unless n.is_a? String
259 def completely_redraw_screen
262 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
267 draw_screen :sync => false, :status => status, :title => title
271 def draw_screen opts={}
275 if opts.member? :status
276 [opts[:status], opts[:title]]
278 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
279 get_status_and_title @focus_buf # must be called outside of the ncurses lock
282 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
283 print "\033]0;#{title}\07" if title && @in_x
285 Ncurses.mutex.lock unless opts[:sync] == false
287 ## disabling this for the time being, to help with debugging
288 ## (currently we only have one buffer visible at a time).
289 ## TODO: reenable this if we allow multiple buffers
290 false && @buffers.inject(@dirty) do |dirty, buf|
291 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
292 #dirty ? buf.draw : buf.redraw
300 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
301 @dirty ? buf.draw(status) : buf.redraw(status)
304 draw_minibuf :sync => false unless opts[:skip_minibuf]
308 Ncurses.refresh if opts[:refresh]
309 Ncurses.mutex.unlock unless opts[:sync] == false
312 ## if the named buffer already exists, pops it to the front without
313 ## calling the block. otherwise, gets the mode from the block and
314 ## creates a new buffer. returns two things: the buffer, and a boolean
315 ## indicating whether it's a new buffer or not.
316 def spawn_unless_exists title, opts={}
318 if @name_map.member? title
319 raise_to_front @name_map[title] unless opts[:hidden]
323 spawn title, mode, opts
326 [@name_map[title], new]
329 def spawn title, mode, opts={}
330 raise ArgumentError, "title must be a string" unless title.is_a? String
333 while @name_map.member? realtitle
334 realtitle = "#{title} <#{num}>"
338 width = opts[:width] || Ncurses.cols
339 height = opts[:height] || Ncurses.rows - 1
341 ## since we are currently only doing multiple full-screen modes,
342 ## use stdscr for each window. once we become more sophisticated,
343 ## we may need to use a new Ncurses::WINDOW
345 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
346 ## (opts[:left] || 0))
348 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
350 @name_map[realtitle] = b
354 focus_on b unless @focus_buf
361 ## requires the mode to have #done? and #value methods
362 def spawn_modal title, mode, opts={}
363 b = spawn title, mode, opts
367 c = Ncurses.nonblocking_getch
368 next unless c # getch timeout
369 break if c == Ncurses::KEY_CANCEL
372 rescue InputSequenceAborted # do nothing
382 def kill_all_buffers_safely
383 until @buffers.empty?
384 ## inbox mode always claims it's unkillable. we'll ignore it.
385 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
386 kill_buffer @buffers.last
391 def kill_buffer_safely buf
392 return false unless buf.mode.killable?
398 kill_buffer @buffers.first until @buffers.empty?
402 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
406 @name_map.delete buf.title
407 @focus_buf = nil if @focus_buf == buf
409 ## TODO: something intelligent here
410 ## for now I will simply prohibit killing the inbox buffer.
412 raise_to_front @buffers.last
416 def ask_with_completions domain, question, completions, default=nil
417 ask domain, question, default do |s|
418 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
422 def ask_many_with_completions domain, question, completions, default=nil
423 ask domain, question, default do |partial|
428 when /^(.*\s+)?(.*?)$/
431 raise "william screwed up completion: #{partial.inspect}"
434 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
438 def ask_many_emails_with_completions domain, question, completions, default=nil
439 ask domain, question, default do |partial|
440 prefix, target = partial.split_on_commas_with_remainder
441 target ||= prefix.pop || ""
442 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
443 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
447 def ask_for_filename domain, question, default=nil
448 answer = ask domain, question, default do |s|
449 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
451 name = $2.empty? ? Etc.getlogin : $2
452 dir = Etc.getpwnam(name).dir rescue nil
454 [[s.sub(full, dir), "~#{name}"]]
456 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
457 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
460 else # regular filename completion
461 Dir["#{s}*"].sort.map do |fn|
462 suffix = File.directory?(fn) ? "/" : ""
463 [fn + suffix, File.basename(fn) + suffix]
471 spawn_modal "file browser", FileBrowserMode.new
472 elsif File.directory?(answer)
473 spawn_modal "file browser", FileBrowserMode.new(answer)
475 File.expand_path answer
482 ## returns an array of labels
483 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
484 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
485 default = default_labels.join(" ")
486 default += " " unless default.empty?
488 # here I would prefer to give more control and allow all_labels instead of
489 # user_defined_labels only
490 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
492 answer = ask_many_with_completions domain, question, applyable_labels, default
496 user_labels = answer.to_set_of_symbols
497 user_labels.each do |l|
498 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
499 BufferManager.flash "'#{l}' is a reserved label!"
506 def ask_for_contacts domain, question, default_contacts=[]
507 default = default_contacts.map { |s| s.to_s }.join(" ")
508 default += " " unless default.empty?
510 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
511 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
513 completions = (recent + contacts).flatten.uniq
514 completions += HookManager.run("extra-contact-addresses") || []
515 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
518 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
522 ## for simplicitly, we always place the question at the very bottom of the
524 def ask domain, question, default=nil, &block
525 raise "impossible!" if @asking
528 @textfields[domain] ||= TextField.new
529 tf = @textfields[domain]
532 status, title = get_status_and_title @focus_buf
535 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
536 @dirty = true # for some reason that blanks the whole fucking screen
537 draw_screen :sync => false, :status => status, :title => title
543 c = Ncurses.nonblocking_getch
544 next unless c # getch timeout
545 break unless tf.handle_input c # process keystroke
547 if tf.new_completions?
548 kill_buffer completion_buf if completion_buf
550 shorts = tf.completions.map { |full, short| short }
551 prefix_len = shorts.shared_prefix.length
553 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
554 completion_buf = spawn "<completions>", mode, :height => 10
556 draw_screen :skip_minibuf => true
558 elsif tf.roll_completions?
559 completion_buf.mode.roll
560 draw_screen :skip_minibuf => true
564 Ncurses.sync { Ncurses.refresh }
567 kill_buffer completion_buf if completion_buf
573 draw_screen :sync => false, :status => status, :title => title
578 def ask_getch question, accept=nil
579 raise "impossible!" if @asking
581 accept = accept.split(//).map { |x| x[0] } if accept
583 status, title = get_status_and_title @focus_buf
585 draw_screen :sync => false, :status => status, :title => title
586 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
587 Ncurses.move Ncurses.rows - 1, question.length + 1
596 key = Ncurses.nonblocking_getch or next
597 if key == Ncurses::KEY_CANCEL
599 elsif accept.nil? || accept.empty? || accept.member?(key)
608 draw_screen :sync => false, :status => status, :title => title
614 ## returns true (y), false (n), or nil (ctrl-g / cancel)
615 def ask_yes_or_no question
616 case(r = ask_getch question, "ynYN")
626 ## turns an input keystroke into an action symbol. returns the action
627 ## if found, nil if not found, and throws InputSequenceAborted if
628 ## the user aborted a multi-key sequence. (Because each of those cases
629 ## should be handled differently.)
631 ## this is in BufferManager because multi-key sequences require prompting.
632 def resolve_input_with_keymap c, keymap
633 action, text = keymap.action_for c
634 while action.is_a? Keymap # multi-key commands, prompt
635 key = BufferManager.ask_getch text
636 unless key # user canceled, abort
638 raise InputSequenceAborted
640 action, text = action.action_for(key) if action.has_key?(key)
646 @minibuf_mutex.synchronize do
649 @minibuf_stack.compact.size, 1].max
653 def draw_minibuf opts={}
655 @minibuf_mutex.synchronize do
656 m = @minibuf_stack.compact
657 m << @flash if @flash
658 m << "" if m.empty? unless @asking # to clear it
661 Ncurses.mutex.lock unless opts[:sync] == false
662 Ncurses.attrset Colormap.color_for(:none)
663 adj = @asking ? 2 : 1
664 m.each_with_index do |s, i|
665 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
667 Ncurses.refresh if opts[:refresh]
668 Ncurses.mutex.unlock unless opts[:sync] == false
674 @minibuf_mutex.synchronize do
676 id ||= @minibuf_stack.length
677 @minibuf_stack[id] = s
681 draw_screen :refresh => true
683 draw_minibuf :refresh => true
696 def erase_flash; @flash = nil; end
700 draw_screen :refresh => true
703 ## a little tricky because we can't just delete_at id because ids
704 ## are relative (they're positions into the array).
706 @minibuf_mutex.synchronize do
707 @minibuf_stack[id] = nil
708 if id == @minibuf_stack.length - 1
710 break if @minibuf_stack[i]
711 @minibuf_stack.delete_at i
716 draw_screen :refresh => true
719 def shell_out command
724 Ncurses.stdscr.keypad 1
732 def default_status_bar buf
733 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
736 def default_terminal_title buf
737 "Sup #{Redwood::VERSION} :: #{buf.title}"
740 def get_status_and_title buf
742 :num_inbox => lambda { Index.num_results_for :label => :inbox },
743 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
744 :num_total => lambda { Index.size },
745 :num_spam => lambda { Index.num_results_for :label => :spam },
747 :mode => buf.mode.name,
748 :status => buf.mode.status
751 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
752 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
754 [statusbar_text, term_title_text]
760 while(u = Etc.getpwent)