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