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