]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
make BufferManager#ask_getch and #ask finally work correctly
[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       Redwood::log "before: prefix #{prefix.inspect}, target #{target.inspect}"
424       target ||= prefix.pop || ""
425       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
426       Redwood::log "after: prefix #{prefix.inspect}, target #{target.inspect}"
427       completions.select { |x| x =~ /^#{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 =~ /^#{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           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     @asking = true
562
563     accept = accept.split(//).map { |x| x[0] } if accept
564
565     status, title = get_status_and_title @focus_buf
566     Ncurses.sync do
567       draw_screen :sync => false, :status => status, :title => title
568       Ncurses.mvaddstr Ncurses.rows - 1, 0, question
569       Ncurses.move Ncurses.rows - 1, question.length + 1
570       Ncurses.curs_set 1
571       Ncurses.refresh
572     end
573
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