sup 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. metadata +423 -0
@@ -0,0 +1,90 @@
1
+ module Redwood
2
+
3
+ class Account < Person
4
+ attr_accessor :sendmail, :signature, :gpgkey
5
+
6
+ def initialize h
7
+ raise ArgumentError, "no name for account" unless h[:name]
8
+ raise ArgumentError, "no email for account" unless h[:email]
9
+ super h[:name], h[:email]
10
+ @sendmail = h[:sendmail]
11
+ @signature = h[:signature]
12
+ @gpgkey = h[:gpgkey]
13
+ end
14
+
15
+ # Default sendmail command for bouncing mail,
16
+ # deduced from #sendmail
17
+ def bounce_sendmail
18
+ sendmail.sub(/\s(\-(ti|it|t))\b/) do |match|
19
+ case $1
20
+ when '-t' then ''
21
+ else ' -i'
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ class AccountManager
28
+ include Redwood::Singleton
29
+
30
+ attr_accessor :default_account
31
+
32
+ def initialize accounts
33
+ @email_map = {}
34
+ @accounts = {}
35
+ @regexen = {}
36
+ @default_account = nil
37
+
38
+ add_account accounts[:default], true
39
+ accounts.each { |k, v| add_account v, false unless k == :default }
40
+ end
41
+
42
+ def user_accounts; @accounts.keys; end
43
+ def user_emails; @email_map.keys.select { |e| String === e }; end
44
+
45
+ ## must be called first with the default account. fills in missing
46
+ ## values from the default account.
47
+ def add_account hash, default=false
48
+ raise ArgumentError, "no email specified for account" unless hash[:email]
49
+ unless default
50
+ [:name, :sendmail, :signature, :gpgkey].each { |k| hash[k] ||= @default_account.send(k) }
51
+ end
52
+ hash[:alternates] ||= []
53
+ fail "alternative emails are not an array: #{hash[:alternates]}" unless hash[:alternates].kind_of? Array
54
+
55
+ [:name, :signature].each { |x| hash[x] ? hash[x].fix_encoding! : nil }
56
+
57
+ a = Account.new hash
58
+ @accounts[a] = true
59
+
60
+ if default
61
+ raise ArgumentError, "multiple default accounts" if @default_account
62
+ @default_account = a
63
+ end
64
+
65
+ ([hash[:email]] + hash[:alternates]).each do |email|
66
+ next if @email_map.member? email
67
+ @email_map[email] = a
68
+ end
69
+
70
+ hash[:regexen].each do |re|
71
+ @regexen[Regexp.new(re)] = a
72
+ end if hash[:regexen]
73
+ end
74
+
75
+ def is_account? p; is_account_email? p.email end
76
+ def is_account_email? email; !account_for(email).nil? end
77
+ def account_for email
78
+ if(a = @email_map[email])
79
+ a
80
+ else
81
+ @regexen.argfind { |re, a| re =~ email && a }
82
+ end
83
+ end
84
+ def full_address_for email
85
+ a = account_for email
86
+ Person.full_address a.name, email
87
+ end
88
+ end
89
+
90
+ end
@@ -0,0 +1,768 @@
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
+ @name_map[realtitle] = b
334
+
335
+ @buffers.unshift b
336
+ if opts[:hidden]
337
+ focus_on b unless @focus_buf
338
+ else
339
+ raise_to_front b
340
+ end
341
+ b
342
+ end
343
+
344
+ ## requires the mode to have #done? and #value methods
345
+ def spawn_modal title, mode, opts={}
346
+ b = spawn title, mode, opts
347
+ draw_screen
348
+
349
+ until mode.done?
350
+ c = Ncurses::CharCode.get
351
+ next unless c.present? # getch timeout
352
+ break if c.is_keycode? Ncurses::KEY_CANCEL
353
+ begin
354
+ mode.handle_input c
355
+ rescue InputSequenceAborted # do nothing
356
+ end
357
+ draw_screen
358
+ erase_flash
359
+ end
360
+
361
+ kill_buffer b
362
+ mode.value
363
+ end
364
+
365
+ def kill_all_buffers_safely
366
+ until @buffers.empty?
367
+ ## inbox mode always claims it's unkillable. we'll ignore it.
368
+ return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
369
+ kill_buffer @buffers.last
370
+ end
371
+ true
372
+ end
373
+
374
+ def kill_buffer_safely buf
375
+ return false unless buf.mode.killable?
376
+ kill_buffer buf
377
+ true
378
+ end
379
+
380
+ def kill_all_buffers
381
+ kill_buffer @buffers.first until @buffers.empty?
382
+ end
383
+
384
+ def kill_buffer buf
385
+ raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf
386
+
387
+ buf.mode.cleanup
388
+ @buffers.delete buf
389
+ @name_map.delete buf.title
390
+ @focus_buf = nil if @focus_buf == buf
391
+ if @buffers.empty?
392
+ ## TODO: something intelligent here
393
+ ## for now I will simply prohibit killing the inbox buffer.
394
+ else
395
+ raise_to_front @buffers.last
396
+ end
397
+ end
398
+
399
+ def ask_with_completions domain, question, completions, default=nil
400
+ ask domain, question, default do |s|
401
+ s.fix_encoding!
402
+ completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] }
403
+ end
404
+ end
405
+
406
+ def ask_many_with_completions domain, question, completions, default=nil
407
+ ask domain, question, default do |partial|
408
+ prefix, target =
409
+ case partial
410
+ when /^\s*$/
411
+ ["", ""]
412
+ when /^(.*\s+)?(.*?)$/
413
+ [$1 || "", $2]
414
+ else
415
+ raise "william screwed up completion: #{partial.inspect}"
416
+ end
417
+
418
+ prefix.fix_encoding!
419
+ target.fix_encoding!
420
+ completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.map { |x| [prefix + x, x] }
421
+ end
422
+ end
423
+
424
+ def ask_many_emails_with_completions domain, question, completions, default=nil
425
+ ask domain, question, default do |partial|
426
+ prefix, target = partial.split_on_commas_with_remainder
427
+ target ||= prefix.pop || ""
428
+ target.fix_encoding!
429
+
430
+ prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
431
+ prefix.fix_encoding!
432
+
433
+ completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
434
+ end
435
+ end
436
+
437
+ def ask_for_filename domain, question, default=nil, allow_directory=false
438
+ answer = ask domain, question, default do |s|
439
+ if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
440
+ full = $1
441
+ name = $2.empty? ? Etc.getlogin : $2
442
+ dir = Etc.getpwnam(name).dir rescue nil
443
+ if dir
444
+ [[s.sub(full, dir), "~#{name}"]]
445
+ else
446
+ users.select { |u| u =~ /^#{Regexp::escape name}/u }.map do |u|
447
+ [s.sub("~#{name}", "~#{u}"), "~#{u}"]
448
+ end
449
+ end
450
+ else # regular filename completion
451
+ Dir["#{s}*"].sort.map do |fn|
452
+ suffix = File.directory?(fn) ? "/" : ""
453
+ [fn + suffix, File.basename(fn) + suffix]
454
+ end
455
+ end
456
+ end
457
+
458
+ if answer
459
+ answer =
460
+ if answer.empty?
461
+ spawn_modal "file browser", FileBrowserMode.new
462
+ elsif File.directory?(answer) && !allow_directory
463
+ spawn_modal "file browser", FileBrowserMode.new(answer)
464
+ else
465
+ File.expand_path answer
466
+ end
467
+ end
468
+
469
+ answer
470
+ end
471
+
472
+ ## returns an array of labels
473
+ def ask_for_labels domain, question, default_labels, forbidden_labels=[]
474
+ default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
475
+ default = default_labels.to_a.join(" ")
476
+ default += " " unless default.empty?
477
+
478
+ # here I would prefer to give more control and allow all_labels instead of
479
+ # user_defined_labels only
480
+ applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
481
+
482
+ answer = ask_many_with_completions domain, question, applyable_labels, default
483
+
484
+ return unless answer
485
+
486
+ user_labels = answer.to_set_of_symbols
487
+ user_labels.each do |l|
488
+ if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
489
+ BufferManager.flash "'#{l}' is a reserved label!"
490
+ return
491
+ end
492
+ end
493
+ user_labels
494
+ end
495
+
496
+ def ask_for_contacts domain, question, default_contacts=[]
497
+ default = default_contacts.is_a?(String) ? default_contacts : default_contacts.map { |s| s.to_s }.join(", ")
498
+ default += " " unless default.empty?
499
+
500
+ recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
501
+ contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
502
+
503
+ completions = (recent + contacts).flatten.uniq
504
+ completions += HookManager.run("extra-contact-addresses") || []
505
+
506
+ answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
507
+
508
+ if answer
509
+ answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
510
+ end
511
+ end
512
+
513
+ def ask_for_account domain, question
514
+ completions = AccountManager.user_emails
515
+ answer = BufferManager.ask_many_emails_with_completions domain, question, completions, ""
516
+ answer = AccountManager.default_account.email if answer == ""
517
+ AccountManager.account_for Person.from_address(answer).email if answer
518
+ end
519
+
520
+ ## for simplicitly, we always place the question at the very bottom of the
521
+ ## screen
522
+ def ask domain, question, default=nil, &block
523
+ raise "impossible!" if @asking
524
+ raise "Question too long" if Ncurses.cols <= question.length
525
+ @asking = true
526
+
527
+ @textfields[domain] ||= TextField.new
528
+ tf = @textfields[domain]
529
+ completion_buf = nil
530
+
531
+ status, title = get_status_and_title @focus_buf
532
+
533
+ Ncurses.sync do
534
+ tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
535
+ @dirty = true # for some reason that blanks the whole fucking screen
536
+ draw_screen :sync => false, :status => status, :title => title
537
+ tf.position_cursor
538
+ Ncurses.refresh
539
+ end
540
+
541
+ while true
542
+ c = Ncurses::CharCode.get
543
+ next unless c.present? # getch timeout
544
+ break unless tf.handle_input c # process keystroke
545
+
546
+ if tf.new_completions?
547
+ kill_buffer completion_buf if completion_buf
548
+
549
+ shorts = tf.completions.map { |full, short| short }
550
+ prefix_len = shorts.shared_prefix(caseless=true).length
551
+
552
+ mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
553
+ completion_buf = spawn "<completions>", mode, :height => 10
554
+
555
+ draw_screen :skip_minibuf => true
556
+ tf.position_cursor
557
+ elsif tf.roll_completions?
558
+ completion_buf.mode.roll
559
+ draw_screen :skip_minibuf => true
560
+ tf.position_cursor
561
+ end
562
+
563
+ Ncurses.sync { Ncurses.refresh }
564
+ end
565
+
566
+ kill_buffer completion_buf if completion_buf
567
+
568
+ @dirty = true
569
+ @asking = false
570
+ Ncurses.sync do
571
+ tf.deactivate
572
+ draw_screen :sync => false, :status => status, :title => title
573
+ end
574
+ tf.value.tap { |x| x }
575
+ end
576
+
577
+ def ask_getch question, accept=nil
578
+ raise "impossible!" if @asking
579
+
580
+ accept = accept.split(//).map { |x| x.ord } if accept
581
+
582
+ status, title = get_status_and_title @focus_buf
583
+ Ncurses.sync do
584
+ draw_screen :sync => false, :status => status, :title => title
585
+ Ncurses.mvaddstr Ncurses.rows - 1, 0, question
586
+ Ncurses.move Ncurses.rows - 1, question.length + 1
587
+ Ncurses.curs_set 1
588
+ Ncurses.refresh
589
+ end
590
+
591
+ @asking = true
592
+ ret = nil
593
+ done = false
594
+ until done
595
+ key = Ncurses::CharCode.get
596
+ next if key.empty?
597
+ if key.is_keycode? Ncurses::KEY_CANCEL
598
+ done = true
599
+ elsif accept.nil? || accept.empty? || accept.member?(key.code)
600
+ ret = key
601
+ done = true
602
+ end
603
+ end
604
+
605
+ @asking = false
606
+ Ncurses.sync do
607
+ Ncurses.curs_set 0
608
+ draw_screen :sync => false, :status => status, :title => title
609
+ end
610
+
611
+ ret
612
+ end
613
+
614
+ ## returns true (y), false (n), or nil (ctrl-g / cancel)
615
+ def ask_yes_or_no question
616
+ case(r = ask_getch question, "ynYN")
617
+ when ?y, ?Y
618
+ true
619
+ when nil
620
+ nil
621
+ else
622
+ false
623
+ end
624
+ end
625
+
626
+ ## turns an input keystroke into an action symbol. returns the action
627
+ ## if found, nil if not found, and throws InputSequenceAborted if
628
+ ## the user aborted a multi-key sequence. (Because each of those cases
629
+ ## should be handled differently.)
630
+ ##
631
+ ## this is in BufferManager because multi-key sequences require prompting.
632
+ def resolve_input_with_keymap c, keymap
633
+ action, text = keymap.action_for c
634
+ while action.is_a? Keymap # multi-key commands, prompt
635
+ key = BufferManager.ask_getch text
636
+ unless key # user canceled, abort
637
+ erase_flash
638
+ raise InputSequenceAborted
639
+ end
640
+ action, text = action.action_for(key) if action.has_key?(key)
641
+ end
642
+ action
643
+ end
644
+
645
+ def minibuf_lines
646
+ @minibuf_mutex.synchronize do
647
+ [(@flash ? 1 : 0) +
648
+ (@asking ? 1 : 0) +
649
+ @minibuf_stack.compact.size, 1].max
650
+ end
651
+ end
652
+
653
+ def draw_minibuf opts={}
654
+ m = nil
655
+ @minibuf_mutex.synchronize do
656
+ m = @minibuf_stack.compact
657
+ m << @flash if @flash
658
+ m << "" if m.empty? unless @asking # to clear it
659
+ end
660
+
661
+ Ncurses.mutex.lock unless opts[:sync] == false
662
+ Ncurses.attrset Colormap.color_for(:text_color)
663
+ adj = @asking ? 2 : 1
664
+ m.each_with_index do |s, i|
665
+ Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
666
+ end
667
+ Ncurses.refresh if opts[:refresh]
668
+ Ncurses.mutex.unlock unless opts[:sync] == false
669
+ end
670
+
671
+ def say s, id=nil
672
+ new_id = nil
673
+
674
+ @minibuf_mutex.synchronize do
675
+ new_id = id.nil?
676
+ id ||= @minibuf_stack.length
677
+ @minibuf_stack[id] = s
678
+ end
679
+
680
+ if new_id
681
+ draw_screen :refresh => true
682
+ else
683
+ draw_minibuf :refresh => true
684
+ end
685
+
686
+ if block_given?
687
+ begin
688
+ yield id
689
+ ensure
690
+ clear id
691
+ end
692
+ end
693
+ id
694
+ end
695
+
696
+ def erase_flash; @flash = nil; end
697
+
698
+ def flash s
699
+ @flash = s
700
+ draw_screen :refresh => true
701
+ end
702
+
703
+ ## a little tricky because we can't just delete_at id because ids
704
+ ## are relative (they're positions into the array).
705
+ def clear id
706
+ @minibuf_mutex.synchronize do
707
+ @minibuf_stack[id] = nil
708
+ if id == @minibuf_stack.length - 1
709
+ id.downto(0) do |i|
710
+ break if @minibuf_stack[i]
711
+ @minibuf_stack.delete_at i
712
+ end
713
+ end
714
+ end
715
+
716
+ draw_screen :refresh => true
717
+ end
718
+
719
+ def shell_out command
720
+ @shelled = true
721
+ Ncurses.sync do
722
+ Ncurses.endwin
723
+ system command
724
+ Ncurses.stdscr.keypad 1
725
+ Ncurses.refresh
726
+ Ncurses.curs_set 0
727
+ end
728
+ @shelled = false
729
+ end
730
+
731
+ private
732
+
733
+ def default_status_bar buf
734
+ " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}"
735
+ end
736
+
737
+ def default_terminal_title buf
738
+ "Sup #{Redwood::VERSION} :: #{buf.title}"
739
+ end
740
+
741
+ def get_status_and_title buf
742
+ opts = {
743
+ :num_inbox => lambda { Index.num_results_for :label => :inbox },
744
+ :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
745
+ :num_total => lambda { Index.size },
746
+ :num_spam => lambda { Index.num_results_for :label => :spam },
747
+ :title => buf.title,
748
+ :mode => buf.mode.name,
749
+ :status => buf.mode.status
750
+ }
751
+
752
+ statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
753
+ term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)
754
+
755
+ [statusbar_text, term_title_text]
756
+ end
757
+
758
+ def users
759
+ unless @users
760
+ @users = []
761
+ while(u = Etc.getpwent)
762
+ @users << u.name
763
+ end
764
+ end
765
+ @users
766
+ end
767
+ end
768
+ end