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.
- 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
|