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