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