]> git.cworth.org Git - sup/blob - lib/sup/buffer.rb
fix warning about redefined constants (thanks to Nicolas Pouillard)
[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 curx
18     lame, lamer = [], []
19     stdscr.getyx lame, lamer
20     lamer.first
21   end
22
23   def mutex; @mutex ||= Mutex.new; end
24   def sync &b; mutex.synchronize(&b); end
25
26   ## magically, this stuff seems to work now. i could swear it didn't
27   ## before. hm.
28   def nonblocking_getch
29     if IO.select([$stdin], nil, nil, 1)
30       Ncurses.getch
31     else
32       nil
33     end
34   end
35
36   module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync
37
38   remove_const :KEY_ENTER
39   remove_const :KEY_CANCEL
40
41   KEY_ENTER = 10
42   KEY_CANCEL = ?\a # ctrl-g
43   KEY_TAB = 9
44 end
45
46 module Redwood
47
48 class Buffer
49   attr_reader :mode, :x, :y, :width, :height, :title
50   bool_reader :dirty
51   bool_accessor :force_to_top
52
53   def initialize window, mode, width, height, opts={}
54     @w = window
55     @mode = mode
56     @dirty = true
57     @focus = false
58     @title = opts[:title] || ""
59     @force_to_top = opts[:force_to_top] || false
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
160     @buffers.delete buf
161     if @buffers.length > 0 && @buffers.last.force_to_top?
162       @buffers.insert(-2, buf)
163     else
164       @buffers.push buf
165       focus_on buf
166     end
167     @dirty = true
168   end
169
170   ## we reset force_to_top when rolling buffers. this is so that the
171   ## human can actually still move buffers around, while still
172   ## programmatically being able to pop stuff up in the middle of
173   ## drawing a window without worrying about covering it up.
174   ##
175   ## if we ever start calling roll_buffers programmatically, we will
176   ## have to change this. but it's not clear that we will ever actually
177   ## do that.
178   def roll_buffers
179     @buffers.last.force_to_top = false
180     raise_to_front @buffers.first
181   end
182
183   def roll_buffers_backwards
184     return unless @buffers.length > 1
185     @buffers.last.force_to_top = false
186     raise_to_front @buffers[@buffers.length - 2]
187   end
188
189   def handle_input c
190     @focus_buf && @focus_buf.mode.handle_input(c)
191   end
192
193   def exists? n; @name_map.member? n; end
194   def [] n; @name_map[n]; end
195   def []= n, b
196     raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
197     raise ArgumentError, "title must be a string" unless n.is_a? String
198     @name_map[n] = b
199   end
200
201   def completely_redraw_screen
202     return if @shelled
203
204     Ncurses.sync do
205       @dirty = true
206       Ncurses.clear
207       draw_screen :sync => false
208     end
209   end
210
211   def draw_screen opts={}
212     return if @shelled
213
214     Ncurses.mutex.lock unless opts[:sync] == false
215
216     ## disabling this for the time being, to help with debugging
217     ## (currently we only have one buffer visible at a time).
218     ## TODO: reenable this if we allow multiple buffers
219     false && @buffers.inject(@dirty) do |dirty, buf|
220       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
221       #dirty ? buf.draw : buf.redraw
222       buf.draw
223       dirty
224     end
225
226     ## quick hack
227     if true
228       buf = @buffers.last
229       buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
230       @dirty ? buf.draw : buf.redraw
231     end
232
233     draw_minibuf :sync => false unless opts[:skip_minibuf]
234
235     @dirty = false
236     Ncurses.doupdate
237     Ncurses.refresh if opts[:refresh]
238     Ncurses.mutex.unlock unless opts[:sync] == false
239   end
240
241   ## gets the mode from the block, which is only called if the buffer
242   ## doesn't already exist. this is useful in the case that generating
243   ## the mode is expensive, as it often is.
244   def spawn_unless_exists title, opts={}
245     if @name_map.member? title
246       raise_to_front @name_map[title] unless opts[:hidden]
247     else
248       mode = yield
249       spawn title, mode, opts
250     end
251     @name_map[title]
252   end
253
254   def spawn title, mode, opts={}
255     raise ArgumentError, "title must be a string" unless title.is_a? String
256     realtitle = title
257     num = 2
258     while @name_map.member? realtitle
259       realtitle = "#{title} <#{num}>"
260       num += 1
261     end
262
263     width = opts[:width] || Ncurses.cols
264     height = opts[:height] || Ncurses.rows - 1
265
266     ## since we are currently only doing multiple full-screen modes,
267     ## use stdscr for each window. once we become more sophisticated,
268     ## we may need to use a new Ncurses::WINDOW
269     ##
270     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
271     ## (opts[:left] || 0))
272     w = Ncurses.stdscr
273     b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
274     mode.buffer = b
275     @name_map[realtitle] = b
276
277     @buffers.unshift b
278     if opts[:hidden]
279       focus_on b unless @focus_buf
280     else
281       raise_to_front b
282     end
283     b
284   end
285
286   ## requires the mode to have #done? and #value methods
287   def spawn_modal title, mode, opts={}
288     b = spawn title, mode, opts
289     draw_screen
290
291     until mode.done?
292       c = Ncurses.nonblocking_getch
293       next unless c # getch timeout
294       break if c == Ncurses::KEY_CANCEL
295       mode.handle_input c
296       draw_screen
297       erase_flash
298     end
299
300     kill_buffer b
301     mode.value
302   end
303
304   def kill_all_buffers_safely
305     until @buffers.empty?
306       ## inbox mode always claims it's unkillable. we'll ignore it.
307       return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
308       kill_buffer @buffers.last
309     end
310     true
311   end
312
313   def kill_buffer_safely buf
314     return false unless buf.mode.killable?
315     kill_buffer buf
316     true
317   end
318
319   def kill_all_buffers
320     kill_buffer @buffers.first until @buffers.empty?
321   end
322
323   def kill_buffer buf
324     raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf
325
326     buf.mode.cleanup
327     @buffers.delete buf
328     @name_map.delete buf.title
329     @focus_buf = nil if @focus_buf == buf
330     if @buffers.empty?
331       ## TODO: something intelligent here
332       ## for now I will simply prohibit killing the inbox buffer.
333     else
334       raise_to_front @buffers.last
335     end
336   end
337
338   def ask_with_completions domain, question, completions, default=nil
339     ask domain, question, default do |s|
340       completions.select { |x| x =~ /^#{s}/i }.map { |x| [x.downcase, x] }
341     end
342   end
343
344   def ask_many_with_completions domain, question, completions, default=nil, sep=" "
345     ask domain, question, default do |partial|
346       prefix, target = 
347         case partial.gsub(/#{sep}+/, sep)
348         when /^\s*$/
349           ["", ""]
350         when /^(.+#{sep})$/
351           [$1, ""]
352         when /^(.*#{sep})?(.+?)$/
353           [$1 || "", $2]
354         else
355           raise "william screwed up completion: #{partial.inspect}"
356         end
357
358       completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] }
359     end
360   end
361
362   ## returns an ARRAY of filenames!
363   def ask_for_filenames domain, question, default=nil
364     answer = ask domain, question, default do |s|
365       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
366         full = $1
367         name = $2.empty? ? Etc.getlogin : $2
368         dir = Etc.getpwnam(name).dir rescue nil
369         if dir
370           [[s.sub(full, dir), "~#{name}"]]
371         else
372           users.select { |u| u =~ /^#{name}/ }.map do |u|
373             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
374           end
375         end
376       else # regular filename completion
377         Dir["#{s}*"].sort.map do |fn|
378           suffix = File.directory?(fn) ? "/" : ""
379           [fn + suffix, File.basename(fn) + suffix]
380         end
381       end
382     end
383
384     if answer
385       answer = 
386         if answer.empty?
387           spawn_modal "file browser", FileBrowserMode.new
388         elsif File.directory?(answer)
389           spawn_modal "file browser", FileBrowserMode.new(answer)
390         else
391           [answer]
392         end
393     end
394
395     answer || []
396   end
397
398   ## returns an array of labels
399   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
400     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
401     default = default_labels.join(" ")
402     default += " " unless default.empty?
403
404     applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
405
406     answer = BufferManager.ask_many_with_completions domain, question, applyable_labels, default
407
408     return unless answer
409
410     user_labels = answer.split(/\s+/).map { |l| l.intern }
411     user_labels.each do |l|
412       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
413         BufferManager.flash "'#{l}' is a reserved label!"
414         return
415       end
416     end
417     user_labels
418   end
419
420
421   def ask domain, question, default=nil, &block
422     raise "impossible!" if @asking
423     @asking = true
424
425     @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
426     tf = @textfields[domain]
427     completion_buf = nil
428
429     ## this goddamn ncurses form shit is a fucking 1970's nightmare.
430     ## jesus christ. the exact sequence of ncurses events that needs
431     ## to happen in order to display a form and have the entire screen
432     ## not disappear and have the cursor in the right place is TOO
433     ## FUCKING COMPLICATED.
434     Ncurses.sync do
435       tf.activate question, default, &block
436       @dirty = true
437       draw_screen :skip_minibuf => true, :sync => false
438     end
439
440     ret = nil
441     tf.position_cursor
442     Ncurses.sync { Ncurses.refresh }
443
444     while true
445       c = Ncurses.nonblocking_getch
446       next unless c # getch timeout
447       break unless tf.handle_input c # process keystroke
448
449       if tf.new_completions?
450         kill_buffer completion_buf if completion_buf
451         
452         shorts = tf.completions.map { |full, short| short }
453         prefix_len = shorts.shared_prefix.length
454
455         mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
456         completion_buf = spawn "<completions>", mode, :height => 10
457
458         draw_screen :skip_minibuf => true
459         tf.position_cursor
460       elsif tf.roll_completions?
461         completion_buf.mode.roll
462         draw_screen :skip_minibuf => true
463         tf.position_cursor
464       end
465
466       Ncurses.sync { Ncurses.refresh }
467     end
468     
469     Ncurses.sync { tf.deactivate }
470     kill_buffer completion_buf if completion_buf
471     @dirty = true
472     @asking = false
473     draw_screen
474     tf.value
475   end
476
477   ## some pretty lame code in here!
478   def ask_getch question, accept=nil
479     accept = accept.split(//).map { |x| x[0] } if accept
480
481     flash question
482     Ncurses.sync do
483       Ncurses.curs_set 1
484       Ncurses.move Ncurses.rows - 1, question.length + 1
485       Ncurses.refresh
486     end
487
488     ret = nil
489     done = false
490     @shelled = true
491     until done
492       key = Ncurses.nonblocking_getch or next
493       if key == Ncurses::KEY_CANCEL
494         done = true
495       elsif (accept && accept.member?(key)) || !accept
496         ret = key
497         done = true
498       end
499     end
500
501     @shelled = false
502
503     Ncurses.sync do
504       Ncurses.curs_set 0
505       erase_flash
506       draw_screen :sync => false
507       Ncurses.curs_set 0
508     end
509
510     ret
511   end
512
513   ## returns true (y), false (n), or nil (ctrl-g / cancel)
514   def ask_yes_or_no question
515     case(r = ask_getch question, "ynYN")
516     when ?y, ?Y
517       true
518     when nil
519       nil
520     else
521       false
522     end
523   end
524
525   def minibuf_lines
526     @minibuf_mutex.synchronize do
527       [(@flash ? 1 : 0) + 
528        (@asking ? 1 : 0) +
529        @minibuf_stack.compact.size, 1].max
530     end
531   end
532   
533   def draw_minibuf opts={}
534     m = nil
535     @minibuf_mutex.synchronize do
536       m = @minibuf_stack.compact
537       m << @flash if @flash
538       m << "" if m.empty?
539     end
540
541     Ncurses.mutex.lock unless opts[:sync] == false
542     Ncurses.attrset Colormap.color_for(:none)
543     adj = @asking ? 2 : 1
544     m.each_with_index do |s, i|
545       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
546     end
547     Ncurses.refresh if opts[:refresh]
548     Ncurses.mutex.unlock unless opts[:sync] == false
549   end
550
551   def say s, id=nil
552     new_id = nil
553
554     @minibuf_mutex.synchronize do
555       new_id = id.nil?
556       id ||= @minibuf_stack.length
557       @minibuf_stack[id] = s
558     end
559
560     if new_id
561       draw_screen :refresh => true
562     else
563       draw_minibuf :refresh => true
564     end
565
566     if block_given?
567       begin
568         yield id
569       ensure
570         clear id
571       end
572     end
573     id
574   end
575
576   def erase_flash; @flash = nil; end
577
578   def flash s
579     @flash = s
580     draw_screen :refresh => true
581   end
582
583   ## a little tricky because we can't just delete_at id because ids
584   ## are relative (they're positions into the array).
585   def clear id
586     @minibuf_mutex.synchronize do
587       @minibuf_stack[id] = nil
588       if id == @minibuf_stack.length - 1
589         id.downto(0) do |i|
590           break if @minibuf_stack[i]
591           @minibuf_stack.delete_at i
592         end
593       end
594     end
595
596     draw_screen :refresh => true
597   end
598
599   def shell_out command
600     @shelled = true
601     Ncurses.sync do
602       Ncurses.endwin
603       system command
604       Ncurses.refresh
605       Ncurses.curs_set 0
606     end
607     @shelled = false
608   end
609
610 private
611
612   def users
613     unless @users
614       @users = []
615       while(u = Etc.getpwent)
616         @users << u.name
617       end
618     end
619     @users
620   end
621 end
622 end