]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
many many changes. this is what happens when i have 5 hours on an airplane
[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 Buffer
52   attr_reader :mode, :x, :y, :width, :height, :title
53   bool_reader :dirty
54   bool_accessor :force_to_top
55
56   def initialize window, mode, width, height, opts={}
57     @w = window
58     @mode = mode
59     @dirty = true
60     @focus = false
61     @title = opts[:title] || ""
62     @force_to_top = opts[:force_to_top] || false
63     @x, @y, @width, @height = 0, 0, width, height
64   end
65
66   def content_height; @height - 1; end
67   def content_width; @width; end
68
69   def resize rows, cols 
70     return if cols == @width && rows == @height
71     @width = cols
72     @height = rows
73     @dirty = true
74     mode.resize rows, cols
75   end
76
77   def redraw status
78     if @dirty
79       draw status 
80     else
81       draw_status status
82     end
83
84     commit
85   end
86
87   def mark_dirty; @dirty = true; end
88
89   def commit
90     @dirty = false
91     @w.noutrefresh
92   end
93
94   def draw status
95     @mode.draw
96     draw_status status
97     commit
98   end
99
100   ## s nil means a blank line!
101   def write y, x, s, opts={}
102     return if x >= @width || y >= @height
103
104     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
105     s ||= ""
106     maxl = @width - x
107     @w.mvaddstr y, x, s[0 ... maxl]
108     unless s.length >= maxl || opts[:no_fill]
109       @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
110     end
111   end
112
113   def clear
114     @w.clear
115   end
116
117   def draw_status status
118     write @height - 1, 0, status, :color => :status_color
119   end
120
121   def focus
122     @focus = true
123     @dirty = true
124     @mode.focus
125   end
126
127   def blur
128     @focus = false
129     @dirty = true
130     @mode.blur
131   end
132 end
133
134 class BufferManager
135   include Singleton
136
137   attr_reader :focus_buf
138
139   ## we have to define the key used to continue in-buffer search here, because
140   ## it has special semantics that BufferManager deals with---current searches
141   ## are canceled by any keypress except this one.
142   CONTINUE_IN_BUFFER_SEARCH_KEY = "n"
143
144   HookManager.register "status-bar-text", <<EOS
145 Sets the status bar. The default status bar contains the mode name, the buffer
146 title, and the mode status. Note that this will be called at least once per
147 keystroke, so excessive computation is discouraged.
148
149 Variables:
150          num_inbox: number of messages in inbox
151   num_inbox_unread: total number of messages marked as unread
152          num_total: total number of messages in the index
153           num_spam: total number of messages marked as spam
154              title: title of the current buffer
155               mode: current mode name (string)
156             status: current mode status (string)
157 Return value: a string to be used as the status bar.
158 EOS
159
160   HookManager.register "terminal-title-text", <<EOS
161 Sets the title of the current terminal, if applicable. Note that this will be
162 called at least once per keystroke, so excessive computation is discouraged.
163
164 Variables: the same as status-bar-text hook.
165 Return value: a string to be used as the terminal title.
166 EOS
167
168   def initialize
169     @name_map = {}
170     @buffers = []
171     @focus_buf = nil
172     @dirty = true
173     @minibuf_stack = []
174     @minibuf_mutex = Mutex.new
175     @textfields = {}
176     @flash = nil
177     @shelled = @asking = false
178
179     self.class.i_am_the_instance self
180   end
181
182   def buffers; @name_map.to_a; end
183
184   def focus_on buf
185     return unless @buffers.member? buf
186
187     return if buf == @focus_buf 
188     @focus_buf.blur if @focus_buf
189     @focus_buf = buf
190     @focus_buf.focus
191   end
192
193   def raise_to_front buf
194     return unless @buffers.member? buf
195
196     @buffers.delete buf
197     if @buffers.length > 0 && @buffers.last.force_to_top?
198       @buffers.insert(-2, buf)
199     else
200       @buffers.push buf
201       focus_on buf
202     end
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         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       mode.handle_input c
353       draw_screen
354       erase_flash
355     end
356
357     kill_buffer b
358     mode.value
359   end
360
361   def kill_all_buffers_safely
362     until @buffers.empty?
363       ## inbox mode always claims it's unkillable. we'll ignore it.
364       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
365       kill_buffer @buffers.last
366     end
367     true
368   end
369
370   def kill_buffer_safely buf
371     return false unless buf.mode.killable?
372     kill_buffer buf
373     true
374   end
375
376   def kill_all_buffers
377     kill_buffer @buffers.first until @buffers.empty?
378   end
379
380   def kill_buffer buf
381     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
382
383     buf.mode.cleanup
384     @buffers.delete buf
385     @name_map.delete buf.title
386     @focus_buf = nil if @focus_buf == buf
387     if @buffers.empty?
388       ## TODO: something intelligent here
389       ## for now I will simply prohibit killing the inbox buffer.
390     else
391       last = @buffers.last
392       @focus_buf ||= last
393       raise_to_front 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
503   def ask domain, question, default=nil, &block
504     raise "impossible!" if @asking
505     @asking = true
506
507     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
508     tf = @textfields[domain]
509     completion_buf = nil
510
511     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
512     ## jesus christ. the exact sequence of ncurses events that needs
513     ## to happen in order to display a form and have the entire screen
514     ## not disappear and have the cursor in the right place is TOO
515     ## FUCKING COMPLICATED.
516     Ncurses.sync do
517       tf.activate 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
539         tf.position_cursor
540       elsif tf.roll_completions?
541         completion_buf.mode.roll
542         draw_screen
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   def minibuf_lines
606     @minibuf_mutex.synchronize do
607       [(@flash ? 1 : 0) + 
608        (@asking ? 1 : 0) +
609        @minibuf_stack.compact.size, 1].max
610     end
611   end
612   
613   def draw_minibuf opts={}
614     m = nil
615     @minibuf_mutex.synchronize do
616       m = @minibuf_stack.compact
617       m << @flash if @flash
618       m << "" if m.empty?
619     end
620
621     Ncurses.mutex.lock unless opts[:sync] == false
622     Ncurses.attrset Colormap.color_for(:none)
623     adj = @asking ? 2 : 1
624     m.each_with_index do |s, i|
625       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
626     end
627     Ncurses.refresh if opts[:refresh]
628     Ncurses.mutex.unlock unless opts[:sync] == false
629   end
630
631   def say s, id=nil
632     new_id = nil
633
634     @minibuf_mutex.synchronize do
635       new_id = id.nil?
636       id ||= @minibuf_stack.length
637       @minibuf_stack[id] = s
638     end
639
640     if new_id
641       draw_screen :refresh => true
642     else
643       draw_minibuf :refresh => true
644     end
645
646     if block_given?
647       begin
648         yield id
649       ensure
650         clear id
651       end
652     end
653     id
654   end
655
656   def erase_flash; @flash = nil; end
657
658   def flash s
659     @flash = s
660     draw_screen :refresh => true
661   end
662
663   ## a little tricky because we can't just delete_at id because ids
664   ## are relative (they're positions into the array).
665   def clear id
666     @minibuf_mutex.synchronize do
667       @minibuf_stack[id] = nil
668       if id == @minibuf_stack.length - 1
669         id.downto(0) do |i|
670           break if @minibuf_stack[i]
671           @minibuf_stack.delete_at i
672         end
673       end
674     end
675
676     draw_screen :refresh => true
677   end
678
679   def shell_out command
680     @shelled = true
681     Ncurses.sync do
682       Ncurses.endwin
683       system command
684       Ncurses.refresh
685       Ncurses.curs_set 0
686     end
687     @shelled = false
688   end
689
690 private
691   def default_status_bar buf
692     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
693   end
694
695   def default_terminal_title buf
696     "Sup #{Redwood::VERSION} :: #{buf.title}"
697   end
698
699   def get_status_and_title buf
700     opts = {
701       :num_inbox => lambda { Index.num_results_for :label => :inbox },
702       :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
703       :num_total => lambda { Index.size },
704       :num_spam => lambda { Index.num_results_for :label => :spam },
705       :title => buf.title,
706       :mode => buf.mode.name,
707       :status => buf.mode.status
708     }
709
710     statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
711     term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
712     
713     [statusbar_text, term_title_text]
714   end
715
716   def users
717     unless @users
718       @users = []
719       while(u = Etc.getpwent)
720         @users << u.name
721       end
722     end
723     @users
724   end
725 end
726 end