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