]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
d4fe54963eed56c1340f594d1ce0cf013558519d
[sup] / lib / sup / buffer.rb
1 require 'thread'
2
3 module Ncurses
4   def rows
5     lame, lamer = [], []
6     stdscr.getmaxyx lame, lamer
7     lame.first
8   end
9
10   def cols
11     lame, lamer = [], []
12     stdscr.getmaxyx lame, lamer
13     lamer.first
14   end
15
16   def mutex; @mutex ||= Mutex.new; end
17   def sync &b; mutex.synchronize(&b); end
18
19   ## aaahhh, user input. who would have though that such a simple
20   ## idea would be SO FUCKING COMPLICATED?! because apparently
21   ## Ncurses.getch (and Curses.getch), even in cbreak mode, BLOCKS
22   ## ALL THREAD ACTIVITY. as in, no threads anywhere will run while
23   ## it's waiting for input. ok, fine, so we wrap it in a select. Of
24   ## course we also rely on Ncurses.getch to tell us when an xterm
25   ## resize has occurred, which select won't catch, so we won't
26   ## resize outselves after a sigwinch until the user hits a key.
27   ## and installing our own sigwinch handler means that the screen
28   ## size returned by getmaxyx() DOESN'T UPDATE! and Kernel#trap
29   ## RETURNS NIL as the previous handler! 
30   ##
31   ## so basically, resizing with multi-threaded ruby Ncurses
32   ## applications will always be broken.
33   ##
34   ## i've coined a new word for this: lametarded.
35   def nonblocking_getch
36     if IO.select([$stdin], nil, nil, nil)
37       Ncurses.getch
38     else
39       nil
40     end
41   end
42
43   module_function :rows, :cols, :nonblocking_getch, :mutex, :sync
44
45   KEY_CANCEL = "\a"[0] # ctrl-g
46 end
47
48 module Redwood
49
50 class Buffer
51   attr_reader :mode, :x, :y, :width, :height, :title
52   bool_reader :dirty
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     @x, @y, @width, @height = 0, 0, width, height
61   end
62
63   def content_height; @height - 1; end
64   def content_width; @width; end
65
66   def resize rows, cols 
67     return if cols == @width && rows == @height
68     @width = cols
69     @height = rows
70     @dirty = true
71     mode.resize rows, cols
72   end
73
74   def redraw
75     draw if @dirty
76     draw_status
77     commit
78   end
79
80   def mark_dirty; @dirty = true; end
81
82   def commit
83     @dirty = false
84     @w.noutrefresh
85   end
86
87   def draw
88     @mode.draw
89     draw_status
90     commit
91   end
92
93   ## s nil means a blank line!
94   def write y, x, s, opts={}
95     return if x >= @width || y >= @height
96
97     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
98     s ||= ""
99     maxl = @width - x
100     @w.mvaddstr y, x, s[0 ... maxl]
101     unless s.length >= maxl || opts[:no_fill]
102       @w.mvaddstr(y, x + s.length, " " * (maxl - s.length))
103     end
104   end
105
106   def clear
107     @w.clear
108   end
109
110   def draw_status
111     write @height - 1, 0, " [#{mode.name}] #{title}   #{mode.status}",
112       :color => :status_color
113   end
114
115   def focus
116     @focus = true
117     @dirty = true
118     @mode.focus
119   end
120
121   def blur
122     @focus = false
123     @dirty = true
124     @mode.blur
125   end
126 end
127
128 class BufferManager
129   include Singleton
130
131   attr_reader :focus_buf
132
133   def initialize
134     @name_map = {}
135     @buffers = []
136     @focus_buf = nil
137     @dirty = true
138     @minibuf_stack = []
139     @minibuf_mutex = Mutex.new
140     @textfields = {}
141     @flash = nil
142     @shelled = @asking = false
143
144     self.class.i_am_the_instance self
145   end
146
147   def buffers; @name_map.to_a; end
148
149   def focus_on buf
150     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
151     return if buf == @focus_buf 
152     @focus_buf.blur if @focus_buf
153     @focus_buf = buf
154     @focus_buf.focus
155   end
156
157   def raise_to_front buf
158     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
159     @buffers.delete buf
160     @buffers.push buf
161     focus_on buf
162     @dirty = true
163   end
164
165   def roll_buffers
166     raise_to_front @buffers.first
167   end
168
169   def roll_buffers_backwards
170     return unless @buffers.length > 1
171     raise_to_front @buffers[@buffers.length - 2]
172   end
173
174   def handle_input c
175     @focus_buf && @focus_buf.mode.handle_input(c)
176   end
177
178   def exists? n; @name_map.member? n; end
179   def [] n; @name_map[n]; end
180   def []= n, b
181     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
182     @name_map[n] = b
183   end
184
185   def completely_redraw_screen
186     return if @shelled
187
188     Ncurses.sync do
189       @dirty = true
190       Ncurses.clear
191       draw_screen :sync => false
192     end
193   end
194
195   def handle_resize
196     return if @shelled
197     rows, cols = Ncurses.rows, Ncurses.cols
198     @buffers.each { |b| b.resize rows - minibuf_lines, cols }
199     completely_redraw_screen
200     flash "Resized to #{rows}x#{cols}"
201   end
202
203   def draw_screen opts={}
204     return if @shelled
205
206     Ncurses.mutex.lock unless opts[:sync] == false
207
208     ## disabling this for the time being, to help with debugging
209     ## (currently we only have one buffer visible at a time).
210     ## TODO: reenable this if we allow multiple buffers
211     false && @buffers.inject(@dirty) do |dirty, buf|
212       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
213       @dirty ? buf.draw : buf.redraw
214     end
215
216     ## quick hack
217     if true
218       buf = @buffers.last
219       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
220       @dirty ? buf.draw : buf.redraw
221     end
222
223     draw_minibuf :sync => false unless opts[:skip_minibuf]
224     @dirty = false
225     Ncurses.doupdate
226     Ncurses.refresh if opts[:refresh]
227     Ncurses.mutex.unlock unless opts[:sync] == false
228   end
229
230   ## gets the mode from the block, which is only called if the buffer
231   ## doesn't already exist. this is useful in the case that generating
232   ## the mode is expensive, as it often is.
233   def spawn_unless_exists title, opts={}
234     if @name_map.member? title
235       raise_to_front @name_map[title] unless opts[:hidden]
236     else
237       mode = yield
238       spawn title, mode, opts
239     end
240     @name_map[title]
241   end
242
243   def spawn title, mode, opts={}
244     realtitle = title
245     num = 2
246     while @name_map.member? realtitle
247       realtitle = "#{title} <#{num}>"
248       num += 1
249     end
250
251     width = opts[:width] || Ncurses.cols
252     height = opts[:height] || Ncurses.rows - 1
253
254     ## since we are currently only doing multiple full-screen modes,
255     ## use stdscr for each window. once we become more sophisticated,
256     ## we may need to use a new Ncurses::WINDOW
257     ##
258     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
259     ## (opts[:left] || 0))
260     w = Ncurses.stdscr
261     b = Buffer.new w, mode, width, height, :title => realtitle
262     mode.buffer = b
263     @name_map[realtitle] = b
264     if opts[:hidden]
265       @buffers.unshift b
266       focus_on b unless @focus_buf
267     else
268       @buffers.push b
269       raise_to_front b
270     end
271     b
272   end
273
274   def kill_all_buffers
275     kill_buffer @buffers.first until @buffers.empty?
276   end
277
278   def kill_buffer buf
279     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
280
281     buf.mode.cleanup
282     @buffers.delete buf
283     @name_map.delete buf.title
284     @focus_buf = nil if @focus_buf == buf
285     if @buffers.empty?
286       ## TODO: something intelligent here
287       ## for now I will simply prohibit killing the inbox buffer.
288     else
289       raise_to_front @buffers.last
290     end
291   end
292
293   ## not really thread safe.
294   def ask domain, question, default=nil
295     raise "impossible!" if @asking
296
297     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
298     tf = @textfields[domain]
299
300     ## this goddamn ncurses form shit is a fucking 1970's
301     ## nightmare. jesus christ. the exact sequence of ncurses events
302     ## that needs to happen in order to display a form and have the
303     ## entire screen not disappear and have the cursor in the right
304     ## place is TOO FUCKING COMPLICATED.
305     Ncurses.sync do
306       tf.activate question, default
307       @dirty = true
308       draw_screen :skip_minibuf => true, :sync => false
309     end
310
311     ret = nil
312     tf.position_cursor
313     Ncurses.sync { Ncurses.refresh }
314
315     @asking = true
316     while tf.handle_input(Ncurses.nonblocking_getch); end
317     @asking = false
318
319     ret = tf.value
320     Ncurses.sync { tf.deactivate }
321     @dirty = true
322
323     ret
324   end
325
326   ## some pretty lame code in here!
327   def ask_getch question, accept=nil
328     accept = accept.split(//).map { |x| x[0] } if accept
329
330     flash question
331     Ncurses.sync do
332       Ncurses.curs_set 1
333       Ncurses.move Ncurses.rows - 1, question.length + 1
334       Ncurses.refresh
335     end
336
337     ret = nil
338     done = false
339     @shelled = true
340     until done
341       key = Ncurses.nonblocking_getch
342       if key == Ncurses::KEY_CANCEL
343         done = true
344       elsif (accept && accept.member?(key)) || !accept
345         ret = key
346         done = true
347       end
348     end
349
350     @shelled = false
351
352     Ncurses.sync do
353       Ncurses.curs_set 0
354       erase_flash
355       draw_screen :sync => false
356       Ncurses.curs_set 0
357     end
358
359     ret
360   end
361
362   ## returns true (y), false (n), or nil (ctrl-g / cancel)
363   def ask_yes_or_no question
364     case(r = ask_getch question, "ynYN")
365     when ?y, ?Y
366       true
367     when nil
368       nil
369     else
370       false
371     end
372   end
373
374   def minibuf_lines
375     @minibuf_mutex.synchronize do
376       [(@flash ? 1 : 0) + 
377        (@asking ? 1 : 0) +
378        @minibuf_stack.compact.size, 1].max
379     end
380   end
381   
382   def draw_minibuf opts={}
383     m = nil
384     @minibuf_mutex.synchronize do
385       m = @minibuf_stack.compact
386       m << @flash if @flash
387       m << "" if m.empty?
388     end
389
390     Ncurses.mutex.lock unless opts[:sync] == false
391     Ncurses.attrset Colormap.color_for(:none)
392     adj = @asking ? 2 : 1
393     m.each_with_index do |s, i|
394       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
395     end
396     Ncurses.refresh if opts[:refresh]
397     Ncurses.mutex.unlock unless opts[:sync] == false
398   end
399
400   def say s, id=nil
401     new_id = nil
402
403     @minibuf_mutex.synchronize do
404       new_id = id.nil?
405       id ||= @minibuf_stack.length
406       @minibuf_stack[id] = s
407     end
408
409     if new_id
410       draw_screen :refresh => true
411     else
412       draw_minibuf :refresh => true
413     end
414
415     if block_given?
416       begin
417         yield id
418       ensure
419         clear id
420       end
421     end
422     id
423   end
424
425   def erase_flash; @flash = nil; end
426
427   def flash s
428     @flash = s
429     draw_screen :refresh => true
430   end
431
432   ## a little tricky because we can't just delete_at id because ids
433   ## are relative (they're positions into the array).
434   def clear id
435     @minibuf_mutex.synchronize do
436       @minibuf_stack[id] = nil
437       if id == @minibuf_stack.length - 1
438         id.downto(0) do |i|
439           break if @minibuf_stack[i]
440           @minibuf_stack.delete_at i
441         end
442       end
443     end
444
445     draw_screen :refresh => true
446   end
447
448   def shell_out command
449     @shelled = true
450     Ncurses.sync do
451       Ncurses.endwin
452       system command
453       Ncurses.refresh
454       Ncurses.curs_set 0
455     end
456     @shelled = false
457   end
458 end
459 end