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