]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
grant hollingworth once again finding my mistakes and correcting them for me
[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     return if buf == @focus_buf 
187     @focus_buf.blur if @focus_buf
188     @focus_buf = buf
189     @focus_buf.focus
190   end
191
192   def raise_to_front buf
193     @buffers.delete(buf) or return
194     if @buffers.length > 0 && @buffers.last.force_to_top?
195       @buffers.insert(-2, buf)
196     else
197       @buffers.push buf
198     end
199     focus_on @buffers.last
200     @dirty = true
201   end
202
203   ## we reset force_to_top when rolling buffers. this is so that the
204   ## human can actually still move buffers around, while still
205   ## programmatically being able to pop stuff up in the middle of
206   ## drawing a window without worrying about covering it up.
207   ##
208   ## if we ever start calling roll_buffers programmatically, we will
209   ## have to change this. but it's not clear that we will ever actually
210   ## do that.
211   def roll_buffers
212     @buffers.last.force_to_top = false
213     raise_to_front @buffers.first
214   end
215
216   def roll_buffers_backwards
217     return unless @buffers.length > 1
218     @buffers.last.force_to_top = false
219     raise_to_front @buffers[@buffers.length - 2]
220   end
221
222   def handle_input c
223     if @focus_buf
224       if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
225         @focus_buf.mode.cancel_search!
226         @focus_buf.mark_dirty
227       end
228       @focus_buf.mode.handle_input c
229     end
230   end
231
232   def exists? n; @name_map.member? n; end
233   def [] n; @name_map[n]; end
234   def []= n, b
235     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
236     raise ArgumentError, "title must be a string" unless n.is_a? String
237     @name_map[n] = b
238   end
239
240   def completely_redraw_screen
241     return if @shelled
242
243     status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
244
245     Ncurses.sync do
246       @dirty = true
247       Ncurses.clear
248       draw_screen :sync => false, :status => status, :title => title
249     end
250   end
251
252   def draw_screen opts={}
253     return if @shelled
254
255     status, title =
256       if opts.member? :status
257         [opts[:status], opts[:title]]
258       else
259         get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
260       end
261
262     print "\033]2;#{title}\07" if title
263
264     Ncurses.mutex.lock unless opts[:sync] == false
265
266     ## disabling this for the time being, to help with debugging
267     ## (currently we only have one buffer visible at a time).
268     ## TODO: reenable this if we allow multiple buffers
269     false && @buffers.inject(@dirty) do |dirty, buf|
270       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
271       #dirty ? buf.draw : buf.redraw
272       buf.draw status
273       dirty
274     end
275
276     ## quick hack
277     if true
278       buf = @buffers.last
279       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
280       @dirty ? buf.draw(status) : buf.redraw(status)
281     end
282
283     draw_minibuf :sync => false unless opts[:skip_minibuf]
284
285     @dirty = false
286     Ncurses.doupdate
287     Ncurses.refresh if opts[:refresh]
288     Ncurses.mutex.unlock unless opts[:sync] == false
289   end
290
291   ## if the named buffer already exists, pops it to the front without
292   ## calling the block. otherwise, gets the mode from the block and
293   ## creates a new buffer. returns two things: the buffer, and a boolean
294   ## indicating whether it's a new buffer or not.
295   def spawn_unless_exists title, opts={}
296     new = 
297       if @name_map.member? title
298         raise_to_front @name_map[title] unless opts[:hidden]
299         false
300       else
301         mode = yield
302         spawn title, mode, opts
303         true
304       end
305     [@name_map[title], new]
306   end
307
308   def spawn title, mode, opts={}
309     raise ArgumentError, "title must be a string" unless title.is_a? String
310     realtitle = title
311     num = 2
312     while @name_map.member? realtitle
313       realtitle = "#{title} <#{num}>"
314       num += 1
315     end
316
317     width = opts[:width] || Ncurses.cols
318     height = opts[:height] || Ncurses.rows - 1
319
320     ## since we are currently only doing multiple full-screen modes,
321     ## use stdscr for each window. once we become more sophisticated,
322     ## we may need to use a new Ncurses::WINDOW
323     ##
324     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
325     ## (opts[:left] || 0))
326     w = Ncurses.stdscr
327     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
328     mode.buffer = b
329     @name_map[realtitle] = b
330
331     @buffers.unshift b
332     if opts[:hidden]
333       focus_on b unless @focus_buf
334     else
335       raise_to_front b
336     end
337     b
338   end
339
340   ## requires the mode to have #done? and #value methods
341   def spawn_modal title, mode, opts={}
342     b = spawn title, mode, opts
343     draw_screen
344
345     until mode.done?
346       c = Ncurses.nonblocking_getch
347       next unless c # getch timeout
348       break if c == Ncurses::KEY_CANCEL
349       mode.handle_input c
350       draw_screen
351       erase_flash
352     end
353
354     kill_buffer b
355     mode.value
356   end
357
358   def kill_all_buffers_safely
359     until @buffers.empty?
360       ## inbox mode always claims it's unkillable. we'll ignore it.
361       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
362       kill_buffer @buffers.last
363     end
364     true
365   end
366
367   def kill_buffer_safely buf
368     return false unless buf.mode.killable?
369     kill_buffer buf
370     true
371   end
372
373   def kill_all_buffers
374     kill_buffer @buffers.first until @buffers.empty?
375   end
376
377   def kill_buffer buf
378     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
379
380     buf.mode.cleanup
381     @buffers.delete buf
382     @name_map.delete buf.title
383     @focus_buf = nil if @focus_buf == buf
384     if @buffers.empty?
385       ## TODO: something intelligent here
386       ## for now I will simply prohibit killing the inbox buffer.
387     else
388       raise_to_front @buffers.last
389     end
390   end
391
392   def ask_with_completions domain, question, completions, default=nil
393     ask domain, question, default do |s|
394       completions.select { |x| x =~ /^#{s}/i }.map { |x| [x, x] }
395     end
396   end
397
398   def ask_many_with_completions domain, question, completions, default=nil
399     ask domain, question, default do |partial|
400       prefix, target = 
401         case partial
402         when /^\s*$/
403           ["", ""]
404         when /^(.*\s+)?(.*?)$/
405           [$1 || "", $2]
406         else
407           raise "william screwed up completion: #{partial.inspect}"
408         end
409
410       completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
411     end
412   end
413
414   def ask_many_emails_with_completions domain, question, completions, default=nil
415     ask domain, question, default do |partial|
416       prefix, target = partial.split_on_commas_with_remainder
417       Redwood::log "before: prefix #{prefix.inspect}, target #{target.inspect}"
418       target ||= prefix.pop || ""
419       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
420       Redwood::log "after: prefix #{prefix.inspect}, target #{target.inspect}"
421       completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
422     end
423   end
424
425   def ask_for_filename domain, question, default=nil
426     answer = ask domain, question, default do |s|
427       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
428         full = $1
429         name = $2.empty? ? Etc.getlogin : $2
430         dir = Etc.getpwnam(name).dir rescue nil
431         if dir
432           [[s.sub(full, dir), "~#{name}"]]
433         else
434           users.select { |u| u =~ /^#{name}/ }.map do |u|
435             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
436           end
437         end
438       else # regular filename completion
439         Dir["#{s}*"].sort.map do |fn|
440           suffix = File.directory?(fn) ? "/" : ""
441           [fn + suffix, File.basename(fn) + suffix]
442         end
443       end
444     end
445
446     if answer
447       answer = 
448         if answer.empty?
449           spawn_modal "file browser", FileBrowserMode.new
450         elsif File.directory?(answer)
451           spawn_modal "file browser", FileBrowserMode.new(answer)
452         else
453           answer
454         end
455     end
456
457     answer
458   end
459
460   ## returns an array of labels
461   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
462     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
463     default = default_labels.join(" ")
464     default += " " unless default.empty?
465
466     applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
467
468     answer = ask_many_with_completions domain, question, applyable_labels, default
469
470     return unless answer
471
472     user_labels = answer.split(/\s+/).map { |l| l.intern }
473     user_labels.each do |l|
474       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
475         BufferManager.flash "'#{l}' is a reserved label!"
476         return
477       end
478     end
479     user_labels
480   end
481
482   def ask_for_contacts domain, question, default_contacts=[]
483     default = default_contacts.map { |s| s.to_s }.join(" ")
484     default += " " unless default.empty?
485     
486     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
487     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
488
489     completions = (recent + contacts).flatten.uniq.sort
490     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
491
492     if answer
493       answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
494     end
495   end
496
497
498   def ask domain, question, default=nil, &block
499     raise "impossible!" if @asking
500     @asking = true
501
502     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
503     tf = @textfields[domain]
504     completion_buf = nil
505
506     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
507     ## jesus christ. the exact sequence of ncurses events that needs
508     ## to happen in order to display a form and have the entire screen
509     ## not disappear and have the cursor in the right place is TOO
510     ## FUCKING COMPLICATED.
511     Ncurses.sync do
512       tf.activate question, default, &block
513       @dirty = true
514       draw_screen :skip_minibuf => true, :sync => false
515       tf.position_cursor
516       Ncurses.refresh
517     end
518
519     while true
520       c = Ncurses.nonblocking_getch
521       next unless c # getch timeout
522       break unless tf.handle_input c # process keystroke
523
524       if tf.new_completions?
525         kill_buffer completion_buf if completion_buf
526         
527         shorts = tf.completions.map { |full, short| short }
528         prefix_len = shorts.shared_prefix.length
529
530         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
531         completion_buf = spawn "<completions>", mode, :height => 10
532
533         draw_screen :skip_minibuf => true
534         tf.position_cursor
535       elsif tf.roll_completions?
536         completion_buf.mode.roll
537         draw_screen :skip_minibuf => true
538         tf.position_cursor
539       end
540
541       Ncurses.sync { Ncurses.refresh }
542     end
543     
544     Ncurses.sync { tf.deactivate }
545     kill_buffer completion_buf if completion_buf
546     @dirty = true
547     @asking = false
548     draw_screen
549     tf.value
550   end
551
552   ## some pretty lame code in here!
553   def ask_getch question, accept=nil
554     accept = accept.split(//).map { |x| x[0] } if accept
555
556     flash question
557     Ncurses.sync do
558       Ncurses.curs_set 1
559       Ncurses.move Ncurses.rows - 1, question.length + 1
560       Ncurses.refresh
561     end
562
563     ret = nil
564     done = false
565     @shelled = true
566     until done
567       key = Ncurses.nonblocking_getch or next
568       if key == Ncurses::KEY_CANCEL
569         done = true
570       elsif (accept && accept.member?(key)) || !accept
571         ret = key
572         done = true
573       end
574     end
575
576     @shelled = false
577
578     Ncurses.sync do
579       Ncurses.curs_set 0
580       erase_flash
581       draw_screen :sync => false
582       Ncurses.curs_set 0
583     end
584
585     ret
586   end
587
588   ## returns true (y), false (n), or nil (ctrl-g / cancel)
589   def ask_yes_or_no question
590     case(r = ask_getch question, "ynYN")
591     when ?y, ?Y
592       true
593     when nil
594       nil
595     else
596       false
597     end
598   end
599
600   def minibuf_lines
601     @minibuf_mutex.synchronize do
602       [(@flash ? 1 : 0) + 
603        (@asking ? 1 : 0) +
604        @minibuf_stack.compact.size, 1].max
605     end
606   end
607   
608   def draw_minibuf opts={}
609     m = nil
610     @minibuf_mutex.synchronize do
611       m = @minibuf_stack.compact
612       m << @flash if @flash
613       m << "" if m.empty?
614     end
615
616     Ncurses.mutex.lock unless opts[:sync] == false
617     Ncurses.attrset Colormap.color_for(:none)
618     adj = @asking ? 2 : 1
619     m.each_with_index do |s, i|
620       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
621     end
622     Ncurses.refresh if opts[:refresh]
623     Ncurses.mutex.unlock unless opts[:sync] == false
624   end
625
626   def say s, id=nil
627     new_id = nil
628
629     @minibuf_mutex.synchronize do
630       new_id = id.nil?
631       id ||= @minibuf_stack.length
632       @minibuf_stack[id] = s
633     end
634
635     if new_id
636       draw_screen :refresh => true
637     else
638       draw_minibuf :refresh => true
639     end
640
641     if block_given?
642       begin
643         yield id
644       ensure
645         clear id
646       end
647     end
648     id
649   end
650
651   def erase_flash; @flash = nil; end
652
653   def flash s
654     @flash = s
655     draw_screen :refresh => true
656   end
657
658   ## a little tricky because we can't just delete_at id because ids
659   ## are relative (they're positions into the array).
660   def clear id
661     @minibuf_mutex.synchronize do
662       @minibuf_stack[id] = nil
663       if id == @minibuf_stack.length - 1
664         id.downto(0) do |i|
665           break if @minibuf_stack[i]
666           @minibuf_stack.delete_at i
667         end
668       end
669     end
670
671     draw_screen :refresh => true
672   end
673
674   def shell_out command
675     @shelled = true
676     Ncurses.sync do
677       Ncurses.endwin
678       system command
679       Ncurses.refresh
680       Ncurses.curs_set 0
681     end
682     @shelled = false
683   end
684
685 private
686   def default_status_bar buf
687     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
688   end
689
690   def default_terminal_title buf
691     "Sup #{Redwood::VERSION} :: #{buf.title}"
692   end
693
694   def get_status_and_title buf
695     opts = {
696       :num_inbox => lambda { Index.num_results_for :label => :inbox },
697       :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
698       :num_total => lambda { Index.size },
699       :num_spam => lambda { Index.num_results_for :label => :spam },
700       :title => buf.title,
701       :mode => buf.mode.name,
702       :status => buf.mode.status
703     }
704
705     statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
706     term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
707     
708     [statusbar_text, term_title_text]
709   end
710
711   def users
712     unless @users
713       @users = []
714       while(u = Etc.getpwent)
715         @users << u.name
716       end
717     end
718     @users
719   end
720 end
721 end