]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
to: tab completion also loads in the 10 most recent correspondents' email addresses
[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
78     draw if @dirty
79     draw_status
80     commit
81   end
82
83   def mark_dirty; @dirty = true; end
84
85   def commit
86     @dirty = false
87     @w.noutrefresh
88   end
89
90   def draw
91     @mode.draw
92     draw_status
93     commit
94   end
95
96   ## s nil means a blank line!
97   def write y, x, s, opts={}
98     return if x >= @width || y >= @height
99
100     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
101     s ||= ""
102     maxl = @width - x
103     @w.mvaddstr y, x, s[0 ... maxl]
104     unless s.length >= maxl || opts[:no_fill]
105       @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
106     end
107   end
108
109   def clear
110     @w.clear
111   end
112
113   def draw_status
114     write @height - 1, 0, " [#{mode.name}] #{title}   #{mode.status}",
115       :color => :status_color
116   end
117
118   def focus
119     @focus = true
120     @dirty = true
121     @mode.focus
122   end
123
124   def blur
125     @focus = false
126     @dirty = true
127     @mode.blur
128   end
129 end
130
131 class BufferManager
132   include Singleton
133
134   attr_reader :focus_buf
135
136   def initialize
137     @name_map = {}
138     @buffers = []
139     @focus_buf = nil
140     @dirty = true
141     @minibuf_stack = []
142     @minibuf_mutex = Mutex.new
143     @textfields = {}
144     @flash = nil
145     @shelled = @asking = false
146
147     self.class.i_am_the_instance self
148   end
149
150   def buffers; @name_map.to_a; end
151
152   def focus_on buf
153     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
154     return if buf == @focus_buf 
155     @focus_buf.blur if @focus_buf
156     @focus_buf = buf
157     @focus_buf.focus
158   end
159
160   def raise_to_front buf
161     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
162
163     @buffers.delete buf
164     if @buffers.length > 0 && @buffers.last.force_to_top?
165       @buffers.insert(-2, buf)
166     else
167       @buffers.push buf
168       focus_on buf
169     end
170     @dirty = true
171   end
172
173   ## we reset force_to_top when rolling buffers. this is so that the
174   ## human can actually still move buffers around, while still
175   ## programmatically being able to pop stuff up in the middle of
176   ## drawing a window without worrying about covering it up.
177   ##
178   ## if we ever start calling roll_buffers programmatically, we will
179   ## have to change this. but it's not clear that we will ever actually
180   ## do that.
181   def roll_buffers
182     @buffers.last.force_to_top = false
183     raise_to_front @buffers.first
184   end
185
186   def roll_buffers_backwards
187     return unless @buffers.length > 1
188     @buffers.last.force_to_top = false
189     raise_to_front @buffers[@buffers.length - 2]
190   end
191
192   def handle_input c
193     @focus_buf && @focus_buf.mode.handle_input(c)
194   end
195
196   def exists? n; @name_map.member? n; end
197   def [] n; @name_map[n]; end
198   def []= n, b
199     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
200     raise ArgumentError, "title must be a string" unless n.is_a? String
201     @name_map[n] = b
202   end
203
204   def completely_redraw_screen
205     return if @shelled
206
207     Ncurses.sync do
208       @dirty = true
209       Ncurses.clear
210       draw_screen :sync => false
211     end
212   end
213
214   def draw_screen opts={}
215     return if @shelled
216
217     Ncurses.mutex.lock unless opts[:sync] == false
218
219     ## disabling this for the time being, to help with debugging
220     ## (currently we only have one buffer visible at a time).
221     ## TODO: reenable this if we allow multiple buffers
222     false && @buffers.inject(@dirty) do |dirty, buf|
223       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
224       #dirty ? buf.draw : buf.redraw
225       buf.draw
226       dirty
227     end
228
229     ## quick hack
230     if true
231       buf = @buffers.last
232       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
233       @dirty ? buf.draw : buf.redraw
234     end
235
236     draw_minibuf :sync => false unless opts[:skip_minibuf]
237
238     @dirty = false
239     Ncurses.doupdate
240     Ncurses.refresh if opts[:refresh]
241     Ncurses.mutex.unlock unless opts[:sync] == false
242   end
243
244   ## gets the mode from the block, which is only called if the buffer
245   ## doesn't already exist. this is useful in the case that generating
246   ## the mode is expensive, as it often is.
247   def spawn_unless_exists title, opts={}
248     if @name_map.member? title
249       raise_to_front @name_map[title] unless opts[:hidden]
250     else
251       mode = yield
252       spawn title, mode, opts
253     end
254     @name_map[title]
255   end
256
257   def spawn title, mode, opts={}
258     raise ArgumentError, "title must be a string" unless title.is_a? String
259     realtitle = title
260     num = 2
261     while @name_map.member? realtitle
262       realtitle = "#{title} <#{num}>"
263       num += 1
264     end
265
266     width = opts[:width] || Ncurses.cols
267     height = opts[:height] || Ncurses.rows - 1
268
269     ## since we are currently only doing multiple full-screen modes,
270     ## use stdscr for each window. once we become more sophisticated,
271     ## we may need to use a new Ncurses::WINDOW
272     ##
273     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
274     ## (opts[:left] || 0))
275     w = Ncurses.stdscr
276     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
277     mode.buffer = b
278     @name_map[realtitle] = b
279
280     @buffers.unshift b
281     if opts[:hidden]
282       focus_on b unless @focus_buf
283     else
284       raise_to_front b
285     end
286     b
287   end
288
289   ## requires the mode to have #done? and #value methods
290   def spawn_modal title, mode, opts={}
291     b = spawn title, mode, opts
292     draw_screen
293
294     until mode.done?
295       c = Ncurses.nonblocking_getch
296       next unless c # getch timeout
297       break if c == Ncurses::KEY_CANCEL
298       mode.handle_input c
299       draw_screen
300       erase_flash
301     end
302
303     kill_buffer b
304     mode.value
305   end
306
307   def kill_all_buffers_safely
308     until @buffers.empty?
309       ## inbox mode always claims it's unkillable. we'll ignore it.
310       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
311       kill_buffer @buffers.last
312     end
313     true
314   end
315
316   def kill_buffer_safely buf
317     return false unless buf.mode.killable?
318     kill_buffer buf
319     true
320   end
321
322   def kill_all_buffers
323     kill_buffer @buffers.first until @buffers.empty?
324   end
325
326   def kill_buffer buf
327     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
328
329     buf.mode.cleanup
330     @buffers.delete buf
331     @name_map.delete buf.title
332     @focus_buf = nil if @focus_buf == buf
333     if @buffers.empty?
334       ## TODO: something intelligent here
335       ## for now I will simply prohibit killing the inbox buffer.
336     else
337       raise_to_front @buffers.last
338     end
339   end
340
341   def ask_with_completions domain, question, completions, default=nil
342     ask domain, question, default do |s|
343       completions.select { |x| x =~ /^#{s}/i }.map { |x| [x.downcase, x] }
344     end
345   end
346
347   def ask_many_with_completions domain, question, completions, default=nil, sep=" "
348     ask domain, question, default do |partial|
349       prefix, target = 
350         case partial#.gsub(/#{sep}+/, sep)
351         when /^\s*$/
352           ["", ""]
353         when /^(.+#{sep})$/
354           [$1, ""]
355         when /^(.*#{sep})?(.+?)$/
356           [$1 || "", $2]
357         else
358           raise "william screwed up completion: #{partial.inspect}"
359         end
360
361       completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
362     end
363   end
364
365   def ask_for_filename domain, question, default=nil
366     answer = ask domain, question, default do |s|
367       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
368         full = $1
369         name = $2.empty? ? Etc.getlogin : $2
370         dir = Etc.getpwnam(name).dir rescue nil
371         if dir
372           [[s.sub(full, dir), "~#{name}"]]
373         else
374           users.select { |u| u =~ /^#{name}/ }.map do |u|
375             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
376           end
377         end
378       else # regular filename completion
379         Dir["#{s}*"].sort.map do |fn|
380           suffix = File.directory?(fn) ? "/" : ""
381           [fn + suffix, File.basename(fn) + suffix]
382         end
383       end
384     end
385
386     if answer
387       answer = 
388         if answer.empty?
389           spawn_modal "file browser", FileBrowserMode.new
390         elsif File.directory?(answer)
391           spawn_modal "file browser", FileBrowserMode.new(answer)
392         else
393           answer
394         end
395     end
396
397     answer
398   end
399
400   ## returns an array of labels
401   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
402     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
403     default = default_labels.join(" ")
404     default += " " unless default.empty?
405
406     applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
407
408     answer = ask_many_with_completions domain, question, applyable_labels, default
409
410     return unless answer
411
412     user_labels = answer.split(/\s+/).map { |l| l.intern }
413     user_labels.each do |l|
414       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
415         BufferManager.flash "'#{l}' is a reserved label!"
416         return
417       end
418     end
419     user_labels
420   end
421
422   def ask_for_contacts domain, question, default_contacts=[]
423     default = default_contacts.map { |s| s.to_s }.join(" ")
424     default += " " unless default.empty?
425     
426     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.longname, c.email] }
427     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.longname, c.email] }
428
429     Redwood::log "recent: #{recent.inspect}"
430
431     completions = (recent + contacts).flatten.uniq.sort
432     answer = BufferManager.ask_many_with_completions domain, question, completions, default, /\s*,\s*/
433
434     if answer
435       answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
436     end
437   end
438
439
440   def ask domain, question, default=nil, &block
441     raise "impossible!" if @asking
442     @asking = true
443
444     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
445     tf = @textfields[domain]
446     completion_buf = nil
447
448     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
449     ## jesus christ. the exact sequence of ncurses events that needs
450     ## to happen in order to display a form and have the entire screen
451     ## not disappear and have the cursor in the right place is TOO
452     ## FUCKING COMPLICATED.
453     Ncurses.sync do
454       tf.activate question, default, &block
455       @dirty = true
456       draw_screen :skip_minibuf => true, :sync => false
457     end
458
459     ret = nil
460     tf.position_cursor
461     Ncurses.sync { Ncurses.refresh }
462
463     while true
464       c = Ncurses.nonblocking_getch
465       next unless c # getch timeout
466       break unless tf.handle_input c # process keystroke
467
468       if tf.new_completions?
469         kill_buffer completion_buf if completion_buf
470         
471         shorts = tf.completions.map { |full, short| short }
472         prefix_len = shorts.shared_prefix.length
473
474         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
475         completion_buf = spawn "<completions>", mode, :height => 10
476
477         draw_screen :skip_minibuf => true
478         tf.position_cursor
479       elsif tf.roll_completions?
480         completion_buf.mode.roll
481         draw_screen :skip_minibuf => true
482         tf.position_cursor
483       end
484
485       Ncurses.sync { Ncurses.refresh }
486     end
487     
488     Ncurses.sync { tf.deactivate }
489     kill_buffer completion_buf if completion_buf
490     @dirty = true
491     @asking = false
492     draw_screen
493     tf.value
494   end
495
496   ## some pretty lame code in here!
497   def ask_getch question, accept=nil
498     accept = accept.split(//).map { |x| x[0] } if accept
499
500     flash question
501     Ncurses.sync do
502       Ncurses.curs_set 1
503       Ncurses.move Ncurses.rows - 1, question.length + 1
504       Ncurses.refresh
505     end
506
507     ret = nil
508     done = false
509     @shelled = true
510     until done
511       key = Ncurses.nonblocking_getch or next
512       if key == Ncurses::KEY_CANCEL
513         done = true
514       elsif (accept && accept.member?(key)) || !accept
515         ret = key
516         done = true
517       end
518     end
519
520     @shelled = false
521
522     Ncurses.sync do
523       Ncurses.curs_set 0
524       erase_flash
525       draw_screen :sync => false
526       Ncurses.curs_set 0
527     end
528
529     ret
530   end
531
532   ## returns true (y), false (n), or nil (ctrl-g / cancel)
533   def ask_yes_or_no question
534     case(r = ask_getch question, "ynYN")
535     when ?y, ?Y
536       true
537     when nil
538       nil
539     else
540       false
541     end
542   end
543
544   def minibuf_lines
545     @minibuf_mutex.synchronize do
546       [(@flash ? 1 : 0) + 
547        (@asking ? 1 : 0) +
548        @minibuf_stack.compact.size, 1].max
549     end
550   end
551   
552   def draw_minibuf opts={}
553     m = nil
554     @minibuf_mutex.synchronize do
555       m = @minibuf_stack.compact
556       m << @flash if @flash
557       m << "" if m.empty?
558     end
559
560     Ncurses.mutex.lock unless opts[:sync] == false
561     Ncurses.attrset Colormap.color_for(:none)
562     adj = @asking ? 2 : 1
563     m.each_with_index do |s, i|
564       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
565     end
566     Ncurses.refresh if opts[:refresh]
567     Ncurses.mutex.unlock unless opts[:sync] == false
568   end
569
570   def say s, id=nil
571     new_id = nil
572
573     @minibuf_mutex.synchronize do
574       new_id = id.nil?
575       id ||= @minibuf_stack.length
576       @minibuf_stack[id] = s
577     end
578
579     if new_id
580       draw_screen :refresh => true
581     else
582       draw_minibuf :refresh => true
583     end
584
585     if block_given?
586       begin
587         yield id
588       ensure
589         clear id
590       end
591     end
592     id
593   end
594
595   def erase_flash; @flash = nil; end
596
597   def flash s
598     @flash = s
599     draw_screen :refresh => true
600   end
601
602   ## a little tricky because we can't just delete_at id because ids
603   ## are relative (they're positions into the array).
604   def clear id
605     @minibuf_mutex.synchronize do
606       @minibuf_stack[id] = nil
607       if id == @minibuf_stack.length - 1
608         id.downto(0) do |i|
609           break if @minibuf_stack[i]
610           @minibuf_stack.delete_at i
611         end
612       end
613     end
614
615     draw_screen :refresh => true
616   end
617
618   def shell_out command
619     @shelled = true
620     Ncurses.sync do
621       Ncurses.endwin
622       system command
623       Ncurses.refresh
624       Ncurses.curs_set 0
625     end
626     @shelled = false
627   end
628
629 private
630
631   def users
632     unless @users
633       @users = []
634       while(u = Etc.getpwent)
635         @users << u.name
636       end
637     end
638     @users
639   end
640 end
641 end