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