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