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