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