]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
add multi-key sequence support
[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         get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
262       end
263
264     print "\033]2;#{title}\07" if title
265
266     Ncurses.mutex.lock unless opts[:sync] == false
267
268     ## disabling this for the time being, to help with debugging
269     ## (currently we only have one buffer visible at a time).
270     ## TODO: reenable this if we allow multiple buffers
271     false && @buffers.inject(@dirty) do |dirty, buf|
272       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
273       #dirty ? buf.draw : buf.redraw
274       buf.draw status
275       dirty
276     end
277
278     ## quick hack
279     if true
280       buf = @buffers.last
281       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
282       @dirty ? buf.draw(status) : buf.redraw(status)
283     end
284
285     draw_minibuf :sync => false unless opts[:skip_minibuf]
286
287     @dirty = false
288     Ncurses.doupdate
289     Ncurses.refresh if opts[:refresh]
290     Ncurses.mutex.unlock unless opts[:sync] == false
291   end
292
293   ## if the named buffer already exists, pops it to the front without
294   ## calling the block. otherwise, gets the mode from the block and
295   ## creates a new buffer. returns two things: the buffer, and a boolean
296   ## indicating whether it's a new buffer or not.
297   def spawn_unless_exists title, opts={}
298     new = 
299       if @name_map.member? title
300         raise_to_front @name_map[title] unless opts[:hidden]
301         false
302       else
303         mode = yield
304         spawn title, mode, opts
305         true
306       end
307     [@name_map[title], new]
308   end
309
310   def spawn title, mode, opts={}
311     raise ArgumentError, "title must be a string" unless title.is_a? String
312     realtitle = title
313     num = 2
314     while @name_map.member? realtitle
315       realtitle = "#{title} <#{num}>"
316       num += 1
317     end
318
319     width = opts[:width] || Ncurses.cols
320     height = opts[:height] || Ncurses.rows - 1
321
322     ## since we are currently only doing multiple full-screen modes,
323     ## use stdscr for each window. once we become more sophisticated,
324     ## we may need to use a new Ncurses::WINDOW
325     ##
326     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
327     ## (opts[:left] || 0))
328     w = Ncurses.stdscr
329     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
330     mode.buffer = b
331     @name_map[realtitle] = b
332
333     @buffers.unshift b
334     if opts[:hidden]
335       focus_on b unless @focus_buf
336     else
337       raise_to_front b
338     end
339     b
340   end
341
342   ## requires the mode to have #done? and #value methods
343   def spawn_modal title, mode, opts={}
344     b = spawn title, mode, opts
345     draw_screen
346
347     until mode.done?
348       c = Ncurses.nonblocking_getch
349       next unless c # getch timeout
350       break if c == Ncurses::KEY_CANCEL
351       begin
352         mode.handle_input c
353       rescue InputSequenceAborted # do nothing
354       end
355       draw_screen
356       erase_flash
357     end
358
359     kill_buffer b
360     mode.value
361   end
362
363   def kill_all_buffers_safely
364     until @buffers.empty?
365       ## inbox mode always claims it's unkillable. we'll ignore it.
366       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
367       kill_buffer @buffers.last
368     end
369     true
370   end
371
372   def kill_buffer_safely buf
373     return false unless buf.mode.killable?
374     kill_buffer buf
375     true
376   end
377
378   def kill_all_buffers
379     kill_buffer @buffers.first until @buffers.empty?
380   end
381
382   def kill_buffer buf
383     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
384
385     buf.mode.cleanup
386     @buffers.delete buf
387     @name_map.delete buf.title
388     @focus_buf = nil if @focus_buf == buf
389     if @buffers.empty?
390       ## TODO: something intelligent here
391       ## for now I will simply prohibit killing the inbox buffer.
392     else
393       raise_to_front @buffers.last
394     end
395   end
396
397   def ask_with_completions domain, question, completions, default=nil
398     ask domain, question, default do |s|
399       completions.select { |x| x =~ /^#{s}/i }.map { |x| [x, x] }
400     end
401   end
402
403   def ask_many_with_completions domain, question, completions, default=nil
404     ask domain, question, default do |partial|
405       prefix, target = 
406         case partial
407         when /^\s*$/
408           ["", ""]
409         when /^(.*\s+)?(.*?)$/
410           [$1 || "", $2]
411         else
412           raise "william screwed up completion: #{partial.inspect}"
413         end
414
415       completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
416     end
417   end
418
419   def ask_many_emails_with_completions domain, question, completions, default=nil
420     ask domain, question, default do |partial|
421       prefix, target = partial.split_on_commas_with_remainder
422       Redwood::log "before: prefix #{prefix.inspect}, target #{target.inspect}"
423       target ||= prefix.pop || ""
424       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
425       Redwood::log "after: prefix #{prefix.inspect}, target #{target.inspect}"
426       completions.select { |x| x =~ /^#{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 =~ /^#{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         else
458           answer
459         end
460     end
461
462     answer
463   end
464
465   ## returns an array of labels
466   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
467     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
468     default = default_labels.join(" ")
469     default += " " unless default.empty?
470
471     applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
472
473     answer = ask_many_with_completions domain, question, applyable_labels, default
474
475     return unless answer
476
477     user_labels = answer.split(/\s+/).map { |l| l.intern }
478     user_labels.each do |l|
479       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
480         BufferManager.flash "'#{l}' is a reserved label!"
481         return
482       end
483     end
484     user_labels
485   end
486
487   def ask_for_contacts domain, question, default_contacts=[]
488     default = default_contacts.map { |s| s.to_s }.join(" ")
489     default += " " unless default.empty?
490     
491     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
492     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
493
494     completions = (recent + contacts).flatten.uniq.sort
495     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
496
497     if answer
498       answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
499     end
500   end
501
502   def ask domain, question, default=nil, &block
503     raise "impossible!" if @asking
504     @asking = true
505
506     @textfields[domain] ||= TextField.new
507     tf = @textfields[domain]
508     completion_buf = nil
509
510     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
511     ## jesus christ. the exact sequence of ncurses events that needs
512     ## to happen in order to display a form and have the entire screen
513     ## not disappear and have the cursor in the right place can only
514     ## be determined by hours of trial and error and is TOO FUCKING
515     ## COMPLICATED.
516     Ncurses.sync do
517       tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
518       @dirty = true
519       draw_screen :skip_minibuf => true, :sync => false
520       tf.position_cursor
521       Ncurses.refresh
522     end
523
524     while true
525       c = Ncurses.nonblocking_getch
526       next unless c # getch timeout
527       break unless tf.handle_input c # process keystroke
528
529       if tf.new_completions?
530         kill_buffer completion_buf if completion_buf
531         
532         shorts = tf.completions.map { |full, short| short }
533         prefix_len = shorts.shared_prefix.length
534
535         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
536         completion_buf = spawn "<completions>", mode, :height => 10
537
538         draw_screen :skip_minibuf => true
539         tf.position_cursor
540       elsif tf.roll_completions?
541         completion_buf.mode.roll
542         draw_screen :skip_minibuf => true
543         tf.position_cursor
544       end
545
546       Ncurses.sync { Ncurses.refresh }
547     end
548     
549     Ncurses.sync { tf.deactivate }
550     kill_buffer completion_buf if completion_buf
551     @dirty = true
552     @asking = false
553     draw_screen
554     tf.value
555   end
556
557   ## some pretty lame code in here!
558   def ask_getch question, accept=nil
559     accept = accept.split(//).map { |x| x[0] } if accept
560
561     flash question
562     Ncurses.sync do
563       Ncurses.curs_set 1
564       Ncurses.move Ncurses.rows - 1, question.length + 1
565       Ncurses.refresh
566     end
567
568     ret = nil
569     done = false
570     @shelled = true
571     until done
572       key = Ncurses.nonblocking_getch or next
573       if key == Ncurses::KEY_CANCEL
574         done = true
575       elsif (accept && accept.member?(key)) || !accept
576         ret = key
577         done = true
578       end
579     end
580
581     @shelled = false
582
583     Ncurses.sync do
584       Ncurses.curs_set 0
585       erase_flash
586       draw_screen :sync => false
587       Ncurses.curs_set 0
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?
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