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