]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
restore keypad mode after we force ncurses to refresh the whole screen
[sup] / lib / sup / buffer.rb
1 require 'etc'
2 require 'thread'
3 require 'ncurses'
4
5 if defined? Ncurses
6 module Ncurses
7   def rows
8     lame, lamer = [], []
9     stdscr.getmaxyx lame, lamer
10     lame.first
11   end
12
13   def cols
14     lame, lamer = [], []
15     stdscr.getmaxyx lame, lamer
16     lamer.first
17   end
18
19   def curx
20     lame, lamer = [], []
21     stdscr.getyx lame, lamer
22     lamer.first
23   end
24
25   def mutex; @mutex ||= Mutex.new; end
26   def sync &b; mutex.synchronize(&b); end
27
28   def nonblocking_getch
29     ## INSANTIY
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)
34       c = Ncurses.getch
35     end
36   end
37
38   module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
39
40   remove_const :KEY_ENTER
41   remove_const :KEY_CANCEL
42
43   KEY_ENTER = 10
44   KEY_CANCEL = 7 # ctrl-g
45   KEY_TAB = 9
46 end
47 end
48
49 module Redwood
50
51 class InputSequenceAborted < StandardError; end
52
53 class Buffer
54   attr_reader :mode, :x, :y, :width, :height, :title, :atime
55   bool_reader :dirty, :system
56   bool_accessor :force_to_top
57
58   def initialize window, mode, width, height, opts={}
59     @w = window
60     @mode = mode
61     @dirty = true
62     @focus = false
63     @title = opts[:title] || ""
64     @force_to_top = opts[:force_to_top] || false
65     @x, @y, @width, @height = 0, 0, width, height
66     @atime = Time.at 0
67     @system = opts[:system] || false
68   end
69
70   def content_height; @height - 1; end
71   def content_width; @width; end
72
73   def resize rows, cols
74     return if cols == @width && rows == @height
75     @width = cols
76     @height = rows
77     @dirty = true
78     mode.resize rows, cols
79   end
80
81   def redraw status
82     if @dirty
83       draw status 
84     else
85       draw_status status
86     end
87
88     commit
89   end
90
91   def mark_dirty; @dirty = true; end
92
93   def commit
94     @dirty = false
95     @w.noutrefresh
96   end
97
98   def draw status
99     @mode.draw
100     draw_status status
101     commit
102     @atime = Time.now
103   end
104
105   ## s nil means a blank line!
106   def write y, x, s, opts={}
107     return if x >= @width || y >= @height
108
109     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
110     s ||= ""
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]
117       l = s.display_length
118       unless l >= maxl
119         @w.mvaddstr(y, x + l, " " * (maxl - l))
120       end
121     end
122   end
123
124   def clear
125     @w.clear
126   end
127
128   def draw_status status
129     write @height - 1, 0, status, :color => :status_color
130   end
131
132   def focus
133     @focus = true
134     @dirty = true
135     @mode.focus
136   end
137
138   def blur
139     @focus = false
140     @dirty = true
141     @mode.blur
142   end
143 end
144
145 class BufferManager
146   include Singleton
147
148   attr_reader :focus_buf
149
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"
154
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.
159
160 Variables:
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.
169 EOS
170
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.
174
175 Variables: the same as status-bar-text hook.
176 Return value: a string to be used as the terminal title.
177 EOS
178
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.
183
184 Variables: none
185 Return value: an array of email address strings.
186 EOS
187
188   def initialize
189     @name_map = {}
190     @buffers = []
191     @focus_buf = nil
192     @dirty = true
193     @minibuf_stack = []
194     @minibuf_mutex = Mutex.new
195     @textfields = {}
196     @flash = nil
197     @shelled = @asking = false
198     @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
199     @sigwinch_happened = false
200     @sigwinch_mutex = Mutex.new
201
202     self.class.i_am_the_instance self
203   end
204
205   def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
206   def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
207
208   def buffers; @name_map.to_a; end
209
210   def focus_on buf
211     return unless @buffers.member? buf
212     return if buf == @focus_buf 
213     @focus_buf.blur if @focus_buf
214     @focus_buf = buf
215     @focus_buf.focus
216   end
217
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)
222     else
223       @buffers.push buf
224     end
225     focus_on @buffers.last
226     @dirty = true
227   end
228
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.
233   ##
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
236   ## do that.
237   def roll_buffers
238     @buffers.last.force_to_top = false
239     raise_to_front @buffers.first
240   end
241
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]
246   end
247
248   def handle_input c
249     if @focus_buf
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
253       end
254       @focus_buf.mode.handle_input c
255     end
256   end
257
258   def exists? n; @name_map.member? n; end
259   def [] n; @name_map[n]; end
260   def []= n, b
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
263     @name_map[n] = b
264   end
265
266   def completely_redraw_screen
267     return if @shelled
268
269     ## this magic makes Ncurses get the new size of the screen
270     Ncurses.endwin
271     Ncurses.stdscr.keypad 1
272     Ncurses.refresh
273     @sigwinch_mutex.synchronize { @sigwinch_happened = false }
274     Redwood::log "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
275
276     status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
277
278     Ncurses.sync do
279       @dirty = true
280       Ncurses.clear
281       draw_screen :sync => false, :status => status, :title => title
282     end
283   end
284
285   def draw_screen opts={}
286     return if @shelled
287
288     status, title =
289       if opts.member? :status
290         [opts[:status], opts[:title]]
291       else
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
294       end
295
296     ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
297     print "\033]0;#{title}\07" if title && @in_x
298
299     Ncurses.mutex.lock unless opts[:sync] == false
300
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
307       buf.draw status
308       dirty
309     end
310
311     ## quick hack
312     if true
313       buf = @buffers.last
314       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
315       @dirty ? buf.draw(status) : buf.redraw(status)
316     end
317
318     draw_minibuf :sync => false unless opts[:skip_minibuf]
319
320     @dirty = false
321     Ncurses.doupdate
322     Ncurses.refresh if opts[:refresh]
323     Ncurses.mutex.unlock unless opts[:sync] == false
324   end
325
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={}
331     new = 
332       if @name_map.member? title
333         raise_to_front @name_map[title] unless opts[:hidden]
334         false
335       else
336         mode = yield
337         spawn title, mode, opts
338         true
339       end
340     [@name_map[title], new]
341   end
342
343   def spawn title, mode, opts={}
344     raise ArgumentError, "title must be a string" unless title.is_a? String
345     realtitle = title
346     num = 2
347     while @name_map.member? realtitle
348       realtitle = "#{title} <#{num}>"
349       num += 1
350     end
351
352     width = opts[:width] || Ncurses.cols
353     height = opts[:height] || Ncurses.rows - 1
354
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
358     ##
359     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
360     ## (opts[:left] || 0))
361     w = Ncurses.stdscr
362     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
363     mode.buffer = b
364     @name_map[realtitle] = b
365
366     @buffers.unshift b
367     if opts[:hidden]
368       focus_on b unless @focus_buf
369     else
370       raise_to_front b
371     end
372     b
373   end
374
375   ## requires the mode to have #done? and #value methods
376   def spawn_modal title, mode, opts={}
377     b = spawn title, mode, opts
378     draw_screen
379
380     until mode.done?
381       c = Ncurses.nonblocking_getch
382       next unless c # getch timeout
383       break if c == Ncurses::KEY_CANCEL
384       begin
385         mode.handle_input c
386       rescue InputSequenceAborted # do nothing
387       end
388       draw_screen
389       erase_flash
390     end
391
392     kill_buffer b
393     mode.value
394   end
395
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
401     end
402     true
403   end
404
405   def kill_buffer_safely buf
406     return false unless buf.mode.killable?
407     kill_buffer buf
408     true
409   end
410
411   def kill_all_buffers
412     kill_buffer @buffers.first until @buffers.empty?
413   end
414
415   def kill_buffer buf
416     raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
417
418     buf.mode.cleanup
419     @buffers.delete buf
420     @name_map.delete buf.title
421     @focus_buf = nil if @focus_buf == buf
422     if @buffers.empty?
423       ## TODO: something intelligent here
424       ## for now I will simply prohibit killing the inbox buffer.
425     else
426       raise_to_front @buffers.last
427     end
428   end
429
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] }
433     end
434   end
435
436   def ask_many_with_completions domain, question, completions, default=nil
437     ask domain, question, default do |partial|
438       prefix, target = 
439         case partial
440         when /^\s*$/
441           ["", ""]
442         when /^(.*\s+)?(.*?)$/
443           [$1 || "", $2]
444         else
445           raise "william screwed up completion: #{partial.inspect}"
446         end
447
448       completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
449     end
450   end
451
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] }
458     end
459   end
460
461   def ask_for_filename domain, question, default=nil
462     answer = ask domain, question, default do |s|
463       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
464         full = $1
465         name = $2.empty? ? Etc.getlogin : $2
466         dir = Etc.getpwnam(name).dir rescue nil
467         if dir
468           [[s.sub(full, dir), "~#{name}"]]
469         else
470           users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
471             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
472           end
473         end
474       else # regular filename completion
475         Dir["#{s}*"].sort.map do |fn|
476           suffix = File.directory?(fn) ? "/" : ""
477           [fn + suffix, File.basename(fn) + suffix]
478         end
479       end
480     end
481
482     if answer
483       answer = 
484         if answer.empty?
485           spawn_modal "file browser", FileBrowserMode.new
486         elsif File.directory?(answer)
487           spawn_modal "file browser", FileBrowserMode.new(answer)
488         else
489           File.expand_path answer
490         end
491     end
492
493     answer
494   end
495
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?
501
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 }
505
506     answer = ask_many_with_completions domain, question, applyable_labels, default
507
508     return unless answer
509
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!"
514         return
515       end
516     end
517     user_labels
518   end
519
520   def ask_for_contacts domain, question, default_contacts=[]
521     default = default_contacts.map { |s| s.to_s }.join(" ")
522     default += " " unless default.empty?
523     
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] }
526
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
530
531     if answer
532       answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
533     end
534   end
535
536   ## for simplicitly, we always place the question at the very bottom of the
537   ## screen
538   def ask domain, question, default=nil, &block
539     raise "impossible!" if @asking
540     @asking = true
541
542     @textfields[domain] ||= TextField.new
543     tf = @textfields[domain]
544     completion_buf = nil
545
546     status, title = get_status_and_title @focus_buf
547
548     Ncurses.sync do
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
552       tf.position_cursor
553       Ncurses.refresh
554     end
555
556     while true
557       c = Ncurses.nonblocking_getch
558       next unless c # getch timeout
559       break unless tf.handle_input c # process keystroke
560
561       if tf.new_completions?
562         kill_buffer completion_buf if completion_buf
563         
564         shorts = tf.completions.map { |full, short| short }
565         prefix_len = shorts.shared_prefix.length
566
567         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
568         completion_buf = spawn "<completions>", mode, :height => 10
569
570         draw_screen :skip_minibuf => true
571         tf.position_cursor
572       elsif tf.roll_completions?
573         completion_buf.mode.roll
574         draw_screen :skip_minibuf => true
575         tf.position_cursor
576       end
577
578       Ncurses.sync { Ncurses.refresh }
579     end
580     
581     kill_buffer completion_buf if completion_buf
582
583     @dirty = true
584     @asking = false
585     Ncurses.sync do
586       tf.deactivate
587       draw_screen :sync => false, :status => status, :title => title
588     end
589     tf.value
590   end
591
592   def ask_getch question, accept=nil
593     raise "impossible!" if @asking
594
595     accept = accept.split(//).map { |x| x[0] } if accept
596
597     status, title = get_status_and_title @focus_buf
598     Ncurses.sync do
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
602       Ncurses.curs_set 1
603       Ncurses.refresh
604     end
605
606     @asking = true
607     ret = nil
608     done = false
609     until done
610       key = Ncurses.nonblocking_getch or next
611       if key == Ncurses::KEY_CANCEL
612         done = true
613       elsif accept.nil? || accept.empty? || accept.member?(key)
614         ret = key
615         done = true
616       end
617     end
618
619     @asking = false
620     Ncurses.sync do
621       Ncurses.curs_set 0
622       draw_screen :sync => false, :status => status, :title => title
623     end
624
625     ret
626   end
627
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")
631     when ?y, ?Y
632       true
633     when nil
634       nil
635     else
636       false
637     end
638   end
639
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.)
644   ##
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
651         erase_flash
652         raise InputSequenceAborted
653       end
654       action, text = action.action_for(key) if action.has_key?(key)
655     end
656     action
657   end
658
659   def minibuf_lines
660     @minibuf_mutex.synchronize do
661       [(@flash ? 1 : 0) + 
662        (@asking ? 1 : 0) +
663        @minibuf_stack.compact.size, 1].max
664     end
665   end
666   
667   def draw_minibuf opts={}
668     m = nil
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
673     end
674
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)
680     end
681     Ncurses.refresh if opts[:refresh]
682     Ncurses.mutex.unlock unless opts[:sync] == false
683   end
684
685   def say s, id=nil
686     new_id = nil
687
688     @minibuf_mutex.synchronize do
689       new_id = id.nil?
690       id ||= @minibuf_stack.length
691       @minibuf_stack[id] = s
692     end
693
694     if new_id
695       draw_screen :refresh => true
696     else
697       draw_minibuf :refresh => true
698     end
699
700     if block_given?
701       begin
702         yield id
703       ensure
704         clear id
705       end
706     end
707     id
708   end
709
710   def erase_flash; @flash = nil; end
711
712   def flash s
713     @flash = s
714     draw_screen :refresh => true
715   end
716
717   ## a little tricky because we can't just delete_at id because ids
718   ## are relative (they're positions into the array).
719   def clear id
720     @minibuf_mutex.synchronize do
721       @minibuf_stack[id] = nil
722       if id == @minibuf_stack.length - 1
723         id.downto(0) do |i|
724           break if @minibuf_stack[i]
725           @minibuf_stack.delete_at i
726         end
727       end
728     end
729
730     draw_screen :refresh => true
731   end
732
733   def shell_out command
734     @shelled = true
735     Ncurses.sync do
736       Ncurses.endwin
737       system command
738       Ncurses.stdscr.keypad 1
739       Ncurses.refresh
740       Ncurses.curs_set 0
741     end
742     @shelled = false
743   end
744
745 private
746   def default_status_bar buf
747     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
748   end
749
750   def default_terminal_title buf
751     "Sup #{Redwood::VERSION} :: #{buf.title}"
752   end
753
754   def get_status_and_title buf
755     opts = {
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 },
760       :title => buf.title,
761       :mode => buf.mode.name,
762       :status => buf.mode.status
763     }
764
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)
767     
768     [statusbar_text, term_title_text]
769   end
770
771   def users
772     unless @users
773       @users = []
774       while(u = Etc.getpwent)
775         @users << u.name
776       end
777     end
778     @users
779   end
780 end
781 end