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