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