]> git.notmuchmail.org Git - sup/blob - lib/sup/buffer.rb
5642727884c1a94dd4bfd0531c34202ed762ae9b
[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     @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 handle_resize
213     return if @shelled
214     rows, cols = Ncurses.rows, Ncurses.cols
215     @buffers.each { |b| b.resize rows - minibuf_lines, cols }
216     completely_redraw_screen
217     flash "Resized to #{rows}x#{cols}"
218   end
219
220   def draw_screen opts={}
221     return if @shelled
222
223     Ncurses.mutex.lock unless opts[:sync] == false
224
225     ## disabling this for the time being, to help with debugging
226     ## (currently we only have one buffer visible at a time).
227     ## TODO: reenable this if we allow multiple buffers
228     false && @buffers.inject(@dirty) do |dirty, buf|
229       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
230       @dirty ? buf.draw : buf.redraw
231     end
232
233     ## quick hack
234     if true
235       buf = @buffers.last
236       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
237       @dirty ? buf.draw : buf.redraw
238     end
239
240     draw_minibuf :sync => false unless opts[:skip_minibuf]
241     @dirty = false
242     Ncurses.doupdate
243     Ncurses.refresh if opts[:refresh]
244     Ncurses.mutex.unlock unless opts[:sync] == false
245   end
246
247   ## gets the mode from the block, which is only called if the buffer
248   ## doesn't already exist. this is useful in the case that generating
249   ## the mode is expensive, as it often is.
250   def spawn_unless_exists title, opts={}
251     if @name_map.member? title
252       raise_to_front @name_map[title] unless opts[:hidden]
253     else
254       mode = yield
255       spawn title, mode, opts
256     end
257     @name_map[title]
258   end
259
260   def spawn title, mode, opts={}
261     realtitle = title
262     num = 2
263     while @name_map.member? realtitle
264       realtitle = "#{title} <#{num}>"
265       num += 1
266     end
267
268     width = opts[:width] || Ncurses.cols
269     height = opts[:height] || Ncurses.rows - 1
270
271     ## since we are currently only doing multiple full-screen modes,
272     ## use stdscr for each window. once we become more sophisticated,
273     ## we may need to use a new Ncurses::WINDOW
274     ##
275     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
276     ## (opts[:left] || 0))
277     w = Ncurses.stdscr
278     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
279     mode.buffer = b
280     @name_map[realtitle] = b
281
282     @buffers.unshift b
283     if opts[:hidden]
284       focus_on b unless @focus_buf
285     else
286       raise_to_front b
287     end
288     b
289   end
290
291   def kill_all_buffers_safely
292     until @buffers.empty?
293       ## inbox mode always claims it's unkillable. we'll ignore it.
294       return false unless @buffers.first.mode.is_a?(InboxMode) || @buffers.first.mode.killable?
295       kill_buffer @buffers.first
296     end
297     true
298   end
299
300   def kill_buffer_safely buf
301     return false unless buf.mode.killable?
302     kill_buffer buf
303     true
304   end
305
306   def kill_all_buffers
307     kill_buffer @buffers.first until @buffers.empty?
308   end
309
310   def kill_buffer buf
311     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
312
313     buf.mode.cleanup
314     @buffers.delete buf
315     @name_map.delete buf.title
316     @focus_buf = nil if @focus_buf == buf
317     if @buffers.empty?
318       ## TODO: something intelligent here
319       ## for now I will simply prohibit killing the inbox buffer.
320     else
321       raise_to_front @buffers.last
322     end
323   end
324
325   ## not really thread safe.
326   def ask domain, question, default=nil
327     raise "impossible!" if @asking
328
329     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
330     tf = @textfields[domain]
331
332     ## this goddamn ncurses form shit is a fucking 1970's
333     ## nightmare. jesus christ. the exact sequence of ncurses events
334     ## that needs to happen in order to display a form and have the
335     ## entire screen not disappear and have the cursor in the right
336     ## place is TOO FUCKING COMPLICATED.
337     Ncurses.sync do
338       tf.activate question, default
339       @dirty = true
340       draw_screen :skip_minibuf => true, :sync => false
341     end
342
343     ret = nil
344     tf.position_cursor
345     Ncurses.sync { Ncurses.refresh }
346
347     @asking = true
348     while tf.handle_input(Ncurses.nonblocking_getch); end
349     @asking = false
350
351     ret = tf.value
352     Ncurses.sync { tf.deactivate }
353     @dirty = true
354
355     ret
356   end
357
358   ## some pretty lame code in here!
359   def ask_getch question, accept=nil
360     accept = accept.split(//).map { |x| x[0] } if accept
361
362     flash question
363     Ncurses.sync do
364       Ncurses.curs_set 1
365       Ncurses.move Ncurses.rows - 1, question.length + 1
366       Ncurses.refresh
367     end
368
369     ret = nil
370     done = false
371     @shelled = true
372     until done
373       key = Ncurses.nonblocking_getch
374       if key == Ncurses::KEY_CANCEL
375         done = true
376       elsif (accept && accept.member?(key)) || !accept
377         ret = key
378         done = true
379       end
380     end
381
382     @shelled = false
383
384     Ncurses.sync do
385       Ncurses.curs_set 0
386       erase_flash
387       draw_screen :sync => false
388       Ncurses.curs_set 0
389     end
390
391     ret
392   end
393
394   ## returns true (y), false (n), or nil (ctrl-g / cancel)
395   def ask_yes_or_no question
396     case(r = ask_getch question, "ynYN")
397     when ?y, ?Y
398       true
399     when nil
400       nil
401     else
402       false
403     end
404   end
405
406   def minibuf_lines
407     @minibuf_mutex.synchronize do
408       [(@flash ? 1 : 0) + 
409        (@asking ? 1 : 0) +
410        @minibuf_stack.compact.size, 1].max
411     end
412   end
413   
414   def draw_minibuf opts={}
415     m = nil
416     @minibuf_mutex.synchronize do
417       m = @minibuf_stack.compact
418       m << @flash if @flash
419       m << "" if m.empty?
420     end
421
422     Ncurses.mutex.lock unless opts[:sync] == false
423     Ncurses.attrset Colormap.color_for(:none)
424     adj = @asking ? 2 : 1
425     m.each_with_index do |s, i|
426       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
427     end
428     Ncurses.refresh if opts[:refresh]
429     Ncurses.mutex.unlock unless opts[:sync] == false
430   end
431
432   def say s, id=nil
433     new_id = nil
434
435     @minibuf_mutex.synchronize do
436       new_id = id.nil?
437       id ||= @minibuf_stack.length
438       @minibuf_stack[id] = s
439     end
440
441     if new_id
442       draw_screen :refresh => true
443     else
444       draw_minibuf :refresh => true
445     end
446
447     if block_given?
448       begin
449         yield id
450       ensure
451         clear id
452       end
453     end
454     id
455   end
456
457   def erase_flash; @flash = nil; end
458
459   def flash s
460     @flash = s
461     draw_screen :refresh => true
462   end
463
464   ## a little tricky because we can't just delete_at id because ids
465   ## are relative (they're positions into the array).
466   def clear id
467     @minibuf_mutex.synchronize do
468       @minibuf_stack[id] = nil
469       if id == @minibuf_stack.length - 1
470         id.downto(0) do |i|
471           break if @minibuf_stack[i]
472           @minibuf_stack.delete_at i
473         end
474       end
475     end
476
477     draw_screen :refresh => true
478   end
479
480   def shell_out command
481     @shelled = true
482     Ncurses.sync do
483       Ncurses.endwin
484       system command
485       Ncurses.refresh
486       Ncurses.curs_set 0
487     end
488     @shelled = false
489   end
490 end
491 end