sup 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTORS +84 -0
- data/Gemfile +3 -0
- data/HACKING +42 -0
- data/History.txt +361 -0
- data/LICENSE +280 -0
- data/README.md +70 -0
- data/Rakefile +12 -0
- data/ReleaseNotes +231 -0
- data/bin/sup +434 -0
- data/bin/sup-add +118 -0
- data/bin/sup-config +243 -0
- data/bin/sup-dump +43 -0
- data/bin/sup-import-dump +101 -0
- data/bin/sup-psych-ify-config-files +21 -0
- data/bin/sup-recover-sources +87 -0
- data/bin/sup-sync +210 -0
- data/bin/sup-sync-back-maildir +127 -0
- data/bin/sup-tweak-labels +140 -0
- data/contrib/colorpicker.rb +100 -0
- data/contrib/completion/_sup.zsh +114 -0
- data/devel/console.sh +3 -0
- data/devel/count-loc.sh +3 -0
- data/devel/load-index.rb +9 -0
- data/devel/profile.rb +12 -0
- data/devel/start-console.rb +5 -0
- data/doc/FAQ.txt +119 -0
- data/doc/Hooks.txt +79 -0
- data/doc/Philosophy.txt +69 -0
- data/lib/sup.rb +467 -0
- data/lib/sup/account.rb +90 -0
- data/lib/sup/buffer.rb +768 -0
- data/lib/sup/colormap.rb +239 -0
- data/lib/sup/contact.rb +67 -0
- data/lib/sup/crypto.rb +461 -0
- data/lib/sup/draft.rb +119 -0
- data/lib/sup/hook.rb +159 -0
- data/lib/sup/horizontal_selector.rb +59 -0
- data/lib/sup/idle.rb +42 -0
- data/lib/sup/index.rb +882 -0
- data/lib/sup/interactive_lock.rb +89 -0
- data/lib/sup/keymap.rb +140 -0
- data/lib/sup/label.rb +87 -0
- data/lib/sup/logger.rb +77 -0
- data/lib/sup/logger/singleton.rb +10 -0
- data/lib/sup/maildir.rb +257 -0
- data/lib/sup/mbox.rb +187 -0
- data/lib/sup/message.rb +803 -0
- data/lib/sup/message_chunks.rb +328 -0
- data/lib/sup/mode.rb +140 -0
- data/lib/sup/modes/buffer_list_mode.rb +50 -0
- data/lib/sup/modes/completion_mode.rb +55 -0
- data/lib/sup/modes/compose_mode.rb +38 -0
- data/lib/sup/modes/console_mode.rb +125 -0
- data/lib/sup/modes/contact_list_mode.rb +148 -0
- data/lib/sup/modes/edit_message_async_mode.rb +110 -0
- data/lib/sup/modes/edit_message_mode.rb +728 -0
- data/lib/sup/modes/file_browser_mode.rb +109 -0
- data/lib/sup/modes/forward_mode.rb +82 -0
- data/lib/sup/modes/help_mode.rb +19 -0
- data/lib/sup/modes/inbox_mode.rb +85 -0
- data/lib/sup/modes/label_list_mode.rb +138 -0
- data/lib/sup/modes/label_search_results_mode.rb +38 -0
- data/lib/sup/modes/line_cursor_mode.rb +203 -0
- data/lib/sup/modes/log_mode.rb +57 -0
- data/lib/sup/modes/person_search_results_mode.rb +12 -0
- data/lib/sup/modes/poll_mode.rb +19 -0
- data/lib/sup/modes/reply_mode.rb +228 -0
- data/lib/sup/modes/resume_mode.rb +52 -0
- data/lib/sup/modes/scroll_mode.rb +252 -0
- data/lib/sup/modes/search_list_mode.rb +204 -0
- data/lib/sup/modes/search_results_mode.rb +59 -0
- data/lib/sup/modes/text_mode.rb +76 -0
- data/lib/sup/modes/thread_index_mode.rb +1033 -0
- data/lib/sup/modes/thread_view_mode.rb +941 -0
- data/lib/sup/person.rb +134 -0
- data/lib/sup/poll.rb +272 -0
- data/lib/sup/rfc2047.rb +56 -0
- data/lib/sup/search.rb +110 -0
- data/lib/sup/sent.rb +58 -0
- data/lib/sup/service/label_service.rb +45 -0
- data/lib/sup/source.rb +244 -0
- data/lib/sup/tagger.rb +50 -0
- data/lib/sup/textfield.rb +253 -0
- data/lib/sup/thread.rb +452 -0
- data/lib/sup/time.rb +93 -0
- data/lib/sup/undo.rb +38 -0
- data/lib/sup/update.rb +30 -0
- data/lib/sup/util.rb +747 -0
- data/lib/sup/util/ncurses.rb +274 -0
- data/lib/sup/util/path.rb +9 -0
- data/lib/sup/util/query.rb +17 -0
- data/lib/sup/util/uri.rb +15 -0
- data/lib/sup/version.rb +3 -0
- data/sup.gemspec +53 -0
- data/test/dummy_source.rb +61 -0
- data/test/gnupg_test_home/gpg.conf +1 -0
- data/test/gnupg_test_home/pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_secring.gpg +0 -0
- data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
- data/test/gnupg_test_home/secring.gpg +0 -0
- data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
- data/test/gnupg_test_home/trustdb.gpg +0 -0
- data/test/integration/test_label_service.rb +18 -0
- data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
- data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
- data/test/messages/missing-line.eml +9 -0
- data/test/test_crypto.rb +109 -0
- data/test/test_header_parsing.rb +168 -0
- data/test/test_helper.rb +7 -0
- data/test/test_message.rb +532 -0
- data/test/test_messages_dir.rb +147 -0
- data/test/test_yaml_migration.rb +85 -0
- data/test/test_yaml_regressions.rb +17 -0
- data/test/unit/service/test_label_service.rb +19 -0
- data/test/unit/test_horizontal_selector.rb +40 -0
- data/test/unit/util/test_query.rb +46 -0
- data/test/unit/util/test_string.rb +57 -0
- data/test/unit/util/test_uri.rb +19 -0
- metadata +423 -0
data/lib/sup/account.rb
ADDED
@@ -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
|
data/lib/sup/buffer.rb
ADDED
@@ -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
|