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