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