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 call nodelay EVERY TIME otherwise a single ctrl-c
31 ## will turn a blocking call into a nonblocking one. hours of my life
32 ## wasted on this trivial bullshit: 3.
33 Ncurses.nodelay Ncurses.stdscr, false
37 module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
39 remove_const :KEY_ENTER
40 remove_const :KEY_CANCEL
43 KEY_CANCEL = 7 # ctrl-g
50 class InputSequenceAborted < StandardError; end
53 attr_reader :mode, :x, :y, :width, :height, :title, :atime
54 bool_reader :dirty, :system
55 bool_accessor :force_to_top
57 def initialize window, mode, width, height, opts={}
62 @title = opts[:title] || ""
63 @force_to_top = opts[:force_to_top] || false
64 @x, @y, @width, @height = 0, 0, width, height
66 @system = opts[:system] || false
69 def content_height; @height - 1; end
70 def content_width; @width; end
73 return if cols == @width && rows == @height
77 mode.resize rows, cols
90 def mark_dirty; @dirty = true; end
104 ## s nil means a blank line!
105 def write y, x, s, opts={}
106 return if x >= @width || y >= @height
108 @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
110 maxl = @width - x # maximum display width width
111 stringl = maxl # string "length"
112 ## the next horribleness is thanks to ruby's lack of widechar support
113 stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
114 @w.mvaddstr y, x, s[0 ... stringl]
115 unless opts[:no_fill]
118 @w.mvaddstr(y, x + l, " " * (maxl - l))
127 def draw_status status
128 write @height - 1, 0, status, :color => :status_color
147 attr_reader :focus_buf
149 ## we have to define the key used to continue in-buffer search here, because
150 ## it has special semantics that BufferManager deals with---current searches
151 ## are canceled by any keypress except this one.
152 CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
154 HookManager.register "status-bar-text", <<EOS
155 Sets the status bar. The default status bar contains the mode name, the buffer
156 title, and the mode status. Note that this will be called at least once per
157 keystroke, so excessive computation is discouraged.
160 num_inbox: number of messages in inbox
161 num_inbox_unread: total number of messages marked as unread
162 num_total: total number of messages in the index
163 num_spam: total number of messages marked as spam
164 title: title of the current buffer
165 mode: current mode name (string)
166 status: current mode status (string)
167 Return value: a string to be used as the status bar.
170 HookManager.register "terminal-title-text", <<EOS
171 Sets the title of the current terminal, if applicable. Note that this will be
172 called at least once per keystroke, so excessive computation is discouraged.
174 Variables: the same as status-bar-text hook.
175 Return value: a string to be used as the terminal title.
178 HookManager.register "extra-contact-addresses", <<EOS
179 A list of extra addresses to propose for tab completion, etc. when the
180 user is entering an email address. Can be plain email addresses or can
181 be full "User Name <email@domain.tld>" entries.
184 Return value: an array of email address strings.
193 @minibuf_mutex = Mutex.new
196 @shelled = @asking = false
197 @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
199 self.class.i_am_the_instance self
202 def buffers; @name_map.to_a; end
205 return unless @buffers.member? buf
206 return if buf == @focus_buf
207 @focus_buf.blur if @focus_buf
212 def raise_to_front buf
213 @buffers.delete(buf) or return
214 if @buffers.length > 0 && @buffers.last.force_to_top?
215 @buffers.insert(-2, buf)
219 focus_on @buffers.last
223 ## we reset force_to_top when rolling buffers. this is so that the
224 ## human can actually still move buffers around, while still
225 ## programmatically being able to pop stuff up in the middle of
226 ## drawing a window without worrying about covering it up.
228 ## if we ever start calling roll_buffers programmatically, we will
229 ## have to change this. but it's not clear that we will ever actually
232 @buffers.last.force_to_top = false
233 raise_to_front @buffers.first
236 def roll_buffers_backwards
237 return unless @buffers.length > 1
238 @buffers.last.force_to_top = false
239 raise_to_front @buffers[@buffers.length - 2]
244 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
245 @focus_buf.mode.cancel_search!
246 @focus_buf.mark_dirty
248 @focus_buf.mode.handle_input c
252 def exists? n; @name_map.member? n; end
253 def [] n; @name_map[n]; end
255 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
256 raise ArgumentError, "title must be a string" unless n.is_a? String
260 def completely_redraw_screen
263 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
268 draw_screen :sync => false, :status => status, :title => title
272 def draw_screen opts={}
276 if opts.member? :status
277 [opts[:status], opts[:title]]
279 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
280 get_status_and_title @focus_buf # must be called outside of the ncurses lock
283 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
284 print "\033]0;#{title}\07" if title && @in_x
286 Ncurses.mutex.lock unless opts[:sync] == false
288 ## disabling this for the time being, to help with debugging
289 ## (currently we only have one buffer visible at a time).
290 ## TODO: reenable this if we allow multiple buffers
291 false && @buffers.inject(@dirty) do |dirty, buf|
292 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
293 #dirty ? buf.draw : buf.redraw
301 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
302 @dirty ? buf.draw(status) : buf.redraw(status)
305 draw_minibuf :sync => false unless opts[:skip_minibuf]
309 Ncurses.refresh if opts[:refresh]
310 Ncurses.mutex.unlock unless opts[:sync] == false
313 ## if the named buffer already exists, pops it to the front without
314 ## calling the block. otherwise, gets the mode from the block and
315 ## creates a new buffer. returns two things: the buffer, and a boolean
316 ## indicating whether it's a new buffer or not.
317 def spawn_unless_exists title, opts={}
319 if @name_map.member? title
320 raise_to_front @name_map[title] unless opts[:hidden]
324 spawn title, mode, opts
327 [@name_map[title], new]
330 def spawn title, mode, opts={}
331 raise ArgumentError, "title must be a string" unless title.is_a? String
334 while @name_map.member? realtitle
335 realtitle = "#{title} <#{num}>"
339 width = opts[:width] || Ncurses.cols
340 height = opts[:height] || Ncurses.rows - 1
342 ## since we are currently only doing multiple full-screen modes,
343 ## use stdscr for each window. once we become more sophisticated,
344 ## we may need to use a new Ncurses::WINDOW
346 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
347 ## (opts[:left] || 0))
349 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
351 @name_map[realtitle] = b
355 focus_on b unless @focus_buf
362 ## requires the mode to have #done? and #value methods
363 def spawn_modal title, mode, opts={}
364 b = spawn title, mode, opts
368 c = Ncurses.nonblocking_getch
369 next unless c # getch timeout
370 break if c == Ncurses::KEY_CANCEL
373 rescue InputSequenceAborted # do nothing
383 def kill_all_buffers_safely
384 until @buffers.empty?
385 ## inbox mode always claims it's unkillable. we'll ignore it.
386 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
387 kill_buffer @buffers.last
392 def kill_buffer_safely buf
393 return false unless buf.mode.killable?
399 kill_buffer @buffers.first until @buffers.empty?
403 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
407 @name_map.delete buf.title
408 @focus_buf = nil if @focus_buf == buf
410 ## TODO: something intelligent here
411 ## for now I will simply prohibit killing the inbox buffer.
413 raise_to_front @buffers.last
417 def ask_with_completions domain, question, completions, default=nil
418 ask domain, question, default do |s|
419 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
423 def ask_many_with_completions domain, question, completions, default=nil
424 ask domain, question, default do |partial|
429 when /^(.*\s+)?(.*?)$/
432 raise "william screwed up completion: #{partial.inspect}"
435 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
439 def ask_many_emails_with_completions domain, question, completions, default=nil
440 ask domain, question, default do |partial|
441 prefix, target = partial.split_on_commas_with_remainder
442 target ||= prefix.pop || ""
443 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
444 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
448 def ask_for_filename domain, question, default=nil
449 answer = ask domain, question, default do |s|
450 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
452 name = $2.empty? ? Etc.getlogin : $2
453 dir = Etc.getpwnam(name).dir rescue nil
455 [[s.sub(full, dir), "~#{name}"]]
457 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
458 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
461 else # regular filename completion
462 Dir["#{s}*"].sort.map do |fn|
463 suffix = File.directory?(fn) ? "/" : ""
464 [fn + suffix, File.basename(fn) + suffix]
472 spawn_modal "file browser", FileBrowserMode.new
473 elsif File.directory?(answer)
474 spawn_modal "file browser", FileBrowserMode.new(answer)
476 File.expand_path answer
483 ## returns an array of labels
484 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
485 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
486 default = default_labels.join(" ")
487 default += " " unless default.empty?
489 # here I would prefer to give more control and allow all_labels instead of
490 # user_defined_labels only
491 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
493 answer = ask_many_with_completions domain, question, applyable_labels, default
497 user_labels = answer.symbolistize
498 user_labels.each do |l|
499 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
500 BufferManager.flash "'#{l}' is a reserved label!"
507 def ask_for_contacts domain, question, default_contacts=[]
508 default = default_contacts.map { |s| s.to_s }.join(" ")
509 default += " " unless default.empty?
511 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
512 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
514 completions = (recent + contacts).flatten.uniq
515 completions += HookManager.run("extra-contact-addresses") || []
516 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
519 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
523 ## for simplicitly, we always place the question at the very bottom of the
525 def ask domain, question, default=nil, &block
526 raise "impossible!" if @asking
529 @textfields[domain] ||= TextField.new
530 tf = @textfields[domain]
533 status, title = get_status_and_title @focus_buf
536 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
537 @dirty = true # for some reason that blanks the whole fucking screen
538 draw_screen :sync => false, :status => status, :title => title
544 c = Ncurses.nonblocking_getch
545 next unless c # getch timeout
546 break unless tf.handle_input c # process keystroke
548 if tf.new_completions?
549 kill_buffer completion_buf if completion_buf
551 shorts = tf.completions.map { |full, short| short }
552 prefix_len = shorts.shared_prefix.length
554 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
555 completion_buf = spawn "<completions>", mode, :height => 10
557 draw_screen :skip_minibuf => true
559 elsif tf.roll_completions?
560 completion_buf.mode.roll
561 draw_screen :skip_minibuf => true
565 Ncurses.sync { Ncurses.refresh }
568 kill_buffer completion_buf if completion_buf
574 draw_screen :sync => false, :status => status, :title => title
579 def ask_getch question, accept=nil
580 raise "impossible!" if @asking
582 accept = accept.split(//).map { |x| x[0] } if accept
584 status, title = get_status_and_title @focus_buf
586 draw_screen :sync => false, :status => status, :title => title
587 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
588 Ncurses.move Ncurses.rows - 1, question.length + 1
597 key = Ncurses.nonblocking_getch or next
598 if key == Ncurses::KEY_CANCEL
600 elsif accept.nil? || accept.empty? || accept.member?(key)
609 draw_screen :sync => false, :status => status, :title => title
615 ## returns true (y), false (n), or nil (ctrl-g / cancel)
616 def ask_yes_or_no question
617 case(r = ask_getch question, "ynYN")
627 ## turns an input keystroke into an action symbol. returns the action
628 ## if found, nil if not found, and throws InputSequenceAborted if
629 ## the user aborted a multi-key sequence. (Because each of those cases
630 ## should be handled differently.)
632 ## this is in BufferManager because multi-key sequences require prompting.
633 def resolve_input_with_keymap c, keymap
634 action, text = keymap.action_for c
635 while action.is_a? Keymap # multi-key commands, prompt
636 key = BufferManager.ask_getch text
637 unless key # user canceled, abort
639 raise InputSequenceAborted
641 action, text = action.action_for(key) if action.has_key?(key)
647 @minibuf_mutex.synchronize do
650 @minibuf_stack.compact.size, 1].max
654 def draw_minibuf opts={}
656 @minibuf_mutex.synchronize do
657 m = @minibuf_stack.compact
658 m << @flash if @flash
659 m << "" if m.empty? unless @asking # to clear it
662 Ncurses.mutex.lock unless opts[:sync] == false
663 Ncurses.attrset Colormap.color_for(:none)
664 adj = @asking ? 2 : 1
665 m.each_with_index do |s, i|
666 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
668 Ncurses.refresh if opts[:refresh]
669 Ncurses.mutex.unlock unless opts[:sync] == false
675 @minibuf_mutex.synchronize do
677 id ||= @minibuf_stack.length
678 @minibuf_stack[id] = s
682 draw_screen :refresh => true
684 draw_minibuf :refresh => true
697 def erase_flash; @flash = nil; end
701 draw_screen :refresh => true
704 ## a little tricky because we can't just delete_at id because ids
705 ## are relative (they're positions into the array).
707 @minibuf_mutex.synchronize do
708 @minibuf_stack[id] = nil
709 if id == @minibuf_stack.length - 1
711 break if @minibuf_stack[i]
712 @minibuf_stack.delete_at i
717 draw_screen :refresh => true
720 def shell_out command
725 Ncurses.stdscr.keypad 1
733 def default_status_bar buf
734 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
737 def default_terminal_title buf
738 "Sup #{Redwood::VERSION} :: #{buf.title}"
741 def get_status_and_title buf
743 :num_inbox => lambda { Index.num_results_for :label => :inbox },
744 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
745 :num_total => lambda { Index.size },
746 :num_spam => lambda { Index.num_results_for :label => :spam },
748 :mode => buf.mode.name,
749 :status => buf.mode.status
752 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
753 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
755 [statusbar_text, term_title_text]
761 while(u = Etc.getpwent)