sup 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
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