]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
L now prompts for label rather than immediately spawning label-select-mode
[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   def ask_with_completions domain, question, completions, default=nil
330     ask domain, question, default do |s|
331       completions.select { |x| x =~ /^#{s}/i }.map { |x| [x, x] }
332     end
333   end
334
335   ## returns an ARRAY of filenames!
336   def ask_for_filenames domain, question, default=nil
337     answer = ask domain, question, default do |s|
338       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
339         full = $1
340         name = $2.empty? ? Etc.getlogin : $2
341         dir = Etc.getpwnam(name).dir rescue nil
342         if dir
343           [[s.sub(full, dir), "~#{name}"]]
344         else
345           users.select { |u| u =~ /^#{name}/ }.map do |u|
346             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
347           end
348         end
349       else # regular filename completion
350         Dir["#{s}*"].sort.map do |fn|
351           suffix = File.directory?(fn) ? "/" : ""
352           [fn + suffix, File.basename(fn) + suffix]
353         end
354       end
355     end
356
357     if answer
358       answer = 
359         if answer.empty?
360           spawn_modal "file browser", FileBrowserMode.new
361         elsif File.directory?(answer)
362           spawn_modal "file browser", FileBrowserMode.new(answer)
363         else
364           [answer]
365         end
366     end
367
368     answer || []
369   end
370
371   def ask domain, question, default=nil, &block
372     raise "impossible!" if @asking
373     @asking = true
374
375     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
376     tf = @textfields[domain]
377     completion_buf = nil
378
379     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
380     ## jesus christ. the exact sequence of ncurses events that needs
381     ## to happen in order to display a form and have the entire screen
382     ## not disappear and have the cursor in the right place is TOO
383     ## FUCKING COMPLICATED.
384     Ncurses.sync do
385       tf.activate question, default, &block
386       @dirty = true
387       draw_screen :skip_minibuf => true, :sync => false
388     end
389
390     ret = nil
391     tf.position_cursor
392     Ncurses.sync { Ncurses.refresh }
393
394     while true
395       c = Ncurses.nonblocking_getch
396       next unless c  # getch timeout
397       break unless tf.handle_input c # process keystroke
398
399       if tf.new_completions?
400         kill_buffer completion_buf if completion_buf
401         
402         prefix_len =
403           if tf.value =~ /\/$/
404             0
405           else
406             File.basename(tf.value).length
407           end
408
409         mode = CompletionMode.new tf.completions.map { |full, short| short }, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
410         completion_buf = spawn "<completions>", mode, :height => 10
411
412         draw_screen :skip_minibuf => true
413         tf.position_cursor
414       elsif tf.roll_completions?
415         completion_buf.mode.roll
416
417         draw_screen :skip_minibuf => true
418         tf.position_cursor
419       end
420
421       Ncurses.sync { Ncurses.refresh }
422     end
423     
424     Ncurses.sync { tf.deactivate }
425     kill_buffer completion_buf if completion_buf
426     @dirty = true
427     @asking = false
428     draw_screen
429     tf.value
430   end
431
432   ## some pretty lame code in here!
433   def ask_getch question, accept=nil
434     accept = accept.split(//).map { |x| x[0] } if accept
435
436     flash question
437     Ncurses.sync do
438       Ncurses.curs_set 1
439       Ncurses.move Ncurses.rows - 1, question.length + 1
440       Ncurses.refresh
441     end
442
443     ret = nil
444     done = false
445     @shelled = true
446     until done
447       key = Ncurses.nonblocking_getch or next
448       if key == Ncurses::KEY_CANCEL
449         done = true
450       elsif (accept && accept.member?(key)) || !accept
451         ret = key
452         done = true
453       end
454     end
455
456     @shelled = false
457
458     Ncurses.sync do
459       Ncurses.curs_set 0
460       erase_flash
461       draw_screen :sync => false
462       Ncurses.curs_set 0
463     end
464
465     ret
466   end
467
468   ## returns true (y), false (n), or nil (ctrl-g / cancel)
469   def ask_yes_or_no question
470     case(r = ask_getch question, "ynYN")
471     when ?y, ?Y
472       true
473     when nil
474       nil
475     else
476       false
477     end
478   end
479
480   def minibuf_lines
481     @minibuf_mutex.synchronize do
482       [(@flash ? 1 : 0) + 
483        (@asking ? 1 : 0) +
484        @minibuf_stack.compact.size, 1].max
485     end
486   end
487   
488   def draw_minibuf opts={}
489     m = nil
490     @minibuf_mutex.synchronize do
491       m = @minibuf_stack.compact
492       m << @flash if @flash
493       m << "" if m.empty?
494     end
495
496     Ncurses.mutex.lock unless opts[:sync] == false
497     Ncurses.attrset Colormap.color_for(:none)
498     adj = @asking ? 2 : 1
499     m.each_with_index do |s, i|
500       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
501     end
502     Ncurses.refresh if opts[:refresh]
503     Ncurses.mutex.unlock unless opts[:sync] == false
504   end
505
506   def say s, id=nil
507     new_id = nil
508
509     @minibuf_mutex.synchronize do
510       new_id = id.nil?
511       id ||= @minibuf_stack.length
512       @minibuf_stack[id] = s
513     end
514
515     if new_id
516       draw_screen :refresh => true
517     else
518       draw_minibuf :refresh => true
519     end
520
521     if block_given?
522       begin
523         yield id
524       ensure
525         clear id
526       end
527     end
528     id
529   end
530
531   def erase_flash; @flash = nil; end
532
533   def flash s
534     @flash = s
535     draw_screen :refresh => true
536   end
537
538   ## a little tricky because we can't just delete_at id because ids
539   ## are relative (they're positions into the array).
540   def clear id
541     @minibuf_mutex.synchronize do
542       @minibuf_stack[id] = nil
543       if id == @minibuf_stack.length - 1
544         id.downto(0) do |i|
545           break if @minibuf_stack[i]
546           @minibuf_stack.delete_at i
547         end
548       end
549     end
550
551     draw_screen :refresh => true
552   end
553
554   def shell_out command
555     @shelled = true
556     Ncurses.sync do
557       Ncurses.endwin
558       system command
559       Ncurses.refresh
560       Ncurses.curs_set 0
561     end
562     @shelled = false
563   end
564
565 private
566
567   def users
568     unless @users
569       @users = []
570       while(u = Etc.getpwent)
571         @users << u.name
572       end
573     end
574     @users
575   end
576 end
577 end