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