]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
ask when quitting with unsaved buffers
[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_safely
275     until @buffers.empty?
276       ## inbox mode always claims it's unkillable. we'll ignore it.
277       return false unless @buffers.first.mode.is_a?(InboxMode) || @buffers.first.mode.killable?
278       kill_buffer @buffers.first
279     end
280     true
281   end
282
283   def kill_buffer_safely buf
284     return false unless buf.mode.killable?
285     kill_buffer buf
286     true
287   end
288
289   def kill_all_buffers
290     kill_buffer @buffers.first until @buffers.empty?
291   end
292
293   def kill_buffer buf
294     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
295
296     buf.mode.cleanup
297     @buffers.delete buf
298     @name_map.delete buf.title
299     @focus_buf = nil if @focus_buf == buf
300     if @buffers.empty?
301       ## TODO: something intelligent here
302       ## for now I will simply prohibit killing the inbox buffer.
303     else
304       raise_to_front @buffers.last
305     end
306   end
307
308   ## not really thread safe.
309   def ask domain, question, default=nil
310     raise "impossible!" if @asking
311
312     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
313     tf = @textfields[domain]
314
315     ## this goddamn ncurses form shit is a fucking 1970's
316     ## nightmare. jesus christ. the exact sequence of ncurses events
317     ## that needs to happen in order to display a form and have the
318     ## entire screen not disappear and have the cursor in the right
319     ## place is TOO FUCKING COMPLICATED.
320     Ncurses.sync do
321       tf.activate question, default
322       @dirty = true
323       draw_screen :skip_minibuf => true, :sync => false
324     end
325
326     ret = nil
327     tf.position_cursor
328     Ncurses.sync { Ncurses.refresh }
329
330     @asking = true
331     while tf.handle_input(Ncurses.nonblocking_getch); end
332     @asking = false
333
334     ret = tf.value
335     Ncurses.sync { tf.deactivate }
336     @dirty = true
337
338     ret
339   end
340
341   ## some pretty lame code in here!
342   def ask_getch question, accept=nil
343     accept = accept.split(//).map { |x| x[0] } if accept
344
345     flash question
346     Ncurses.sync do
347       Ncurses.curs_set 1
348       Ncurses.move Ncurses.rows - 1, question.length + 1
349       Ncurses.refresh
350     end
351
352     ret = nil
353     done = false
354     @shelled = true
355     until done
356       key = Ncurses.nonblocking_getch
357       if key == Ncurses::KEY_CANCEL
358         done = true
359       elsif (accept && accept.member?(key)) || !accept
360         ret = key
361         done = true
362       end
363     end
364
365     @shelled = false
366
367     Ncurses.sync do
368       Ncurses.curs_set 0
369       erase_flash
370       draw_screen :sync => false
371       Ncurses.curs_set 0
372     end
373
374     ret
375   end
376
377   ## returns true (y), false (n), or nil (ctrl-g / cancel)
378   def ask_yes_or_no question
379     case(r = ask_getch question, "ynYN")
380     when ?y, ?Y
381       true
382     when nil
383       nil
384     else
385       false
386     end
387   end
388
389   def minibuf_lines
390     @minibuf_mutex.synchronize do
391       [(@flash ? 1 : 0) + 
392        (@asking ? 1 : 0) +
393        @minibuf_stack.compact.size, 1].max
394     end
395   end
396   
397   def draw_minibuf opts={}
398     m = nil
399     @minibuf_mutex.synchronize do
400       m = @minibuf_stack.compact
401       m << @flash if @flash
402       m << "" if m.empty?
403     end
404
405     Ncurses.mutex.lock unless opts[:sync] == false
406     Ncurses.attrset Colormap.color_for(:none)
407     adj = @asking ? 2 : 1
408     m.each_with_index do |s, i|
409       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
410     end
411     Ncurses.refresh if opts[:refresh]
412     Ncurses.mutex.unlock unless opts[:sync] == false
413   end
414
415   def say s, id=nil
416     new_id = nil
417
418     @minibuf_mutex.synchronize do
419       new_id = id.nil?
420       id ||= @minibuf_stack.length
421       @minibuf_stack[id] = s
422     end
423
424     if new_id
425       draw_screen :refresh => true
426     else
427       draw_minibuf :refresh => true
428     end
429
430     if block_given?
431       begin
432         yield id
433       ensure
434         clear id
435       end
436     end
437     id
438   end
439
440   def erase_flash; @flash = nil; end
441
442   def flash s
443     @flash = s
444     draw_screen :refresh => true
445   end
446
447   ## a little tricky because we can't just delete_at id because ids
448   ## are relative (they're positions into the array).
449   def clear id
450     @minibuf_mutex.synchronize do
451       @minibuf_stack[id] = nil
452       if id == @minibuf_stack.length - 1
453         id.downto(0) do |i|
454           break if @minibuf_stack[i]
455           @minibuf_stack.delete_at i
456         end
457       end
458     end
459
460     draw_screen :refresh => true
461   end
462
463   def shell_out command
464     @shelled = true
465     Ncurses.sync do
466       Ncurses.endwin
467       system command
468       Ncurses.refresh
469       Ncurses.curs_set 0
470     end
471     @shelled = false
472   end
473 end
474 end