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