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