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