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