]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
tab completion for all filename prompts
[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     all_contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.longname, c.email] }.flatten.uniq.sort
427
428     answer = BufferManager.ask_many_with_completions domain, question, all_contacts, default, /\s*,\s*/
429
430     if answer
431       answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
432     end
433   end
434
435
436   def ask domain, question, default=nil, &block
437     raise "impossible!" if @asking
438     @asking = true
439
440     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
441     tf = @textfields[domain]
442     completion_buf = nil
443
444     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
445     ## jesus christ. the exact sequence of ncurses events that needs
446     ## to happen in order to display a form and have the entire screen
447     ## not disappear and have the cursor in the right place is TOO
448     ## FUCKING COMPLICATED.
449     Ncurses.sync do
450       tf.activate question, default, &block
451       @dirty = true
452       draw_screen :skip_minibuf => true, :sync => false
453     end
454
455     ret = nil
456     tf.position_cursor
457     Ncurses.sync { Ncurses.refresh }
458
459     while true
460       c = Ncurses.nonblocking_getch
461       next unless c # getch timeout
462       break unless tf.handle_input c # process keystroke
463
464       if tf.new_completions?
465         kill_buffer completion_buf if completion_buf
466         
467         shorts = tf.completions.map { |full, short| short }
468         prefix_len = shorts.shared_prefix.length
469
470         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
471         completion_buf = spawn "<completions>", mode, :height => 10
472
473         draw_screen :skip_minibuf => true
474         tf.position_cursor
475       elsif tf.roll_completions?
476         completion_buf.mode.roll
477         draw_screen :skip_minibuf => true
478         tf.position_cursor
479       end
480
481       Ncurses.sync { Ncurses.refresh }
482     end
483     
484     Ncurses.sync { tf.deactivate }
485     kill_buffer completion_buf if completion_buf
486     @dirty = true
487     @asking = false
488     draw_screen
489     tf.value
490   end
491
492   ## some pretty lame code in here!
493   def ask_getch question, accept=nil
494     accept = accept.split(//).map { |x| x[0] } if accept
495
496     flash question
497     Ncurses.sync do
498       Ncurses.curs_set 1
499       Ncurses.move Ncurses.rows - 1, question.length + 1
500       Ncurses.refresh
501     end
502
503     ret = nil
504     done = false
505     @shelled = true
506     until done
507       key = Ncurses.nonblocking_getch or next
508       if key == Ncurses::KEY_CANCEL
509         done = true
510       elsif (accept && accept.member?(key)) || !accept
511         ret = key
512         done = true
513       end
514     end
515
516     @shelled = false
517
518     Ncurses.sync do
519       Ncurses.curs_set 0
520       erase_flash
521       draw_screen :sync => false
522       Ncurses.curs_set 0
523     end
524
525     ret
526   end
527
528   ## returns true (y), false (n), or nil (ctrl-g / cancel)
529   def ask_yes_or_no question
530     case(r = ask_getch question, "ynYN")
531     when ?y, ?Y
532       true
533     when nil
534       nil
535     else
536       false
537     end
538   end
539
540   def minibuf_lines
541     @minibuf_mutex.synchronize do
542       [(@flash ? 1 : 0) + 
543        (@asking ? 1 : 0) +
544        @minibuf_stack.compact.size, 1].max
545     end
546   end
547   
548   def draw_minibuf opts={}
549     m = nil
550     @minibuf_mutex.synchronize do
551       m = @minibuf_stack.compact
552       m << @flash if @flash
553       m << "" if m.empty?
554     end
555
556     Ncurses.mutex.lock unless opts[:sync] == false
557     Ncurses.attrset Colormap.color_for(:none)
558     adj = @asking ? 2 : 1
559     m.each_with_index do |s, i|
560       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
561     end
562     Ncurses.refresh if opts[:refresh]
563     Ncurses.mutex.unlock unless opts[:sync] == false
564   end
565
566   def say s, id=nil
567     new_id = nil
568
569     @minibuf_mutex.synchronize do
570       new_id = id.nil?
571       id ||= @minibuf_stack.length
572       @minibuf_stack[id] = s
573     end
574
575     if new_id
576       draw_screen :refresh => true
577     else
578       draw_minibuf :refresh => true
579     end
580
581     if block_given?
582       begin
583         yield id
584       ensure
585         clear id
586       end
587     end
588     id
589   end
590
591   def erase_flash; @flash = nil; end
592
593   def flash s
594     @flash = s
595     draw_screen :refresh => true
596   end
597
598   ## a little tricky because we can't just delete_at id because ids
599   ## are relative (they're positions into the array).
600   def clear id
601     @minibuf_mutex.synchronize do
602       @minibuf_stack[id] = nil
603       if id == @minibuf_stack.length - 1
604         id.downto(0) do |i|
605           break if @minibuf_stack[i]
606           @minibuf_stack.delete_at i
607         end
608       end
609     end
610
611     draw_screen :refresh => true
612   end
613
614   def shell_out command
615     @shelled = true
616     Ncurses.sync do
617       Ncurses.endwin
618       system command
619       Ncurses.refresh
620       Ncurses.curs_set 0
621     end
622     @shelled = false
623   end
624
625 private
626
627   def users
628     unless @users
629       @users = []
630       while(u = Etc.getpwent)
631         @users << u.name
632       end
633     end
634     @users
635   end
636 end
637 end