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