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