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