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