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