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 wrap Ncurses.getch in a select() otherwise all
31 ## background threads will be BLOCKED. (except in very modern versions
32 ## of libncurses-ruby. the current one on ubuntu seems to work well.)
33 if IO.select([$stdin], nil, nil, 0.5)
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)/
199 @sigwinch_happened = false
200 @sigwinch_mutex = Mutex.new
202 self.class.i_am_the_instance self
205 def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
206 def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
208 def buffers; @name_map.to_a; end
211 return unless @buffers.member? buf
212 return if buf == @focus_buf
213 @focus_buf.blur if @focus_buf
218 def raise_to_front buf
219 @buffers.delete(buf) or return
220 if @buffers.length > 0 && @buffers.last.force_to_top?
221 @buffers.insert(-2, buf)
225 focus_on @buffers.last
229 ## we reset force_to_top when rolling buffers. this is so that the
230 ## human can actually still move buffers around, while still
231 ## programmatically being able to pop stuff up in the middle of
232 ## drawing a window without worrying about covering it up.
234 ## if we ever start calling roll_buffers programmatically, we will
235 ## have to change this. but it's not clear that we will ever actually
238 @buffers.last.force_to_top = false
239 raise_to_front @buffers.first
242 def roll_buffers_backwards
243 return unless @buffers.length > 1
244 @buffers.last.force_to_top = false
245 raise_to_front @buffers[@buffers.length - 2]
250 if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
251 @focus_buf.mode.cancel_search!
252 @focus_buf.mark_dirty
254 @focus_buf.mode.handle_input c
258 def exists? n; @name_map.member? n; end
259 def [] n; @name_map[n]; end
261 raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
262 raise ArgumentError, "title must be a string" unless n.is_a? String
266 def completely_redraw_screen
269 ## this magic makes Ncurses get the new size of the screen
271 Ncurses.stdscr.keypad 1
273 @sigwinch_mutex.synchronize { @sigwinch_happened = false }
274 Redwood::log "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
276 status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
281 draw_screen :sync => false, :status => status, :title => title
285 def draw_screen opts={}
289 if opts.member? :status
290 [opts[:status], opts[:title]]
292 raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
293 get_status_and_title @focus_buf # must be called outside of the ncurses lock
296 ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
297 print "\033]0;#{title}\07" if title && @in_x
299 Ncurses.mutex.lock unless opts[:sync] == false
301 ## disabling this for the time being, to help with debugging
302 ## (currently we only have one buffer visible at a time).
303 ## TODO: reenable this if we allow multiple buffers
304 false && @buffers.inject(@dirty) do |dirty, buf|
305 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
306 #dirty ? buf.draw : buf.redraw
314 buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
315 @dirty ? buf.draw(status) : buf.redraw(status)
318 draw_minibuf :sync => false unless opts[:skip_minibuf]
322 Ncurses.refresh if opts[:refresh]
323 Ncurses.mutex.unlock unless opts[:sync] == false
326 ## if the named buffer already exists, pops it to the front without
327 ## calling the block. otherwise, gets the mode from the block and
328 ## creates a new buffer. returns two things: the buffer, and a boolean
329 ## indicating whether it's a new buffer or not.
330 def spawn_unless_exists title, opts={}
332 if @name_map.member? title
333 raise_to_front @name_map[title] unless opts[:hidden]
337 spawn title, mode, opts
340 [@name_map[title], new]
343 def spawn title, mode, opts={}
344 raise ArgumentError, "title must be a string" unless title.is_a? String
347 while @name_map.member? realtitle
348 realtitle = "#{title} <#{num}>"
352 width = opts[:width] || Ncurses.cols
353 height = opts[:height] || Ncurses.rows - 1
355 ## since we are currently only doing multiple full-screen modes,
356 ## use stdscr for each window. once we become more sophisticated,
357 ## we may need to use a new Ncurses::WINDOW
359 ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
360 ## (opts[:left] || 0))
362 b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
364 @name_map[realtitle] = b
368 focus_on b unless @focus_buf
375 ## requires the mode to have #done? and #value methods
376 def spawn_modal title, mode, opts={}
377 b = spawn title, mode, opts
381 c = Ncurses.nonblocking_getch
382 next unless c # getch timeout
383 break if c == Ncurses::KEY_CANCEL
386 rescue InputSequenceAborted # do nothing
396 def kill_all_buffers_safely
397 until @buffers.empty?
398 ## inbox mode always claims it's unkillable. we'll ignore it.
399 return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
400 kill_buffer @buffers.last
405 def kill_buffer_safely buf
406 return false unless buf.mode.killable?
412 kill_buffer @buffers.first until @buffers.empty?
416 raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
420 @name_map.delete buf.title
421 @focus_buf = nil if @focus_buf == buf
423 ## TODO: something intelligent here
424 ## for now I will simply prohibit killing the inbox buffer.
426 raise_to_front @buffers.last
430 def ask_with_completions domain, question, completions, default=nil
431 ask domain, question, default do |s|
432 completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
436 def ask_many_with_completions domain, question, completions, default=nil
437 ask domain, question, default do |partial|
442 when /^(.*\s+)?(.*?)$/
445 raise "william screwed up completion: #{partial.inspect}"
448 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
452 def ask_many_emails_with_completions domain, question, completions, default=nil
453 ask domain, question, default do |partial|
454 prefix, target = partial.split_on_commas_with_remainder
455 target ||= prefix.pop || ""
456 prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
457 completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
461 def ask_for_filename domain, question, default=nil
462 answer = ask domain, question, default do |s|
463 if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
465 name = $2.empty? ? Etc.getlogin : $2
466 dir = Etc.getpwnam(name).dir rescue nil
468 [[s.sub(full, dir), "~#{name}"]]
470 users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
471 [s.sub("~#{name}", "~#{u}"), "~#{u}"]
474 else # regular filename completion
475 Dir["#{s}*"].sort.map do |fn|
476 suffix = File.directory?(fn) ? "/" : ""
477 [fn + suffix, File.basename(fn) + suffix]
485 spawn_modal "file browser", FileBrowserMode.new
486 elsif File.directory?(answer)
487 spawn_modal "file browser", FileBrowserMode.new(answer)
489 File.expand_path answer
496 ## returns an array of labels
497 def ask_for_labels domain, question, default_labels, forbidden_labels=[]
498 default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
499 default = default_labels.join(" ")
500 default += " " unless default.empty?
502 # here I would prefer to give more control and allow all_labels instead of
503 # user_defined_labels only
504 applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
506 answer = ask_many_with_completions domain, question, applyable_labels, default
510 user_labels = answer.symbolistize
511 user_labels.each do |l|
512 if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
513 BufferManager.flash "'#{l}' is a reserved label!"
520 def ask_for_contacts domain, question, default_contacts=[]
521 default = default_contacts.map { |s| s.to_s }.join(" ")
522 default += " " unless default.empty?
524 recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
525 contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
527 completions = (recent + contacts).flatten.uniq
528 completions += HookManager.run("extra-contact-addresses") || []
529 answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
532 answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
536 ## for simplicitly, we always place the question at the very bottom of the
538 def ask domain, question, default=nil, &block
539 raise "impossible!" if @asking
542 @textfields[domain] ||= TextField.new
543 tf = @textfields[domain]
546 status, title = get_status_and_title @focus_buf
549 tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
550 @dirty = true # for some reason that blanks the whole fucking screen
551 draw_screen :sync => false, :status => status, :title => title
557 c = Ncurses.nonblocking_getch
558 next unless c # getch timeout
559 break unless tf.handle_input c # process keystroke
561 if tf.new_completions?
562 kill_buffer completion_buf if completion_buf
564 shorts = tf.completions.map { |full, short| short }
565 prefix_len = shorts.shared_prefix.length
567 mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
568 completion_buf = spawn "<completions>", mode, :height => 10
570 draw_screen :skip_minibuf => true
572 elsif tf.roll_completions?
573 completion_buf.mode.roll
574 draw_screen :skip_minibuf => true
578 Ncurses.sync { Ncurses.refresh }
581 kill_buffer completion_buf if completion_buf
587 draw_screen :sync => false, :status => status, :title => title
592 def ask_getch question, accept=nil
593 raise "impossible!" if @asking
595 accept = accept.split(//).map { |x| x[0] } if accept
597 status, title = get_status_and_title @focus_buf
599 draw_screen :sync => false, :status => status, :title => title
600 Ncurses.mvaddstr Ncurses.rows - 1, 0, question
601 Ncurses.move Ncurses.rows - 1, question.length + 1
610 key = Ncurses.nonblocking_getch or next
611 if key == Ncurses::KEY_CANCEL
613 elsif accept.nil? || accept.empty? || accept.member?(key)
622 draw_screen :sync => false, :status => status, :title => title
628 ## returns true (y), false (n), or nil (ctrl-g / cancel)
629 def ask_yes_or_no question
630 case(r = ask_getch question, "ynYN")
640 ## turns an input keystroke into an action symbol. returns the action
641 ## if found, nil if not found, and throws InputSequenceAborted if
642 ## the user aborted a multi-key sequence. (Because each of those cases
643 ## should be handled differently.)
645 ## this is in BufferManager because multi-key sequences require prompting.
646 def resolve_input_with_keymap c, keymap
647 action, text = keymap.action_for c
648 while action.is_a? Keymap # multi-key commands, prompt
649 key = BufferManager.ask_getch text
650 unless key # user canceled, abort
652 raise InputSequenceAborted
654 action, text = action.action_for(key) if action.has_key?(key)
660 @minibuf_mutex.synchronize do
663 @minibuf_stack.compact.size, 1].max
667 def draw_minibuf opts={}
669 @minibuf_mutex.synchronize do
670 m = @minibuf_stack.compact
671 m << @flash if @flash
672 m << "" if m.empty? unless @asking # to clear it
675 Ncurses.mutex.lock unless opts[:sync] == false
676 Ncurses.attrset Colormap.color_for(:none)
677 adj = @asking ? 2 : 1
678 m.each_with_index do |s, i|
679 Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
681 Ncurses.refresh if opts[:refresh]
682 Ncurses.mutex.unlock unless opts[:sync] == false
688 @minibuf_mutex.synchronize do
690 id ||= @minibuf_stack.length
691 @minibuf_stack[id] = s
695 draw_screen :refresh => true
697 draw_minibuf :refresh => true
710 def erase_flash; @flash = nil; end
714 draw_screen :refresh => true
717 ## a little tricky because we can't just delete_at id because ids
718 ## are relative (they're positions into the array).
720 @minibuf_mutex.synchronize do
721 @minibuf_stack[id] = nil
722 if id == @minibuf_stack.length - 1
724 break if @minibuf_stack[i]
725 @minibuf_stack.delete_at i
730 draw_screen :refresh => true
733 def shell_out command
738 Ncurses.stdscr.keypad 1
746 def default_status_bar buf
747 " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
750 def default_terminal_title buf
751 "Sup #{Redwood::VERSION} :: #{buf.title}"
754 def get_status_and_title buf
756 :num_inbox => lambda { Index.num_results_for :label => :inbox },
757 :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
758 :num_total => lambda { Index.size },
759 :num_spam => lambda { Index.num_results_for :label => :spam },
761 :mode => buf.mode.name,
762 :status => buf.mode.status
765 statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
766 term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
768 [statusbar_text, term_title_text]
774 while(u = Etc.getpwent)