]> git.notmuchmail.org Git - sup/blob - lib/sup/buffer.rb
Merge branch 'buffer-rolling'
[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   end
202
203   def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
204   def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
205
206   def buffers; @name_map.to_a; end
207
208   def focus_on buf
209     return unless @buffers.member? buf
210     return if buf == @focus_buf 
211     @focus_buf.blur if @focus_buf
212     @focus_buf = buf
213     @focus_buf.focus
214   end
215
216   def raise_to_front buf
217     @buffers.delete(buf) or return
218     if @buffers.length > 0 && @buffers.last.force_to_top?
219       @buffers.insert(-2, buf)
220     else
221       @buffers.push buf
222     end
223     focus_on @buffers.last
224     @dirty = true
225   end
226
227   ## we reset force_to_top when rolling buffers. this is so that the
228   ## human can actually still move buffers around, while still
229   ## programmatically being able to pop stuff up in the middle of
230   ## drawing a window without worrying about covering it up.
231   ##
232   ## if we ever start calling roll_buffers programmatically, we will
233   ## have to change this. but it's not clear that we will ever actually
234   ## do that.
235   def roll_buffers
236     bufs = rollable_buffers
237     bufs.last.force_to_top = false
238     raise_to_front bufs.first
239   end
240
241   def roll_buffers_backwards
242     bufs = rollable_buffers
243     return unless bufs.length > 1
244     bufs.last.force_to_top = false
245     raise_to_front bufs[bufs.length - 2]
246   end
247
248   def rollable_buffers
249     @buffers.select { |b| !b.system? || @buffers.last == b }
250   end
251
252   def handle_input c
253     if @focus_buf
254       if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
255         @focus_buf.mode.cancel_search!
256         @focus_buf.mark_dirty
257       end
258       @focus_buf.mode.handle_input c
259     end
260   end
261
262   def exists? n; @name_map.member? n; end
263   def [] n; @name_map[n]; end
264   def []= n, b
265     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
266     raise ArgumentError, "title must be a string" unless n.is_a? String
267     @name_map[n] = b
268   end
269
270   def completely_redraw_screen
271     return if @shelled
272
273     ## this magic makes Ncurses get the new size of the screen
274     Ncurses.endwin
275     Ncurses.stdscr.keypad 1
276     Ncurses.curs_set 0
277     Ncurses.refresh
278     @sigwinch_mutex.synchronize { @sigwinch_happened = false }
279     debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
280
281     status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
282
283     Ncurses.sync do
284       @dirty = true
285       Ncurses.clear
286       draw_screen :sync => false, :status => status, :title => title
287     end
288   end
289
290   def draw_screen opts={}
291     return if @shelled
292
293     status, title =
294       if opts.member? :status
295         [opts[:status], opts[:title]]
296       else
297         raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
298         get_status_and_title @focus_buf # must be called outside of the ncurses lock
299       end
300
301     ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
302     print "\033]0;#{title}\07" if title && @in_x
303
304     Ncurses.mutex.lock unless opts[:sync] == false
305
306     ## disabling this for the time being, to help with debugging
307     ## (currently we only have one buffer visible at a time).
308     ## TODO: reenable this if we allow multiple buffers
309     false && @buffers.inject(@dirty) do |dirty, buf|
310       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
311       #dirty ? buf.draw : buf.redraw
312       buf.draw status
313       dirty
314     end
315
316     ## quick hack
317     if true
318       buf = @buffers.last
319       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
320       @dirty ? buf.draw(status) : buf.redraw(status)
321     end
322
323     draw_minibuf :sync => false unless opts[:skip_minibuf]
324
325     @dirty = false
326     Ncurses.doupdate
327     Ncurses.refresh if opts[:refresh]
328     Ncurses.mutex.unlock unless opts[:sync] == false
329   end
330
331   ## if the named buffer already exists, pops it to the front without
332   ## calling the block. otherwise, gets the mode from the block and
333   ## creates a new buffer. returns two things: the buffer, and a boolean
334   ## indicating whether it's a new buffer or not.
335   def spawn_unless_exists title, opts={}
336     new = 
337       if @name_map.member? title
338         raise_to_front @name_map[title] unless opts[:hidden]
339         false
340       else
341         mode = yield
342         spawn title, mode, opts
343         true
344       end
345     [@name_map[title], new]
346   end
347
348   def spawn title, mode, opts={}
349     raise ArgumentError, "title must be a string" unless title.is_a? String
350     realtitle = title
351     num = 2
352     while @name_map.member? realtitle
353       realtitle = "#{title} <#{num}>"
354       num += 1
355     end
356
357     width = opts[:width] || Ncurses.cols
358     height = opts[:height] || Ncurses.rows - 1
359
360     ## since we are currently only doing multiple full-screen modes,
361     ## use stdscr for each window. once we become more sophisticated,
362     ## we may need to use a new Ncurses::WINDOW
363     ##
364     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
365     ## (opts[:left] || 0))
366     w = Ncurses.stdscr
367     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
368     mode.buffer = b
369     @name_map[realtitle] = b
370
371     @buffers.unshift b
372     if opts[:hidden]
373       focus_on b unless @focus_buf
374     else
375       raise_to_front b
376     end
377     b
378   end
379
380   ## requires the mode to have #done? and #value methods
381   def spawn_modal title, mode, opts={}
382     b = spawn title, mode, opts
383     draw_screen
384
385     until mode.done?
386       c = Ncurses.nonblocking_getch
387       next unless c # getch timeout
388       break if c == Ncurses::KEY_CANCEL
389       begin
390         mode.handle_input c
391       rescue InputSequenceAborted # do nothing
392       end
393       draw_screen
394       erase_flash
395     end
396
397     kill_buffer b
398     mode.value
399   end
400
401   def kill_all_buffers_safely
402     until @buffers.empty?
403       ## inbox mode always claims it's unkillable. we'll ignore it.
404       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
405       kill_buffer @buffers.last
406     end
407     true
408   end
409
410   def kill_buffer_safely buf
411     return false unless buf.mode.killable?
412     kill_buffer buf
413     true
414   end
415
416   def kill_all_buffers
417     kill_buffer @buffers.first until @buffers.empty?
418   end
419
420   def kill_buffer buf
421     raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
422
423     buf.mode.cleanup
424     @buffers.delete buf
425     @name_map.delete buf.title
426     @focus_buf = nil if @focus_buf == buf
427     if @buffers.empty?
428       ## TODO: something intelligent here
429       ## for now I will simply prohibit killing the inbox buffer.
430     else
431       raise_to_front @buffers.last
432     end
433   end
434
435   def ask_with_completions domain, question, completions, default=nil
436     ask domain, question, default do |s|
437       completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] }
438     end
439   end
440
441   def ask_many_with_completions domain, question, completions, default=nil
442     ask domain, question, default do |partial|
443       prefix, target = 
444         case partial
445         when /^\s*$/
446           ["", ""]
447         when /^(.*\s+)?(.*?)$/
448           [$1 || "", $2]
449         else
450           raise "william screwed up completion: #{partial.inspect}"
451         end
452
453       completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
454     end
455   end
456
457   def ask_many_emails_with_completions domain, question, completions, default=nil
458     ask domain, question, default do |partial|
459       prefix, target = partial.split_on_commas_with_remainder
460       target ||= prefix.pop || ""
461       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
462       completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
463     end
464   end
465
466   def ask_for_filename domain, question, default=nil
467     answer = ask domain, question, default do |s|
468       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
469         full = $1
470         name = $2.empty? ? Etc.getlogin : $2
471         dir = Etc.getpwnam(name).dir rescue nil
472         if dir
473           [[s.sub(full, dir), "~#{name}"]]
474         else
475           users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
476             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
477           end
478         end
479       else # regular filename completion
480         Dir["#{s}*"].sort.map do |fn|
481           suffix = File.directory?(fn) ? "/" : ""
482           [fn + suffix, File.basename(fn) + suffix]
483         end
484       end
485     end
486
487     if answer
488       answer =
489         if answer.empty?
490           spawn_modal "file browser", FileBrowserMode.new
491         elsif File.directory?(answer)
492           spawn_modal "file browser", FileBrowserMode.new(answer)
493         else
494           File.expand_path answer
495         end
496     end
497
498     answer
499   end
500
501   ## returns an array of labels
502   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
503     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
504     default = default_labels.to_a.join(" ")
505     default += " " unless default.empty?
506
507     # here I would prefer to give more control and allow all_labels instead of
508     # user_defined_labels only
509     applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
510
511     answer = ask_many_with_completions domain, question, applyable_labels, default
512
513     return unless answer
514
515     user_labels = answer.to_set_of_symbols
516     user_labels.each do |l|
517       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
518         BufferManager.flash "'#{l}' is a reserved label!"
519         return
520       end
521     end
522     user_labels
523   end
524
525   def ask_for_contacts domain, question, default_contacts=[]
526     default = default_contacts.map { |s| s.to_s }.join(" ")
527     default += " " unless default.empty?
528
529     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
530     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
531
532     completions = (recent + contacts).flatten.uniq
533     completions += HookManager.run("extra-contact-addresses") || []
534     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
535
536     if answer
537       answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
538     end
539   end
540
541   ## for simplicitly, we always place the question at the very bottom of the
542   ## screen
543   def ask domain, question, default=nil, &block
544     raise "impossible!" if @asking
545     @asking = true
546
547     @textfields[domain] ||= TextField.new
548     tf = @textfields[domain]
549     completion_buf = nil
550
551     status, title = get_status_and_title @focus_buf
552
553     Ncurses.sync do
554       tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
555       @dirty = true # for some reason that blanks the whole fucking screen
556       draw_screen :sync => false, :status => status, :title => title
557       tf.position_cursor
558       Ncurses.refresh
559     end
560
561     while true
562       c = Ncurses.nonblocking_getch
563       next unless c # getch timeout
564       break unless tf.handle_input c # process keystroke
565
566       if tf.new_completions?
567         kill_buffer completion_buf if completion_buf
568         
569         shorts = tf.completions.map { |full, short| short }
570         prefix_len = shorts.shared_prefix.length
571
572         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
573         completion_buf = spawn "<completions>", mode, :height => 10
574
575         draw_screen :skip_minibuf => true
576         tf.position_cursor
577       elsif tf.roll_completions?
578         completion_buf.mode.roll
579         draw_screen :skip_minibuf => true
580         tf.position_cursor
581       end
582
583       Ncurses.sync { Ncurses.refresh }
584     end
585     
586     kill_buffer completion_buf if completion_buf
587
588     @dirty = true
589     @asking = false
590     Ncurses.sync do
591       tf.deactivate
592       draw_screen :sync => false, :status => status, :title => title
593     end
594     tf.value
595   end
596
597   def ask_getch question, accept=nil
598     raise "impossible!" if @asking
599
600     accept = accept.split(//).map { |x| x[0] } if accept
601
602     status, title = get_status_and_title @focus_buf
603     Ncurses.sync do
604       draw_screen :sync => false, :status => status, :title => title
605       Ncurses.mvaddstr Ncurses.rows - 1, 0, question
606       Ncurses.move Ncurses.rows - 1, question.length + 1
607       Ncurses.curs_set 1
608       Ncurses.refresh
609     end
610
611     @asking = true
612     ret = nil
613     done = false
614     until done
615       key = Ncurses.nonblocking_getch or next
616       if key == Ncurses::KEY_CANCEL
617         done = true
618       elsif accept.nil? || accept.empty? || accept.member?(key)
619         ret = key
620         done = true
621       end
622     end
623
624     @asking = false
625     Ncurses.sync do
626       Ncurses.curs_set 0
627       draw_screen :sync => false, :status => status, :title => title
628     end
629
630     ret
631   end
632
633   ## returns true (y), false (n), or nil (ctrl-g / cancel)
634   def ask_yes_or_no question
635     case(r = ask_getch question, "ynYN")
636     when ?y, ?Y
637       true
638     when nil
639       nil
640     else
641       false
642     end
643   end
644
645   ## turns an input keystroke into an action symbol. returns the action
646   ## if found, nil if not found, and throws InputSequenceAborted if
647   ## the user aborted a multi-key sequence. (Because each of those cases
648   ## should be handled differently.)
649   ##
650   ## this is in BufferManager because multi-key sequences require prompting.
651   def resolve_input_with_keymap c, keymap
652     action, text = keymap.action_for c
653     while action.is_a? Keymap # multi-key commands, prompt
654       key = BufferManager.ask_getch text
655       unless key # user canceled, abort
656         erase_flash
657         raise InputSequenceAborted
658       end
659       action, text = action.action_for(key) if action.has_key?(key)
660     end
661     action
662   end
663
664   def minibuf_lines
665     @minibuf_mutex.synchronize do
666       [(@flash ? 1 : 0) + 
667        (@asking ? 1 : 0) +
668        @minibuf_stack.compact.size, 1].max
669     end
670   end
671   
672   def draw_minibuf opts={}
673     m = nil
674     @minibuf_mutex.synchronize do
675       m = @minibuf_stack.compact
676       m << @flash if @flash
677       m << "" if m.empty? unless @asking # to clear it
678     end
679
680     Ncurses.mutex.lock unless opts[:sync] == false
681     Ncurses.attrset Colormap.color_for(:none)
682     adj = @asking ? 2 : 1
683     m.each_with_index do |s, i|
684       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
685     end
686     Ncurses.refresh if opts[:refresh]
687     Ncurses.mutex.unlock unless opts[:sync] == false
688   end
689
690   def say s, id=nil
691     new_id = nil
692
693     @minibuf_mutex.synchronize do
694       new_id = id.nil?
695       id ||= @minibuf_stack.length
696       @minibuf_stack[id] = s
697     end
698
699     if new_id
700       draw_screen :refresh => true
701     else
702       draw_minibuf :refresh => true
703     end
704
705     if block_given?
706       begin
707         yield id
708       ensure
709         clear id
710       end
711     end
712     id
713   end
714
715   def erase_flash; @flash = nil; end
716
717   def flash s
718     @flash = s
719     draw_screen :refresh => true
720   end
721
722   ## a little tricky because we can't just delete_at id because ids
723   ## are relative (they're positions into the array).
724   def clear id
725     @minibuf_mutex.synchronize do
726       @minibuf_stack[id] = nil
727       if id == @minibuf_stack.length - 1
728         id.downto(0) do |i|
729           break if @minibuf_stack[i]
730           @minibuf_stack.delete_at i
731         end
732       end
733     end
734
735     draw_screen :refresh => true
736   end
737
738   def shell_out command
739     @shelled = true
740     Ncurses.sync do
741       Ncurses.endwin
742       system command
743       Ncurses.stdscr.keypad 1
744       Ncurses.refresh
745       Ncurses.curs_set 0
746     end
747     @shelled = false
748   end
749
750 private
751   def default_status_bar buf
752     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
753   end
754
755   def default_terminal_title buf
756     "Sup #{Redwood::VERSION} :: #{buf.title}"
757   end
758
759   def get_status_and_title buf
760     opts = {
761       :num_inbox => lambda { Index.num_results_for :label => :inbox },
762       :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
763       :num_total => lambda { Index.size },
764       :num_spam => lambda { Index.num_results_for :label => :spam },
765       :title => buf.title,
766       :mode => buf.mode.name,
767       :status => buf.mode.status
768     }
769
770     statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
771     term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
772     
773     [statusbar_text, term_title_text]
774   end
775
776   def users
777     unless @users
778       @users = []
779       while(u = Etc.getpwent)
780         @users << u.name
781       end
782     end
783     @users
784   end
785 end
786 end