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