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