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)/
200 self.class.i_am_the_instance self
203 def buffers; @name_map.to_a; end
206 return unless @buffers.member? buf
207 return if buf == @focus_buf
208 @focus_buf.blur if @focus_buf
213 def raise_to_front buf
214 @buffers.delete(buf) or return
215 if @buffers.length > 0 && @buffers.last.force_to_top?
216 @buffers.insert(-2, buf)
220 focus_on @buffers.last
224 ## we reset force_to_top when rolling buffers. this is so that the
225 ## human can actually still move buffers around, while still
226 ## programmatically being able to pop stuff up in the middle of
227 ## drawing a window without worrying about covering it up.
229 ## if we ever start calling roll_buffers programmatically, we will
230 ## have to change this. but it's not clear that we will ever actually
233 @buffers.last.force_to_top = false
234 raise_to_front @buffers.first
237 def roll_buffers_backwards
238 return unless @buffers.length > 1
239 @buffers.last.force_to_top = false
240 raise_to_front @buffers[@buffers.length - 2]
245 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
246 @focus_buf.mode.cancel_search!
247 @focus_buf.mark_dirty
249 @focus_buf.mode.handle_input c
253 def exists? n; @name_map.member? n; end
254 def [] n; @name_map[n]; end
256 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
257 raise ArgumentError, "title must be a string" unless n.is_a? String
261 def completely_redraw_screen
264 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
269 draw_screen :sync => false, :status => status, :title => title
273 def draw_screen opts={}
277 if opts.member? :status
278 [opts[:status], opts[:title]]
280 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
281 get_status_and_title @focus_buf # must be called outside of the ncurses lock
284 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
285 print "\033]0;#{title}\07" if title && @in_x
287 Ncurses.mutex.lock unless opts[:sync] == false
289 ## disabling this for the time being, to help with debugging
290 ## (currently we only have one buffer visible at a time).
291 ## TODO: reenable this if we allow multiple buffers
292 false && @buffers.inject(@dirty) do |dirty, buf|
293 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
294 #dirty ? buf.draw : buf.redraw
302 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
303 @dirty ? buf.draw(status) : buf.redraw(status)
306 draw_minibuf :sync => false unless opts[:skip_minibuf]
310 Ncurses.refresh if opts[:refresh]
311 Ncurses.mutex.unlock unless opts[:sync] == false
314 ## if the named buffer already exists, pops it to the front without
315 ## calling the block. otherwise, gets the mode from the block and
316 ## creates a new buffer. returns two things: the buffer, and a boolean
317 ## indicating whether it's a new buffer or not.
318 def spawn_unless_exists title, opts={}
320 if @name_map.member? title
321 raise_to_front @name_map[title] unless opts[:hidden]
325 spawn title, mode, opts
328 [@name_map[title], new]
331 def spawn title, mode, opts={}
332 raise ArgumentError, "title must be a string" unless title.is_a? String
335 while @name_map.member? realtitle
336 realtitle = "#{title} <#{num}>"
340 width = opts[:width] || Ncurses.cols
341 height = opts[:height] || Ncurses.rows - 1
343 ## since we are currently only doing multiple full-screen modes,
344 ## use stdscr for each window. once we become more sophisticated,
345 ## we may need to use a new Ncurses::WINDOW
347 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
348 ## (opts[:left] || 0))
350 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
352 @name_map[realtitle] = b
356 focus_on b unless @focus_buf
363 ## requires the mode to have #done? and #value methods
364 def spawn_modal title, mode, opts={}
365 b = spawn title, mode, opts
369 c = Ncurses.nonblocking_getch
370 next unless c # getch timeout
371 break if c == Ncurses::KEY_CANCEL
374 rescue InputSequenceAborted # do nothing
384 def kill_all_buffers_safely
385 until @buffers.empty?
386 ## inbox mode always claims it's unkillable. we'll ignore it.
387 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
388 kill_buffer @buffers.last
393 def kill_buffer_safely buf
394 return false unless buf.mode.killable?
400 kill_buffer @buffers.first until @buffers.empty?
404 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
408 @name_map.delete buf.title
409 @focus_buf = nil if @focus_buf == buf
411 ## TODO: something intelligent here
412 ## for now I will simply prohibit killing the inbox buffer.
414 raise_to_front @buffers.last
418 def ask_with_completions domain, question, completions, default=nil
419 ask domain, question, default do |s|
420 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
424 def ask_many_with_completions domain, question, completions, default=nil
425 ask domain, question, default do |partial|
430 when /^(.*\s+)?(.*?)$/
433 raise "william screwed up completion: #{partial.inspect}"
436 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
440 def ask_many_emails_with_completions domain, question, completions, default=nil
441 ask domain, question, default do |partial|
442 prefix, target = partial.split_on_commas_with_remainder
443 target ||= prefix.pop || ""
444 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
445 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
449 def ask_for_filename domain, question, default=nil
450 answer = ask domain, question, default do |s|
451 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
453 name = $2.empty? ? Etc.getlogin : $2
454 dir = Etc.getpwnam(name).dir rescue nil
456 [[s.sub(full, dir), "~#{name}"]]
458 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
459 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
462 else # regular filename completion
463 Dir["#{s}*"].sort.map do |fn|
464 suffix = File.directory?(fn) ? "/" : ""
465 [fn + suffix, File.basename(fn) + suffix]
473 spawn_modal "file browser", FileBrowserMode.new
474 elsif File.directory?(answer)
475 spawn_modal "file browser", FileBrowserMode.new(answer)
477 File.expand_path answer
484 ## returns an array of labels
485 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
486 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
487 default = default_labels.join(" ")
488 default += " " unless default.empty?
490 # here I would prefer to give more control and allow all_labels instead of
491 # user_defined_labels only
492 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
494 answer = ask_many_with_completions domain, question, applyable_labels, default
498 user_labels = answer.symbolistize
499 user_labels.each do |l|
500 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
501 BufferManager.flash "'#{l}' is a reserved label!"
508 def ask_for_contacts domain, question, default_contacts=[]
509 default = default_contacts.map { |s| s.to_s }.join(" ")
510 default += " " unless default.empty?
512 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
513 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
515 completions = (recent + contacts).flatten.uniq
516 completions += HookManager.run("extra-contact-addresses") || []
517 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
520 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
524 ## for simplicitly, we always place the question at the very bottom of the
526 def ask domain, question, default=nil, &block
527 raise "impossible!" if @asking
530 @textfields[domain] ||= TextField.new
531 tf = @textfields[domain]
534 status, title = get_status_and_title @focus_buf
537 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
538 @dirty = true # for some reason that blanks the whole fucking screen
539 draw_screen :sync => false, :status => status, :title => title
545 c = Ncurses.nonblocking_getch
546 next unless c # getch timeout
547 break unless tf.handle_input c # process keystroke
549 if tf.new_completions?
550 kill_buffer completion_buf if completion_buf
552 shorts = tf.completions.map { |full, short| short }
553 prefix_len = shorts.shared_prefix.length
555 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
556 completion_buf = spawn "<completions>", mode, :height => 10
558 draw_screen :skip_minibuf => true
560 elsif tf.roll_completions?
561 completion_buf.mode.roll
562 draw_screen :skip_minibuf => true
566 Ncurses.sync { Ncurses.refresh }
569 kill_buffer completion_buf if completion_buf
575 draw_screen :sync => false, :status => status, :title => title
580 def ask_getch question, accept=nil
581 raise "impossible!" if @asking
583 accept = accept.split(//).map { |x| x[0] } if accept
585 status, title = get_status_and_title @focus_buf
587 draw_screen :sync => false, :status => status, :title => title
588 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
589 Ncurses.move Ncurses.rows - 1, question.length + 1
598 key = Ncurses.nonblocking_getch or next
599 if key == Ncurses::KEY_CANCEL
601 elsif accept.nil? || accept.empty? || accept.member?(key)
610 draw_screen :sync => false, :status => status, :title => title
616 ## returns true (y), false (n), or nil (ctrl-g / cancel)
617 def ask_yes_or_no question
618 case(r = ask_getch question, "ynYN")
628 ## turns an input keystroke into an action symbol. returns the action
629 ## if found, nil if not found, and throws InputSequenceAborted if
630 ## the user aborted a multi-key sequence. (Because each of those cases
631 ## should be handled differently.)
633 ## this is in BufferManager because multi-key sequences require prompting.
634 def resolve_input_with_keymap c, keymap
635 action, text = keymap.action_for c
636 while action.is_a? Keymap # multi-key commands, prompt
637 key = BufferManager.ask_getch text
638 unless key # user canceled, abort
640 raise InputSequenceAborted
642 action, text = action.action_for(key) if action.has_key?(key)
648 @minibuf_mutex.synchronize do
651 @minibuf_stack.compact.size, 1].max
655 def draw_minibuf opts={}
657 @minibuf_mutex.synchronize do
658 m = @minibuf_stack.compact
659 m << @flash if @flash
660 m << "" if m.empty? unless @asking # to clear it
663 Ncurses.mutex.lock unless opts[:sync] == false
664 Ncurses.attrset Colormap.color_for(:none)
665 adj = @asking ? 2 : 1
666 m.each_with_index do |s, i|
667 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
669 Ncurses.refresh if opts[:refresh]
670 Ncurses.mutex.unlock unless opts[:sync] == false
676 @minibuf_mutex.synchronize do
678 id ||= @minibuf_stack.length
679 @minibuf_stack[id] = s
683 draw_screen :refresh => true
685 draw_minibuf :refresh => true
698 def erase_flash; @flash = nil; end
702 draw_screen :refresh => true
705 ## a little tricky because we can't just delete_at id because ids
706 ## are relative (they're positions into the array).
708 @minibuf_mutex.synchronize do
709 @minibuf_stack[id] = nil
710 if id == @minibuf_stack.length - 1
712 break if @minibuf_stack[i]
713 @minibuf_stack.delete_at i
718 draw_screen :refresh => true
721 def shell_out command
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)