summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--NEWS13
-rw-r--r--README.md3
-rw-r--r--USAGE.md (renamed from USAGE)104
-rw-r--r--alot/account.py127
-rw-r--r--alot/buffer.py3
-rw-r--r--alot/command.py232
-rw-r--r--alot/completion.py170
-rw-r--r--alot/db.py4
-rw-r--r--alot/defaults/alot.rc (renamed from data/example.full.rc)303
-rw-r--r--alot/defaults/notmuch.rc3
-rw-r--r--alot/helper.py31
-rwxr-xr-xalot/init.py29
-rw-r--r--alot/message.py22
-rw-r--r--alot/settings.py318
-rw-r--r--alot/ui.py177
-rw-r--r--alot/widgets.py88
-rw-r--r--data/example.rc3
-rwxr-xr-xsetup.py1
18 files changed, 894 insertions, 737 deletions
diff --git a/NEWS b/NEWS
index 5b56780e..a8c6373e 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,16 @@
+0.12
+
+* moved default location for config to ~/.config/alot/config
+* hooks for pre/post editing and RE/FWD quotestrings
+* recipient completion gives priority to abook of sender account
+* smarter in-string-tabcompletion
+* added ability to pipe messages/treads to custom shellcommands
+* initial searchstring configurable in configfile
+* non-blocking prompt/choice (new syntax for prompts!)
+* fix attachment saving
+
+Thanks: Ruben Pollan, Luke Macken, Justus Winter
+
0.11
This minor release is mostly bug fixes and some small features.
diff --git a/README.md b/README.md
index fca69be2..b3720cfb 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,6 @@ the `docs` directory contains their sources.
Do comment on the code or file issues! I'm curious what you think of it.
You can talk to me in #notmuch@Freenode.
-Be aware that the master branch is used only for releases and hotfixes,
-the bleeding edge version sits in branch `development`!.
-If you'd like to contribute, please make sure your patches can be applied to that branch.
Current features include:
-------------------------
diff --git a/USAGE b/USAGE.md
index e537956b..375d5ec9 100644
--- a/USAGE
+++ b/USAGE.md
@@ -6,54 +6,57 @@ to the prompt. Any commandline can be mapped by using the "MODE-maps" sections
in the config file. These are the default keymaps:
[global-maps]
- $ = flush
- : = prompt
- ; = bufferlist
@ = refresh
I = search tag:inbox AND NOT tag:killed
L = taglist
+ shift tab = bprevious
U = search tag:unread
- \ = prompt search
+ tab = bnext
+ \ = 'prompt search '
d = bclose
+ $ = flush
m = compose
- o = prompt search
+ o = 'prompt search '
q = exit
- shift tab = bprevious
- tab = bnext
-
+ ';' = bufferlist
+ colon = prompt
+
[bufferlist-maps]
- enter = openfocussed
x = closefocussed
-
+ enter = openfocussed
+
[search-maps]
+ a = toggletag inbox
& = toggletag killed
+ l = retagprompt
O = refineprompt
- a = toggletag inbox
enter = openthread
- l = retagprompt
| = refineprompt
-
+
[envelope-maps]
- a = attach
- enter = reedit
- s = prompt subject
- t = prompt to
+ a = prompt attach ~/
y = send
-
+ s = 'prompt subject '
+ t = 'prompt to '
+ enter = reedit
+
[taglist-maps]
enter = select
-
+
[thread-maps]
C = fold --all
E = unfold --all
H = toggleheaders
- P = print --all
+ P = print --thread
+ S = save --all
a = toggletag inbox
- enter = select
- f = forward
g = groupreply
+ f = forward
p = print
+ s = save
r = reply
+ enter = select
+ | = 'prompt pipeto '
Config
------
@@ -61,8 +64,13 @@ Just like offlineimap or notmuch itself, alot reads a config file in the "INI" s
It consists of some sections whose names are given in square brackets, followed by
key-value pairs that use "=" or ":" as separator, ';' and '#' are comment-prefixes.
-The default location for the config file is `~/.alot.rc`.
-You can find a complete example config in `data/example.full.rc`.
+The default location for the config file is `~/.config/alot/config`.
+You can find a complete example config with the default values in
+`alot/defaults/alot.rc`.
+
+Note that since ":" is a separator for key-value pairs you need to use "colon" to bind
+commands to ":".
+
Here is a key for the interpreted sections:
[general]
@@ -107,6 +115,8 @@ I use this for my uni-account:
draft_box = maildir:///home/pazz/mail/uoe/Drafts
signature = ~/my_uni_vcard.vcs
signature_filename = p.totzke.vcs
+ abook_command = abook --mutt-query
+
Caution: Sending mails is only supported via sendmail for now. If you want
to use a sendmail command different from `sendmail`, specify it as `sendmail_command`.
@@ -118,19 +128,27 @@ in the protocol part of the url.
The file specified by `signature` is attached to all outgoing mails from this account, optionally renamed to
`signature_filename`.
+If you specified `abook_command`, it will be used for tab completion in queries (to/from)
+and in message composition. The command will be called with your prefix as only argument
+and its output is searched for name-email pairs. The regular expression used here
+defaults to `(?P<email>.+?@.+?)\s+(?P<name>.+)`, which makes it work nicely with `abook --mutt-query`.
+You can tune this using the `abook_regexp` option (beware Commandparsers escaping semantic!).
+
+
Hooks
-----
-Before and after every command execution, alot calls this commands pre/post hook:
-Hooks are python callables with arity 4 that live in a module specified by
-`hooksfile` in the `[global]` section of your config. Per default this points to `~/.alot.py`
+Hooks are python callables that live in a module specified by
+`hooksfile` in the `[global]` section of your config. Per default this points
+to `~/.config/alot/hooks.py`.
For every command X, the callable 'pre_X' will be called before X and 'post_X' afterwards.
When a hook gets called, it receives instances of
-1. `alot.ui.UI`, the main user interface object that can prompt etc.
-2. `alot.db.DBManager`, the applications database manager
-3. `alot.account.AccountManager`, can be used to look up account info
-4. `alot.settings.config`, a configparser to access the users config
+ * ui: `alot.ui.UI`, the main user interface object that can prompt etc.
+ * dbm: `alot.db.DBManager`, the applications database manager
+ * aman: `alot.account.AccountManager`, can be used to look up account info
+ * log: `logging.Logger`, to write to logfile
+ * config: `alot.settings.config`, a configparser to access the users config
An autogenerated API doc for these can be found at http://pazz.github.com/alot/ ,
the sphinx sources live in the `docs` folder.
@@ -138,14 +156,30 @@ As an example, consider this pre-hook for the exit command,
that logs a personalized goodby message:
```python
-def pre_exit(ui, dbman, accountman, config):
- accounts = accountman.get_accounts()
+def pre_exit(aman=None, log=None, **rest):
+ accounts = aman.get_accounts()
if accounts:
- ui.logger.info('goodbye, %s!' % accounts[0].realname)
+ log.info('goodbye, %s!' % accounts[0].realname)
else:
- ui.logger.info('goodbye!')
+ log.info('goodbye!')
```
+Apart from command pre and posthooks, the following hooks will be interpreted:
+ * `reply_prefix(realname, address, timestamp, **kwargs)`
+ Is used to reformat the first indented line in a reply message.
+ Should return a string and defaults to 'Quoting %s (%s)\n' % (realname, timestamp)
+
+ * `forward_prefix(realname, address, timestamp, **kwargs)`
+ Is used to reformat the first indented line in a inline forwarded message.
+ Returns a string and defaults to 'Forwarded message from %s (%s)\n' % (realname, timestamp)
+ * `pre_edit_translate(bodytext, **kwargs)`
+ can be used to manipulate a messages bodytext before the editor is called.
+ Receives and returns a string.
+ * `post_edit_translate(bodytext, **kwargs)`
+ can be used to manipulate a messages bodytext after the editor is called
+ Receives and returns a string.
+
+
Theming
-------
diff --git a/alot/account.py b/alot/account.py
index 13bed02b..91ef5fff 100644
--- a/alot/account.py
+++ b/alot/account.py
@@ -22,16 +22,23 @@ import shlex
import subprocess
import logging
import time
+import re
import email
+import os
+from ConfigParser import SafeConfigParser
from urlparse import urlparse
+from helper import cmd_output
+import helper
-class Account:
+
+class Account(object):
"""
Datastructure that represents an email account. It manages
this account's settings, can send and store mails to
maildirs (drafts/send)
"""
+
address = None
"""this accounts main email address"""
aliases = []
@@ -41,14 +48,18 @@ class Account:
gpg_key = None
"""gpg fingerprint. CURRENTLY IGNORED"""
signature = None
- """path to a signature file to append to outgoing mails."""
+ """signature to append to outgoing mails"""
signature_filename = None
"""filename of signature file in attachment"""
+ abook = None
+ """addressbook"""
def __init__(self, address=None, aliases=None, realname=None, gpg_key=None,
- signature=None, signature_filename=None, sent_box=None,
- draft_box=None):
+ signature=None, signature_filename=None, sent_box=None,
+ draft_box=None, abook=None):
+
self.address = address
+ self.abook = abook
self.aliases = []
if aliases:
self.aliases = aliases.split(';')
@@ -141,23 +152,14 @@ class SendmailAccount(Account):
def send_mail(self, mail):
mail['Date'] = email.utils.formatdate(time.time(), True)
- # no unicode in shlex on 2.x
- args = shlex.split(self.cmd.encode('ascii'))
- try:
- proc = subprocess.Popen(args, stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE)
- out, err = proc.communicate(mail.as_string())
- except OSError, e:
- return str(e) + '. sendmail_cmd set to: %s' % self.cmd
- if proc.poll(): # returncode is not 0
- return err.strip()
- else:
- self.store_sent_mail(mail)
- return None
+ out, err = helper.pipe_to_command(self.cmd, mail.as_string())
+ if err:
+ return err + '. sendmail_cmd set to: %s' % self.cmd
+ self.store_sent_mail(mail)
+ return None
-class AccountManager:
+class AccountManager(object):
"""Easy access to all known accounts"""
allowed = ['realname',
'address',
@@ -167,6 +169,8 @@ class AccountManager:
'signature_filename',
'type',
'sendmail_command',
+ 'abook_command',
+ 'abook_regexp',
'sent_box',
'draft_box']
manditory = ['realname', 'address']
@@ -179,7 +183,19 @@ class AccountManager:
accountsections = filter(lambda s: s.startswith('account '), sections)
for s in accountsections:
options = filter(lambda x: x in self.allowed, config.options(s))
+
args = {}
+ if 'abook_command' in options:
+ cmd = config.get(s, 'abook_command').encode('ascii',
+ errors='ignore')
+ options.remove('abook_command')
+ if 'abook_regexp' in options:
+ regexp = config.get(s, 'abook_regexp')
+ options.remove('abook_regexp')
+ else:
+ regexp = None # will use default in constructor
+ args['abook'] = MatchSdtoutAddressbook(cmd, match=regexp)
+
to_set = self.manditory
for o in options:
args[o] = config.get(s, o)
@@ -212,8 +228,9 @@ class AccountManager:
:rtype: `account.Account` or None
"""
- if address in self.accountmap:
- return self.accountmap[address]
+ for myad in self.get_addresses():
+ if myad in address:
+ return self.accountmap[myad]
else:
return None
# log info
@@ -225,3 +242,71 @@ class AccountManager:
def get_addresses(self):
"""returns addresses of known accounts including all their aliases"""
return self.accountmap.keys()
+
+ def get_addressbooks(self, order=[], append_remaining=True):
+ abooks = []
+ for a in order:
+ if a:
+ if a.abook:
+ abooks.append(a.abook)
+ if append_remaining:
+ for a in self.accounts:
+ if a.abook and a.abook not in abooks:
+ abooks.append(a.abook)
+ return abooks
+
+
+class AddressBook(object):
+ def get_contacts(self):
+ return []
+
+ def lookup(self, prefix=''):
+ res = []
+ for name, email in self.get_contacts():
+ if name.startswith(prefix) or email.startswith(prefix):
+ res.append("%s <%s>" % (name, email))
+ return res
+
+
+class AbookAddressBook(AddressBook):
+ def __init__(self, config=None):
+ self.abook = SafeConfigParser()
+ if not config:
+ config = os.environ["HOME"] + "/.abook/addressbook"
+ self.abook.read(config)
+
+ def get_contacts(self):
+ res = []
+ for s in self.abook.sections():
+ if s.isdigit():
+ name = self.abook.get(s, 'name')
+ email = self.abook.get(s, 'email')
+ res.append((name, email))
+ return res
+
+
+class MatchSdtoutAddressbook(AddressBook):
+ def __init__(self, command, match=None):
+ self.command = command
+ if not match:
+ self.match = "(?P<email>.+?@.+?)\s+(?P<name>.+)"
+ else:
+ self.match = match
+
+ def get_contacts(self):
+ return self.lookup('\'\'')
+
+ def lookup(self, prefix):
+ resultstring = cmd_output('%s %s' % (self.command, prefix))
+ if not resultstring:
+ return []
+ lines = resultstring.replace('\t', ' ' * 4).splitlines()
+ res = []
+ for l in lines:
+ m = re.match(self.match, l)
+ if m:
+ info = m.groupdict()
+ email = info['email'].strip()
+ name = info['name'].strip()
+ res.append((name, email))
+ return res
diff --git a/alot/buffer.py b/alot/buffer.py
index e44db1bd..77eef99e 100644
--- a/alot/buffer.py
+++ b/alot/buffer.py
@@ -27,7 +27,7 @@ from walker import IteratorWalker
from message import decode_header
-class Buffer:
+class Buffer(object):
def __init__(self, ui, widget, name):
self.ui = ui
self.typename = name
@@ -206,6 +206,7 @@ class ThreadBuffer(Buffer):
self._build_pile(acc, reply, msg, depth + 1)
def rebuild(self):
+ self.thread.refresh()
# depth-first traversing the thread-tree, thereby
# 1) build a list of tuples (parentmsg, depth, message) in DF order
# 2) create a dict that counts no. of direct replies per message
diff --git a/alot/command.py b/alot/command.py
index af5f3308..e491d583 100644
--- a/alot/command.py
+++ b/alot/command.py
@@ -34,6 +34,7 @@ from email.message import Message
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import urwid
+from twisted.internet import reactor, defer
import buffer
import settings
@@ -49,7 +50,7 @@ from message import decode_header
from message import encode_header
-class Command:
+class Command(object):
"""base class for commands"""
def __init__(self, prehook=None, posthook=None):
self.prehook = prehook
@@ -63,11 +64,13 @@ class Command:
class ExitCommand(Command):
"""shuts the MUA down cleanly"""
+ @defer.inlineCallbacks
def apply(self, ui):
if settings.config.getboolean('general', 'bug_on_exit'):
- if not ui.choice('realy quit?', choices={'yes': ['y', 'enter'],
- 'no': ['n']}) == 'yes':
+ if (yield ui.choice('realy quit?', select='yes', cancel='no',
+ msg_position='left')) == 'no':
return
+ reactor.stop()
raise urwid.ExitMainLoop()
@@ -98,11 +101,12 @@ class SearchCommand(Command):
self.query = query
Command.__init__(self, **kwargs)
+ @defer.inlineCallbacks
def apply(self, ui):
if self.query:
if self.query == '*' and ui.current_buffer:
s = 'really search for all threads? This takes a while..'
- if not ui.choice(s) == 'yes':
+ if (yield ui.choice(s, select='yes', cancel='no')) == 'no':
return
open_searches = ui.get_buffers_of_type(buffer.SearchBuffer)
to_be_focused = None
@@ -136,11 +140,13 @@ class RefreshCommand(Command):
class ExternalCommand(Command):
"""calls external command"""
- def __init__(self, commandstring, spawn=False, refocus=True,
+ def __init__(self, commandstring, path=None, spawn=False, refocus=True,
in_thread=False, on_success=None, **kwargs):
"""
:param commandstring: the command to call
:type commandstring: str
+ :param path: a path to a file (or None)
+ :type path: str
:param spawn: run command in a new terminal
:type spawn: boolean
:param in_thread: run asynchronously, don't block alot
@@ -151,6 +157,7 @@ class ExternalCommand(Command):
:type on_success: callable
"""
self.commandstring = commandstring
+ self.path = path
self.spawn = spawn
self.refocus = refocus
self.in_thread = in_thread
@@ -170,13 +177,23 @@ class ExternalCommand(Command):
write_fd = ui.mainloop.watch_pipe(afterwards)
def thread_code(*args):
- cmd = self.commandstring
+ if self.path:
+ if '{}' in self.commandstring:
+ cmd = self.commandstring.replace('{}',
+ helper.shell_quote(self.path))
+ else:
+ cmd = '%s %s' % (self.commandstring,
+ helper.shell_quote(self.path))
+ else:
+ cmd = self.commandstring
+
if self.spawn:
cmd = '%s %s' % (settings.config.get('general',
'terminal_cmd'),
cmd)
+ cmd = cmd.encode('ascii', errors='ignore')
ui.logger.info('calling external command: %s' % cmd)
- returncode = subprocess.call(cmd, shell=True)
+ returncode = subprocess.call(shlex.split(cmd))
if returncode == 0:
os.write(write_fd, 'success')
@@ -197,9 +214,9 @@ class EditCommand(ExternalCommand):
else:
self.spawn = settings.config.getboolean('general', 'spawn_editor')
editor_cmd = settings.config.get('general', 'editor_cmd')
- cmd = editor_cmd + ' ' + self.path
- ExternalCommand.__init__(self, cmd, spawn=self.spawn,
- in_thread=self.spawn,
+
+ ExternalCommand.__init__(self, editor_cmd, path=self.path,
+ spawn=self.spawn, in_thread=self.spawn,
**kwargs)
@@ -354,6 +371,7 @@ class ComposeCommand(Command):
for key, value in headers.items():
self.mail[key] = encode_header(key, value)
+ @defer.inlineCallbacks
def apply(self, ui):
# TODO: fill with default header (per account)
# get From header
@@ -366,11 +384,13 @@ class ComposeCommand(Command):
a = accounts[0]
else:
cmpl = AccountCompleter(ui.accountman)
- fromaddress = ui.prompt(prefix='From>', completer=cmpl, tab=1)
+ fromaddress = yield ui.prompt(prefix='From>', completer=cmpl,
+ tab=1)
validaddresses = [a.address for a in accounts] + [None]
while fromaddress not in validaddresses: # TODO: not cool
ui.notify('no account for this address. (<esc> cancels)')
- fromaddress = ui.prompt(prefix='From>', completer=cmpl)
+ fromaddress = yield ui.prompt(prefix='From>',
+ completer=cmpl)
if not fromaddress:
ui.notify('canceled')
return
@@ -379,14 +399,24 @@ class ComposeCommand(Command):
#get To header
if 'To' not in self.mail:
- to = ui.prompt(prefix='To>', completer=ContactsCompleter())
+ name, addr = email.Utils.parseaddr(unicode(self.mail.get('From')))
+ a = ui.accountman.get_account_by_address(addr)
+
+ allbooks = not settings.config.getboolean('general',
+ 'complete_matching_abook_only')
+ ui.logger.debug(allbooks)
+ abooks = ui.accountman.get_addressbooks(order=[a],
+ append_remaining=allbooks)
+ ui.logger.debug(abooks)
+ to = yield ui.prompt(prefix='To>',
+ completer=ContactsCompleter(abooks))
if to == None:
ui.notify('canceled')
return
self.mail['To'] = encode_header('to', to)
if settings.config.getboolean('general', 'ask_subject') and \
not 'Subject' in self.mail:
- subject = ui.prompt(prefix='Subject>')
+ subject = yield ui.prompt(prefix='Subject>')
if subject == None:
ui.notify('canceled')
return
@@ -444,11 +474,12 @@ class RefineCommand(Command):
self.querystring = query
Command.__init__(self, **kwargs)
+ @defer.inlineCallbacks
def apply(self, ui):
if self.querystring:
if self.querystring == '*':
s = 'really search for all threads? This takes a while..'
- if not ui.choice(s) == 'yes':
+ if (yield ui.choice(s, select='yes', cancel='no')) == 'no':
return
sbuffer = ui.current_buffer
oldquery = sbuffer.querystring
@@ -488,8 +519,16 @@ class ReplyCommand(Command):
self.message = ui.current_buffer.get_selected_message()
mail = self.message.get_email()
# set body text
- mailcontent = '\nOn %s, %s wrote:\n' % (self.message.get_datestring(),
- self.message.get_author()[0])
+ name, address = self.message.get_author()
+ timestamp = self.message.get_date()
+ qf = settings.hooks.get('reply_prefix')
+ if qf:
+ quotestring = qf(name, address, timestamp,
+ ui=ui, dbm=ui.dbman, aman=ui.accountman,
+ log=ui.logger, config=settings.config)
+ else:
+ quotestring = 'Quoting %s (%s)\n' % (name, timestamp)
+ mailcontent = quotestring
for line in self.message.accumulate_body().splitlines():
mailcontent += '>' + line + '\n'
@@ -590,8 +629,16 @@ class ForwardCommand(Command):
Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8')
if self.inline: # inline mode
# set body text
- author = self.message.get_author()[0]
- mailcontent = '\nForwarded message from %s:\n' % author
+ name, address = self.message.get_author()
+ timestamp = self.message.get_date()
+ qf = settings.hooks.get('forward_prefix')
+ if qf:
+ quote = qf(name, address, timestamp,
+ ui=ui, dbm=ui.dbman, aman=ui.accountman,
+ log=ui.logger, config=settings.config)
+ else:
+ quote = 'Forwarded message from %s (%s):\n' % (name, timestamp)
+ mailcontent = quote
for line in self.message.accumulate_body().splitlines():
mailcontent += '>' + line + '\n'
@@ -660,30 +707,38 @@ class ToggleHeaderCommand(Command):
msgw.toggle_full_header()
-class PrintCommand(Command):
- def __init__(self, all=False, separately=False, confirm=True, **kwargs):
+class PipeCommand(Command):
+ def __init__(self, command, whole_thread=False, separately=False,
+ noop_msg='no command specified', confirm_msg='',
+ done_msg='done', **kwargs):
Command.__init__(self, **kwargs)
- self.all = all
+ self.cmd = command
+ self.whole_thread = whole_thread
self.separately = separately
- self.confirm = confirm
+ self.noop_msg = noop_msg
+ self.confirm_msg = confirm_msg
+ self.done_msg = done_msg
+ @defer.inlineCallbacks
def apply(self, ui):
- # get messages to print
- if self.all:
+ # abort if command unset
+ if not self.cmd:
+ ui.notify(self.noop_msg, priority='error')
+ return
+
+ # get messages to pipe
+ if self.whole_thread:
thread = ui.current_buffer.get_selected_thread()
if not thread:
return
to_print = thread.get_messages().keys()
- confirm_msg = 'print all messages in thread?'
- ok_msg = 'printed thread: %s' % str(thread)
else:
to_print = [ui.current_buffer.get_selected_message()]
- confirm_msg = 'print this message?'
- ok_msg = 'printed message: %s' % str(to_print[0])
# ask for confirmation if needed
- if self.confirm:
- if not ui.choice(confirm_msg) == 'yes':
+ if self.confirm_msg:
+ if (yield ui.choice(self.confirm_msg, select='yes',
+ cancel='no')) == 'no':
return
# prepare message sources
@@ -691,26 +746,38 @@ class PrintCommand(Command):
if not self.separately:
mailstrings = ['\n\n'.join(mailstrings)]
- # get print command
- cmd = settings.config.get('general', 'print_cmd')
- args = shlex.split(cmd.encode('ascii'))
-
- # print
- try:
- for mail in mailstrings:
- proc = subprocess.Popen(args, stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE)
- out, err = proc.communicate(mail)
- if proc.poll(): # returncode is not 0
- raise OSError(err)
- except OSError, e: # handle errors
- ui.notify(str(e), priority='error')
- return
+ # do teh monkey
+ for mail in mailstrings:
+ out, err = helper.pipe_to_command(self.cmd, mail)
+ if err:
+ ui.notify(err, priority='error')
+ return
# display 'done' message
- ui.notify(ok_msg)
+ if self.done_msg:
+ ui.notify(self.done_msg)
+
+
+class PrintCommand(PipeCommand):
+ def __init__(self, whole_thread=False, separately=False, **kwargs):
+ # get print command
+ cmd = settings.config.get('general', 'print_cmd', fallback='')
+
+ # set up notification strings
+ if whole_thread:
+ confirm_msg = 'print all messages in thread?'
+ ok_msg = 'printed thread using %s' % cmd
+ else:
+ confirm_msg = 'print selected message?'
+ ok_msg = 'printed message using %s' % cmd
+ # no print cmd set
+ noop_msg = 'no print command specified. Set "print_cmd" in the '\
+ 'global section.'
+ PipeCommand.__init__(self, cmd, whole_thread=whole_thread,
+ separately=separately,
+ noop_msg=noop_msg, confirm_msg=confirm_msg,
+ done_msg=ok_msg, **kwargs)
class SaveAttachmentCommand(Command):
@@ -719,22 +786,27 @@ class SaveAttachmentCommand(Command):
self.all = all
self.path = path
+ @defer.inlineCallbacks
def apply(self, ui):
pcomplete = completion.PathCompleter()
if self.all:
msg = ui.current_buffer.get_selected_message()
if not self.path:
- self.path = ui.prompt(prefix='save attachments to:',
+ self.path = yield ui.prompt(prefix='save attachments to:',
text=os.path.join('~', ''),
completer=pcomplete)
if self.path:
- self.path = os.path.expanduser(self.path)
- if os.path.isdir(self.path):
+ if os.path.isdir(os.path.expanduser(self.path)):
for a in msg.get_attachments():
dest = a.save(self.path)
- ui.notify('saved attachment as: %s' % dest)
+ name = a.get_filename()
+ if name:
+ ui.notify('saved %s as: %s' % (name, dest))
+ else:
+ ui.notify('saved attachment as: %s' % dest)
else:
- ui.notify('not a directory: %s' % self.path)
+ ui.notify('not a directory: %s' % self.path,
+ priority='error')
else:
ui.notify('canceled')
else: # save focussed attachment
@@ -743,12 +815,17 @@ class SaveAttachmentCommand(Command):
attachment = focus.get_attachment()
filename = attachment.get_filename()
if not self.path:
- self.path = ui.prompt(prefix='save attachment as:',
- text=os.path.join('~', filename),
- completer=pcomplete)
+ msg = 'save attachment (%s) to:' % filename
+ initialtext = os.path.join('~', filename)
+ self.path = yield ui.prompt(prefix=msg,
+ completer=pcomplete,
+ text=initialtext)
if self.path:
- attachment.save(os.path.expanduser(self.path))
- ui.notify('saved attachment as: %s' % self.path)
+ try:
+ dest = attachment.save(self.path)
+ ui.notify('saved attachment as: %s' % dest)
+ except (IOError, OSError), e:
+ ui.notify(str(e), priority='error')
else:
ui.notify('canceled')
@@ -766,9 +843,9 @@ class OpenAttachmentCommand(Command):
if handler:
path = self.attachment.save(tempfile.gettempdir())
if '%s' in handler:
- cmd = handler % path.replace(' ', '\ ')
+ cmd = handler % path
else:
- cmd = '%s %s' % (handler, path.replace(' ', '\ '))
+ cmd = '%s %s' % (handler, path)
def afterwards():
os.remove(path)
@@ -815,9 +892,9 @@ class EnvelopeEditCommand(Command):
def openEnvelopeFromTmpfile():
# This parses the input from the tempfile.
- # we do this ourselves here because we want to be able to
- # just type utf-8 encoded stuff into the tempfile and let alot worry
- # about encodings.
+ # we do this ourselves here because we want to be able to
+ # just type utf-8 encoded stuff into the tempfile and let alot
+ # worry about encodings.
# get input
f = open(tf.name)
@@ -825,15 +902,22 @@ class EnvelopeEditCommand(Command):
editor_input = f.read().decode(enc)
headertext, bodytext = editor_input.split('\n\n', 1)
+ # call post-edit translate hook
+ translate = settings.hooks.get('post_edit_translate')
+ if translate:
+ bodytext = translate(bodytext, ui=ui, dbm=ui.dbman,
+ aman=ui.accountman, log=ui.logger,
+ config=settings.config)
+
# go through multiline, utf-8 encoded headers
key = value = None
for line in headertext.splitlines():
- if re.match('\w+:', line): #new k/v pair
+ if re.match('\w+:', line): # new k/v pair
if key and value: # save old one from stack
del self.mail[key] # ensure unique values in mails
self.mail[key] = encode_header(key, value) # save
key, value = line.strip().split(':', 1) # parse new pair
- elif key and value: # append new line without key prefix to value
+ elif key and value: # append new line without key prefix
value += line
if key and value: # save last one if present
del self.mail[key]
@@ -871,6 +955,13 @@ class EnvelopeEditCommand(Command):
else:
bodytext = decode_to_unicode(self.mail)
+ # call pre-edit translate hook
+ translate = settings.hooks.get('pre_edit_translate')
+ if translate:
+ bodytext = translate(bodytext, ui=ui, dbm=ui.dbman,
+ aman=ui.accountman, log=ui.logger,
+ config=settings.config)
+
#write stuff to tempfile
tf = tempfile.NamedTemporaryFile(delete=False)
content = '%s\n\n%s' % (headertext,
@@ -902,6 +993,7 @@ class EnvelopeSetCommand(Command):
class EnvelopeSendCommand(Command):
+ @defer.inlineCallbacks
def apply(self, ui):
envelope = ui.current_buffer
mail = envelope.get_email()
@@ -921,7 +1013,8 @@ class EnvelopeSendCommand(Command):
else:
ui.notify('could not locate signature: %s' % sig,
priority='error')
- if not ui.choice('send without signature') == 'yes':
+ if (yield ui.choice('send without signature',
+ select='yes', cancel='no')) == 'no':
return
clearme = ui.notify('sending..', timeout=-1, block=False)
@@ -1002,6 +1095,7 @@ COMMANDS = {
'groupreply': (ReplyCommand, {'groupreply': True}),
'forward': (ForwardCommand, {}),
'fold': (FoldMessagesCommand, {'visible': False}),
+ 'pipeto': (PipeCommand, {}),
'print': (PrintCommand, {}),
'unfold': (FoldMessagesCommand, {'visible': True}),
'select': (ThreadSelectCommand, {}),
@@ -1050,6 +1144,7 @@ def commandfactory(cmdname, mode='global', **kwargs):
def interpret_commandline(cmdline, mode):
+ # TODO: use argparser here!
if not cmdline:
return None
logging.debug('mode:%s got commandline "%s"' % (mode, cmdline))
@@ -1120,8 +1215,11 @@ def interpret_commandline(cmdline, mode):
return commandfactory(cmd, mode=mode, path=filepath)
elif cmd == 'print':
args = [a.strip() for a in params.split()]
- return commandfactory(cmd, mode=mode, all=('--all' in args),
+ return commandfactory(cmd, mode=mode,
+ whole_thread=('--thread' in args),
separately=('--separately' in args))
+ elif cmd == 'pipeto':
+ return commandfactory(cmd, mode=mode, command=params)
elif not params and cmd in ['exit', 'flush', 'pyshell', 'taglist',
'bclose', 'compose', 'openfocussed',
diff --git a/alot/completion.py b/alot/completion.py
index 716554c3..38d1eade 100644
--- a/alot/completion.py
+++ b/alot/completion.py
@@ -19,40 +19,71 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com>
import re
import os
+import glob
+import logging
import command
-class Completer:
- def complete(self, original):
- """takes a string that's the prefix of a word,
- returns a list of suffix-strings that complete the original"""
+class Completer(object):
+ def complete(self, original, pos):
+ """returns a list of completions and cursor positions for the
+ string original from position pos on.
+
+ :param original: the complete string to complete
+ :type original: str
+ :param pos: starting position to complete from
+ :returns: a list of tuples (ctext, cpos), where ctext is the completed
+ string and cpos the cursor position in the new string
+ """
return list()
+ def relevant_part(self, original, pos, sep=' '):
+ """calculates the subword in a sep-splitted list of original
+ that pos is in"""
+ start = original.rfind(sep, 0, pos) + 1
+ end = original.find(sep, pos - 1)
+ if end == -1:
+ end = len(original)
+ return original[start:end], start, end, pos - start
+
class QueryCompleter(Completer):
"""completion for a notmuch query string"""
- # TODO: boolean connectors and braces?
- def __init__(self, dbman):
+ def __init__(self, dbman, accountman):
self.dbman = dbman
- self._contactscompleter = ContactsCompleter()
+ abooks = accountman.get_addressbooks()
+ self._contactscompleter = ContactsCompleter(abooks, addressesonly=True)
self._tagscompleter = TagsCompleter(dbman)
self.keywords = ['tag', 'from', 'to', 'subject', 'attachment',
'is', 'id', 'thread', 'folder']
- def complete(self, original):
- prefix = original.split(' ')[-1]
- m = re.search('(tag|is|to):(\w*)', prefix)
+ def complete(self, original, pos):
+ mypart, start, end, mypos = self.relevant_part(original, pos)
+ myprefix = mypart[:mypos]
+ m = re.search('(tag|is|to):(\w*)', myprefix)
if m:
cmd, params = m.groups()
+ cmdlen = len(cmd) + 1 # length of the keyword part incld colon
if cmd == 'to':
- return self._contactscompleter.complete(params)
+ localres = self._contactscompleter.complete(mypart[cmdlen:],
+ mypos - cmdlen)
else:
- return self._tagscompleter.complete(params, last=True)
+ localres = self._tagscompleter.complete(mypart[cmdlen:],
+ mypos - cmdlen)
+ resultlist = []
+ for ltxt, lpos in localres:
+ newtext = original[:start] + cmd + ':' + ltxt + original[end:]
+ newpos = start + len(cmd) + 1 + lpos
+ resultlist.append((newtext, newpos))
+ return resultlist
else:
- plen = len(prefix)
- matched = filter(lambda t: t.startswith(prefix), self.keywords)
- return [t[plen:] + ':' for t in matched]
+ matched = filter(lambda t: t.startswith(myprefix), self.keywords)
+ resultlist = []
+ for keyword in matched:
+ newprefix = original[:start] + keyword + ':'
+ resultlist.append((newprefix + original[end:], len(newprefix)))
+ return resultlist
class TagsCompleter(Completer):
@@ -61,26 +92,47 @@ class TagsCompleter(Completer):
def __init__(self, dbman):
self.dbman = dbman
- def complete(self, original, last=False):
- otags = original.split(',')
- prefix = otags[-1]
+ def complete(self, original, pos, single_tag=True):
tags = self.dbman.get_all_tags()
- if len(otags) > 1 and last:
- return []
+ if single_tag:
+ prefix = original[:pos]
+ matching = [t for t in tags if t.startswith(prefix)]
+ return [(t, len(t)) for t in matching]
else:
- matching = [t[len(prefix):] for t in tags if t.startswith(prefix)]
- if last:
- return matching
- else:
- return [t + ',' for t in matching]
+ mypart, start, end, mypos = self.relevant_part(original, pos,
+ sep=',')
+ prefix = mypart[:mypos]
+ res = []
+ for tag in tags:
+ if tag.startswith(prefix):
+ newprefix = original[:start] + tag
+ if not original[end:].startswith(','):
+ newprefix += ','
+ res.append((newprefix + original[end:], len(newprefix)))
+ return res
class ContactsCompleter(Completer):
"""completes contacts"""
+ def __init__(self, abooks, addressesonly=False):
+ self.abooks = abooks
+ self.addressesonly = addressesonly
- def complete(self, prefix):
- # TODO
- return []
+ def complete(self, original, pos):
+ if not self.abooks:
+ return []
+ prefix = original[:pos]
+ res = []
+ for abook in self.abooks:
+ res = res + abook.lookup(prefix)
+ if self.addressesonly:
+ returnlist = [(email, len(email)) for (name, email) in res]
+ else:
+ returnlist = []
+ for name, email in res:
+ newtext = "%s <%s>" % (name, email)
+ returnlist.append((newtext, len(newtext)))
+ return returnlist
class AccountCompleter(Completer):
@@ -89,9 +141,10 @@ class AccountCompleter(Completer):
def __init__(self, accountman):
self.accountman = accountman
- def complete(self, prefix):
+ def complete(self, original, pos):
valids = self.accountman.get_main_addresses()
- return [a[len(prefix):] for a in valids if a.startswith(prefix)]
+ prefix = original[:pos]
+ return [(a, len(a)) for a in valids if a.startswith(prefix)]
class CommandCompleter(Completer):
@@ -101,12 +154,14 @@ class CommandCompleter(Completer):
self.dbman = dbman
self.mode = mode
- def complete(self, original):
+ def complete(self, original, pos):
#TODO refine <tab> should get current querystring
+ commandprefix = original[:pos]
+ logging.debug('original="%s" prefix="%s"' % (original, commandprefix))
cmdlist = command.COMMANDS['global']
cmdlist.update(command.COMMANDS[self.mode])
- olen = len(original)
- return [t[olen:] + '' for t in cmdlist if t.startswith(original)]
+ matching = [t for t in cmdlist if t.startswith(commandprefix)]
+ return [(t, len(t)) for t in matching]
class CommandLineCompleter(Completer):
@@ -117,41 +172,42 @@ class CommandLineCompleter(Completer):
self.accountman = accountman
self.mode = mode
self._commandcompleter = CommandCompleter(dbman, mode)
- self._querycompleter = QueryCompleter(dbman)
+ self._querycompleter = QueryCompleter(dbman, accountman)
self._tagscompleter = TagsCompleter(dbman)
- self._contactscompleter = ContactsCompleter()
+ abooks = accountman.get_addressbooks()
+ self._contactscompleter = ContactsCompleter(abooks, addressesonly=True)
self._pathcompleter = PathCompleter()
- def complete(self, prefix):
- words = prefix.split(' ', 1)
- if len(words) <= 1: # we complete commands
- return self._commandcompleter.complete(prefix)
+ def complete(self, line, pos):
+ words = line.split(' ', 1)
+
+ res = []
+ if pos <= len(words[0]): # we complete commands
+ for cmd, cpos in self._commandcompleter.complete(line, pos):
+ newtext = ('%s %s' % (cmd, ' '.join(words[1:])))
+ res.append((newtext, cpos + 1))
else:
cmd, params = words
+ localpos = pos - (len(cmd) + 1)
if cmd in ['search', 'refine']:
- return self._querycompleter.complete(params)
+ res = self._querycompleter.complete(params, localpos)
if cmd == 'retag':
- return self._tagscompleter.complete(params)
+ res = self._tagscompleter.complete(params, localpos,
+ single_tag=False)
if cmd == 'toggletag':
- return self._tagscompleter.complete(params, last=True)
+ res = self._tagscompleter.complete(params, localpos)
if cmd in ['to', 'compose']:
- return self._contactscompleter.complete(params)
+ res = self._contactscompleter.complete(params, localpos)
if cmd in ['attach', 'edit', 'save']:
- return self._pathcompleter.complete(params)
- else:
- return []
+ res = self._pathcompleter.complete(params, localpos)
+ res = [('%s %s' % (cmd, t), p + len(cmd) + 1) for (t, p) in res]
+ return res
class PathCompleter(Completer):
"""completion for paths"""
- def complete(self, prefix):
- if not prefix:
- return ['~/']
- dir = os.path.expanduser(os.path.dirname(prefix))
- fileprefix = os.path.basename(prefix)
- res = []
- if os.path.isdir(dir):
- for f in os.listdir(dir):
- if f.startswith(fileprefix):
- res.append(f[len(fileprefix):])
- return res
+ def complete(self, original, pos):
+ if not original:
+ return [('~/', 2)]
+ prefix = os.path.expanduser(original[:pos])
+ return [(f, len(f)) for f in glob.glob(prefix + '*')]
diff --git a/alot/db.py b/alot/db.py
index 5dca9b88..b96d3841 100644
--- a/alot/db.py
+++ b/alot/db.py
@@ -38,7 +38,7 @@ class DatabaseLockedError(DatabaseError):
pass
-class DBManager:
+class DBManager(object):
"""
keeps track of your index parameters, can create notmuch.Query
objects from its Database on demand and implements a bunch of
@@ -174,7 +174,7 @@ class DBManager:
return db.create_query(querystring)
-class Thread:
+class Thread(object):
def __init__(self, dbman, thread):
"""
:param dbman: db manager that is used for further lookups
diff --git a/data/example.full.rc b/alot/defaults/alot.rc
index d31e78a5..1426c5e4 100644
--- a/data/example.full.rc
+++ b/alot/defaults/alot.rc
@@ -18,16 +18,15 @@ display_content_in_threadline = False
# headers that get displayed by default
displayed_headers = From,To,Cc,Bcc,Subject
-
# editor command
editor_cmd = /usr/bin/vim -f -c 'set filetype=mail' +
-editor_writes_encoding' = UTF-8
+editor_writes_encoding = UTF-8
# timeout in secs after a failed attempt to flush is repeated
flush_retry_timeout = 5
# where to look up hooks
-hooksfile = ~/.alot.py
+hooksfile = ~/.config/alot/hooks.py
# time in secs to display status messages
notify_timeout = 2
@@ -44,48 +43,57 @@ terminal_cmd = x-terminal-emulator -e
# http://docs.python.org/library/datetime.html#strftime-strptime-behavior
timestamp_format = ''
-#how to print messages:
-print_cmd = muttprint
+# how to print messages:
+# this specifies a shellcommand used pro printing.
+# threads/messages are piped to this as plaintext.
+# muttprint/a2ps works nicely
+print_cmd = ''
#initial searchstring when none is given as argument:
initial_searchstring = tag:inbox AND NOT tag:killed
+#initial searchstring when none is given as argument:
+initial_searchstring = tag:inbox AND NOT tag:killed
+
+# in case more than one account has an addressbook:
+# Set this to True to make tabcompletion for recipients during compose only
+# look in the abook of the account matching the sender address
+complete_matching_abook_only = False
[global-maps]
-$ = flush
-: = prompt
-; = bufferlist
@ = refresh
I = search tag:inbox AND NOT tag:killed
L = taglist
+shift tab = bprevious
U = search tag:unread
-\ = prompt search
+tab = bnext
+\ = 'prompt search '
d = bclose
+$ = flush
m = compose
-o = prompt search
+o = 'prompt search '
q = exit
-s = shell
-shift tab = bprevious
-tab = bnext
+';' = bufferlist
+colon = prompt
[bufferlist-maps]
x = closefocussed
enter = openfocussed
[search-maps]
+a = toggletag inbox
& = toggletag killed
+l = retagprompt
O = refineprompt
-a = toggletag inbox
enter = openthread
-l = retagprompt
| = refineprompt
[envelope-maps]
-enter = reedit
-a = attach
-s = prompt subject
-t = prompt to
+a = prompt attach ~/
y = send
+s = 'prompt subject '
+t = 'prompt to '
+enter = reedit
[taglist-maps]
enter = select
@@ -94,184 +102,191 @@ enter = select
C = fold --all
E = unfold --all
H = toggleheaders
-P = print --all
+P = print --thread
+S = save --all
a = toggletag inbox
-enter = select
-f = forward
g = groupreply
+f = forward
p = print
+s = save
r = reply
+enter = select
+| = 'prompt pipeto '
[command-aliases]
+quit = exit
bn = bnext
-bp = bprevious
clo = close
+bp = bprevious
ls = bufferlist
-quit = exit
-
[16c-theme]
-bufferlist_focus_bg = dark gray
bufferlist_focus_fg = white
+threadline_mailcount_focus_bg = dark cyan
+bufferlist_focus_bg = dark gray
+tag_bg = black
+message_attachment_fg = light gray
+threadline_authors_focus_fg = black,bold
+threadline_bg = default
+notify_error_fg = white
bufferlist_results_even_bg = black
+threadline_subject_fg = light gray
+message_header_value_bg = dark gray
+messagesummary_even_fg = white
+threadline_authors_focus_bg = dark cyan
+footer_fg = light green
+bufferlist_results_odd_fg = light gray
+threadline_date_fg = light gray
bufferlist_results_even_fg = light gray
+message_header_value_fg = light gray
+threadline_subject_bg = default
+messagesummary_odd_fg = white
+message_header_key_fg = white
+threadline_tags_focus_bg = dark cyan
+tag_focus_fg = white
bufferlist_results_odd_bg = black
-bufferlist_results_odd_fg = light gray
-footer_bg = dark blue
-footer_fg = light green
+threadline_fg = default
+prompt_fg = light gray
header_bg = dark blue
-header_fg = white
-message_attachment_bg = dark gray
-message_attachment_fg = light gray
-message_attachment_focussed_bg = light green
-message_attachment_focussed_fg = light gray
-message_body_bg = default
+threadline_subject_focus_bg = dark cyan
message_body_fg = light gray
-message_header_bg = dark gray
-message_header_fg = white
message_header_key_bg = dark gray
-message_header_key_fg = white
-message_header_value_bg = dark gray
-message_header_value_fg = light gray
+threadline_date_bg = default
+tag_focus_bg = dark cyan
+messagesummary_odd_bg = dark blue
+threadline_subject_focus_fg = black
+threadline_date_focus_fg = black
messagesummary_even_bg = light blue
-messagesummary_even_fg = white
+message_header_fg = white
+threadline_focus_fg = white
messagesummary_focus_bg = dark cyan
+threadline_date_focus_bg = dark cyan
+threadline_content_bg = default
+threadline_tags_bg = default
+threadline_mailcount_bg = default
messagesummary_focus_fg = white
-messagesummary_odd_bg = dark blue
-messagesummary_odd_fg = white
-notify_error_bg = dark red
-notify_error_fg = white
-notify_normal_bg = default
-notify_normal_fg = default
+message_body_bg = default
+threadline_mailcount_focus_fg = black
prompt_bg = black
-prompt_fg = light gray
-tag_bg = black
-tag_fg = brown
-tag_focus_bg = dark cyan
-tag_focus_fg = white
-threadline_authors_bg = default
-threadline_authors_fg = dark green
-threadline_authors_focus_bg = dark cyan
-threadline_authors_focus_fg = black,bold
-threadline_bg = default
-threadline_content_bg = default
+header_fg = white
threadline_content_fg = dark gray
-threadline_date_bg = default
-threadline_date_fg = light gray
-threadline_date_focus_bg = dark cyan
-threadline_date_focus_fg = black
-threadline_fg = default
threadline_focus_bg = dark cyan
-threadline_focus_fg = white
-threadline_mailcount_bg = default
threadline_mailcount_fg = light gray
-threadline_mailcount_focus_bg = dark cyan
-threadline_mailcount_focus_fg = black
-threadline_subject_bg = default
-threadline_subject_fg = light gray
-threadline_subject_focus_bg = dark cyan
-threadline_subject_focus_fg = black
-threadline_tags_bg = default
+threadline_content_focus_bg = dark cyan
+tag_fg = brown
+notify_normal_fg = default
+message_attachment_focussed_bg = light green
threadline_tags_fg = brown
-threadline_tags_focus_bg = dark cyan
+notify_error_bg = dark red
+threadline_content_focus_fg = dark gray
+footer_bg = dark blue
+threadline_authors_bg = default
threadline_tags_focus_fg = yellow,bold
+message_attachment_bg = dark gray
+notify_normal_bg = default
+message_attachment_focussed_fg = light gray
+threadline_authors_fg = dark green
+message_header_bg = dark gray
[256c-theme]
-bufferlist_focus_bg = g38
bufferlist_focus_fg = #ffa
+threadline_mailcount_focus_bg = g58
+bufferlist_focus_bg = g38
+tag_bg = default
+message_attachment_fg = light gray
+threadline_authors_focus_fg = #8f6
+threadline_bg = default
+notify_error_fg = white
bufferlist_results_even_bg = g3
+threadline_subject_fg = g58
+message_header_value_bg = dark gray
+messagesummary_even_fg = white
+threadline_authors_focus_bg = g58
+footer_fg = white
+bufferlist_results_odd_fg = default
+threadline_date_fg = g58
bufferlist_results_even_fg = default
+message_header_value_fg = light gray
+threadline_subject_bg = default
+messagesummary_odd_fg = white
+message_header_key_fg = white
+threadline_tags_focus_bg = g58
+tag_focus_fg = #ffa
bufferlist_results_odd_bg = default
-bufferlist_results_odd_fg = default
-footer_bg = #006
-footer_fg = white
+threadline_fg = default
+prompt_fg = light gray
header_bg = dark blue
-header_fg = white
-message_attachment_bg = dark gray
-message_attachment_fg = light gray
-message_attachment_focussed_bg = light green
-message_attachment_focussed_fg = light gray
-message_body_bg = default
+threadline_subject_focus_bg = g58
message_body_fg = light gray
-message_header_bg = dark gray
-message_header_fg = white
message_header_key_bg = dark gray
-message_header_key_fg = white
-message_header_value_bg = dark gray
-message_header_value_fg = light gray
+threadline_date_bg = default
+tag_focus_bg = g58
+messagesummary_odd_bg = #006
+threadline_subject_focus_fg = g89
+threadline_date_focus_fg = g89
messagesummary_even_bg = #068
-messagesummary_even_fg = white
+message_header_fg = white
+threadline_focus_fg = white
messagesummary_focus_bg = g58
+threadline_date_focus_bg = g58
+threadline_content_bg = default
+threadline_tags_bg = default
+threadline_mailcount_bg = default
messagesummary_focus_fg = #ff8
-messagesummary_odd_bg = #006
-messagesummary_odd_fg = white
-notify_error_bg = dark red
-notify_error_fg = white
-notify_normal_bg = default
-notify_normal_fg = default
+message_body_bg = default
+threadline_mailcount_focus_fg = g89
prompt_bg = default
-prompt_fg = light gray
-tag_bg = default
-tag_fg = brown
-tag_focus_bg = g58
-tag_focus_fg = #ffa
-threadline_authors_bg = default
-threadline_authors_fg = #6d6
-threadline_authors_focus_bg = g58
-threadline_authors_focus_fg = #8f6
-threadline_bg = default
-threadline_content_bg = default
+header_fg = white
threadline_content_fg = #866
-threadline_date_bg = default
-threadline_date_fg = g58
-threadline_date_focus_bg = g58
-threadline_date_focus_fg = g89
-threadline_fg = default
threadline_focus_bg = g58
-threadline_focus_fg = white
-threadline_mailcount_bg = default
threadline_mailcount_fg = light gray
-threadline_mailcount_focus_bg = g58
-threadline_mailcount_focus_fg = g89
-threadline_subject_bg = default
-threadline_subject_fg = g58
-threadline_subject_focus_bg = g58
-threadline_subject_focus_fg = g89
-threadline_tags_bg = default
+threadline_content_focus_bg = g58
+tag_fg = brown
+notify_normal_fg = default
+message_attachment_focussed_bg = light green
threadline_tags_fg = #a86
-threadline_tags_focus_bg = g58
+notify_error_bg = dark red
+threadline_content_focus_fg = #866
+footer_bg = #006
+threadline_authors_bg = default
threadline_tags_focus_fg = #ff8
+message_attachment_bg = dark gray
+notify_normal_bg = default
+message_attachment_focussed_fg = light gray
+threadline_authors_fg = #6d6
+message_header_bg = dark gray
[1c-theme]
-bufferlist_focus = standout
-bufferlist_results_even = default
-bufferlist_results_odd = default
-footer = standout
-header = standout
-message_attachment = default
-message_attachment_focussed = underline
message_body = default
message_header = default
-message_header_key = default
-message_header_value = default
-messagesummary_even =
-messagesummary_focus = standout
-messagesummary_odd =
-notify_error = standout
+prompt =
+message_attachment = default
notify_normal = default
-prompt =
-tag = default
+messagesummary_odd =
+threadline_content = default
+threadline_subject_focus = standout
+bufferlist_results_odd = default
+messagesummary_focus = standout
+message_attachment_focussed = underline
tag_focus = standout
-threadline = default
+header = standout
+tag = default
+threadline_content_focus = standout
+message_header_value = default
+threadline_mailcount_focus = standout
+threadline_date_focus = standout
+message_header_key = default
+threadline_subject = default
threadline_authors = default,underline
+bufferlist_results_even = default
threadline_authors_focus = standout
-threadline_content = default
+footer = standout
+notify_error = standout
+messagesummary_even =
+threadline_tags_focus = standout
+threadline = default
threadline_date = default
-threadline_date_focus = standout
-threadline_focus = standout
threadline_mailcount = default
-threadline_mailcount_focus = standout
-threadline_subject = default
-threadline_subject_focus = standout
+threadline_focus = standout
threadline_tags = bold
-threadline_tags_focus = standout
+bufferlist_focus = standout
diff --git a/alot/defaults/notmuch.rc b/alot/defaults/notmuch.rc
new file mode 100644
index 00000000..f03fcda1
--- /dev/null
+++ b/alot/defaults/notmuch.rc
@@ -0,0 +1,3 @@
+[maildir]
+synchronize_flags = False
+
diff --git a/alot/helper.py b/alot/helper.py
index eb0d4978..91da0811 100644
--- a/alot/helper.py
+++ b/alot/helper.py
@@ -51,7 +51,7 @@ def pretty_datetime(d):
def cmd_output(command_line):
- args = shlex.split(command_line)
+ args = shlex.split(command_line.encode('ascii', errors='ignore'))
try:
output = subprocess.check_output(args)
except subprocess.CalledProcessError:
@@ -61,6 +61,25 @@ def cmd_output(command_line):
return output
+def pipe_to_command(cmd, stdin):
+ # no unicode in shlex on 2.x
+ args = shlex.split(cmd.encode('ascii'))
+ try:
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ out, err = proc.communicate(stdin)
+ except OSError, e:
+ return '', str(e)
+ if proc.poll(): # returncode is not 0
+ e = 'return value != 0'
+ if err.strip():
+ e = e + ': %s' % err
+ return '', e
+ else:
+ return out, err
+
+
def attach(path, mail, filename=None):
ctype, encoding = mimetypes.guess_type(path)
if ctype is None or encoding is not None:
@@ -94,3 +113,13 @@ def attach(path, mail, filename=None):
part.add_header('Content-Disposition', 'attachment',
filename=filename)
mail.attach(part)
+
+
+def shell_quote(text):
+ r'''
+ >>> print(shell_quote("hello"))
+ 'hello'
+ >>> print(shell_quote("hello'there"))
+ 'hello'"'"'there'
+ '''
+ return "'%s'" % text.replace("'", """'"'"'""")
diff --git a/alot/init.py b/alot/init.py
index 9972e42f..93c84149 100755
--- a/alot/init.py
+++ b/alot/init.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
"""
This file is part of alot, a terminal UI to notmuch mail (notmuchmail.org).
Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com>
@@ -16,6 +16,7 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
+import sys
import argparse
import logging
import os
@@ -30,7 +31,7 @@ from urwid.command_map import command_map
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('-c', dest='configfile',
- default='~/.alot.rc',
+ default=None,
help='alot\'s config file')
parser.add_argument('-n', dest='notmuchconfigfile',
default='~/.notmuch-config',
@@ -61,9 +62,25 @@ def main():
# interpret cml arguments
args = parse_args()
- #read config file
- configfilename = os.path.expanduser(args.configfile)
- settings.config.read(configfilename)
+ #locate and read config file
+ configfiles = [
+ os.path.join(os.environ.get('XDG_CONFIG_HOME',
+ os.path.expanduser('~/.config')),
+ 'alot', 'config'),
+ os.path.expanduser('~/.alot.rc'),
+ ]
+ if args.configfile:
+ expanded_path = os.path.expanduser(args.configfile)
+ if not os.path.exists(expanded_path):
+ sys.exit('File %s does not exist' % expanded_path)
+ configfiles.insert(0, expanded_path)
+
+ found_config = False
+ for configfilename in configfiles:
+ if os.path.exists(configfilename):
+ settings.config.read(configfilename)
+ found_config = True
+
notmuchfile = os.path.expanduser(args.notmuchconfigfile)
settings.notmuchconfig.read(notmuchfile)
settings.hooks.setup(settings.config.get('general', 'hooksfile'))
@@ -88,7 +105,7 @@ def main():
command_map['esc'] = 'cancel'
# get initial searchstring
- query = settings.config.get('general','initial_searchstring')
+ query = settings.config.get('general', 'initial_searchstring')
if args.query != '':
query = args.query
diff --git a/alot/message.py b/alot/message.py
index f3128eba..e8cf539c 100644
--- a/alot/message.py
+++ b/alot/message.py
@@ -29,7 +29,7 @@ from settings import get_mime_handler
from settings import config
-class Message:
+class Message(object):
def __init__(self, dbman, msg, thread=None):
"""
:param dbman: db manager that is used for further lookups
@@ -279,7 +279,7 @@ def encode_header(key, value):
return value
-class Attachment:
+class Attachment(object):
"""represents a single mail attachment"""
def __init__(self, emailpart):
@@ -296,7 +296,10 @@ class Attachment:
def get_filename(self):
"""return the filename, extracted from content-disposition header"""
- return self.part.get_filename()
+ extracted_name = self.part.get_filename()
+ if extracted_name:
+ return os.path.basename(extracted_name)
+ return None
def get_content_type(self):
"""mime type of the attachment"""
@@ -316,11 +319,16 @@ class Attachment:
def save(self, path):
"""save the attachment to disk. Uses self.get_filename
in case path is a directory"""
- if self.get_filename() and os.path.isdir(path):
- path = os.path.join(path, self.get_filename())
- FILE = open(path, "w")
+ filename = self.get_filename()
+ path = os.path.expanduser(path)
+ if os.path.isdir(path):
+ if filename:
+ basename = os.path.basename(filename)
+ FILE = open(os.path.join(path, basename), "w")
+ else:
+ FILE = tempfile.NamedTemporaryFile(delete=False, dir=path)
else:
- FILE = tempfile.NamedTemporaryFile(delete=False)
+ FILE = open(path, "w") # this throws IOErrors for invalid path
FILE.write(self.part.get_payload(decode=True))
FILE.close()
return FILE.name
diff --git a/alot/settings.py b/alot/settings.py
index 4ddf4fce..5afd4463 100644
--- a/alot/settings.py
+++ b/alot/settings.py
@@ -18,302 +18,31 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com>
"""
import imp
import os
+import ast
import mailcap
import codecs
from ConfigParser import SafeConfigParser
-DEFAULTS = {
- 'general': {
- 'colourmode': '256',
- 'editor_cmd': "/usr/bin/vim -f -c 'set filetype=mail' +",
- 'editor_writes_encoding': 'UTF-8',
- 'terminal_cmd': 'x-terminal-emulator -e',
- 'spawn_editor': 'False',
- 'displayed_headers': 'From,To,Cc,Bcc,Subject',
- 'display_content_in_threadline': 'False',
- 'authors_maxlength': '30',
- 'ask_subject': 'True',
- 'notify_timeout': '2',
- 'show_statusbar': 'True',
- 'flush_retry_timeout': '5',
- 'hooksfile': '~/.alot.py',
- 'bug_on_exit': 'False',
- 'timestamp_format': '',
- 'print_cmd': 'muttprint',
- 'initial_searchstring': 'tag:inbox AND NOT tag:killed',
- },
- '16c-theme': {
- 'bufferlist_focus_bg': 'dark gray',
- 'bufferlist_focus_fg': 'white',
- 'bufferlist_results_even_bg': 'black',
- 'bufferlist_results_even_fg': 'light gray',
- 'bufferlist_results_odd_bg': 'black',
- 'bufferlist_results_odd_fg': 'light gray',
- 'footer_bg': 'dark blue',
- 'footer_fg': 'light green',
- 'header_bg': 'dark blue',
- 'header_fg': 'white',
- 'message_attachment_bg': 'dark gray',
- 'message_attachment_fg': 'light gray',
- 'message_attachment_focussed_bg': 'light green',
- 'message_attachment_focussed_fg': 'light gray',
- 'message_body_bg': 'default',
- 'message_body_fg': 'light gray',
- 'message_header_bg': 'dark gray',
- 'message_header_fg': 'white',
- 'message_header_key_bg': 'dark gray',
- 'message_header_key_fg': 'white',
- 'message_header_value_bg': 'dark gray',
- 'message_header_value_fg': 'light gray',
- 'messagesummary_even_bg': 'light blue',
- 'messagesummary_even_fg': 'white',
- 'messagesummary_focus_bg': 'dark cyan',
- 'messagesummary_focus_fg': 'white',
- 'messagesummary_odd_bg': 'dark blue',
- 'messagesummary_odd_fg': 'white',
- 'notify_error_bg': 'dark red',
- 'notify_error_fg': 'white',
- 'notify_normal_bg': 'default',
- 'notify_normal_fg': 'default',
- 'prompt_bg': 'black',
- 'prompt_fg': 'light gray',
- 'tag_focus_bg': 'dark cyan',
- 'tag_focus_fg': 'white',
- 'tag_bg': 'black',
- 'tag_fg': 'brown',
- 'threadline_authors_bg': 'default',
- 'threadline_authors_fg': 'dark green',
- 'threadline_authors_focus_bg': 'dark cyan',
- 'threadline_authors_focus_fg': 'black,bold',
- 'threadline_bg': 'default',
- 'threadline_content_bg': 'default',
- 'threadline_content_fg': 'dark gray',
- 'threadline_content_focus_bg': 'dark cyan',
- 'threadline_content_focus_fg': 'dark gray',
- 'threadline_date_bg': 'default',
- 'threadline_date_fg': 'light gray',
- 'threadline_date_focus_bg': 'dark cyan',
- 'threadline_date_focus_fg': 'black',
- 'threadline_fg': 'default',
- 'threadline_focus_bg': 'dark cyan',
- 'threadline_focus_fg': 'white',
- 'threadline_mailcount_bg': 'default',
- 'threadline_mailcount_fg': 'light gray',
- 'threadline_mailcount_focus_bg': 'dark cyan',
- 'threadline_mailcount_focus_fg': 'black',
- 'threadline_subject_bg': 'default',
- 'threadline_subject_fg': 'light gray',
- 'threadline_subject_focus_bg': 'dark cyan',
- 'threadline_subject_focus_fg': 'black',
- 'threadline_tags_bg': 'default',
- 'threadline_tags_fg': 'brown',
- 'threadline_tags_focus_bg': 'dark cyan',
- 'threadline_tags_focus_fg': 'yellow,bold',
- },
- '1c-theme': {
- 'bufferlist_focus': 'standout',
- 'bufferlist_results_even': 'default',
- 'bufferlist_results_odd': 'default',
- 'footer': 'standout',
- 'header': 'standout',
- 'message_attachment': 'default',
- 'message_attachment_focussed': 'underline',
- 'message_body': 'default',
- 'message_header': 'default',
- 'message_header_key': 'default',
- 'message_header_value': 'default',
- 'messagesummary_even': '',
- 'messagesummary_focus': 'standout',
- 'messagesummary_odd': '',
- 'notify_error': 'standout',
- 'notify_normal': 'default',
- 'prompt': '',
- 'tag_focus': 'standout',
- 'tag': 'default',
- 'threadline': 'default',
- 'threadline_authors': 'default,underline',
- 'threadline_authors_focus': 'standout',
- 'threadline_content': 'default',
- 'threadline_content_focus': 'standout',
- 'threadline_date': 'default',
- 'threadline_date_focus': 'standout',
- 'threadline_focus': 'standout',
- 'threadline_mailcount': 'default',
- 'threadline_mailcount_focus': 'standout',
- 'threadline_subject': 'default',
- 'threadline_subject_focus': 'standout',
- 'threadline_tags': 'bold',
- 'threadline_tags_focus': 'standout',
- },
- '256c-theme': {
- 'bufferlist_focus_bg': 'g38',
- 'bufferlist_focus_fg': '#ffa',
- 'bufferlist_results_even_bg': 'g3',
- 'bufferlist_results_even_fg': 'default',
- 'bufferlist_results_odd_bg': 'default',
- 'bufferlist_results_odd_fg': 'default',
- 'footer_bg': '#006',
- 'footer_fg': 'white',
- 'header_bg': 'dark blue',
- 'header_fg': 'white',
- 'message_attachment_bg': 'dark gray',
- 'message_attachment_fg': 'light gray',
- 'message_attachment_focussed_bg': 'light green',
- 'message_attachment_focussed_fg': 'light gray',
- 'message_body_bg': 'default',
- 'message_body_fg': 'light gray',
- 'message_header_bg': 'dark gray',
- 'message_header_fg': 'white',
- 'message_header_key_bg': 'dark gray',
- 'message_header_key_fg': 'white',
- 'message_header_value_bg': 'dark gray',
- 'message_header_value_fg': 'light gray',
- 'messagesummary_even_bg': '#068',
- 'messagesummary_even_fg': 'white',
- 'messagesummary_focus_bg': 'g58',
- 'messagesummary_focus_fg': '#ff8',
- 'messagesummary_odd_bg': '#006',
- 'messagesummary_odd_fg': 'white',
- 'notify_error_bg': 'dark red',
- 'notify_error_fg': 'white',
- 'notify_normal_bg': 'default',
- 'notify_normal_fg': 'default',
- 'prompt_bg': 'default',
- 'prompt_fg': 'light gray',
- 'tag_focus_bg': 'g58',
- 'tag_focus_fg': '#ffa',
- 'tag_bg': 'default',
- 'tag_fg': 'brown',
- 'threadline_authors_bg': 'default',
- 'threadline_authors_fg': '#6d6',
- 'threadline_authors_focus_bg': 'g58',
- 'threadline_authors_focus_fg': '#8f6',
- 'threadline_bg': 'default',
- 'threadline_content_bg': 'default',
- 'threadline_content_fg': '#866',
- 'threadline_content_focus_bg': 'g58',
- 'threadline_content_focus_fg': '#866',
- 'threadline_date_bg': 'default',
- 'threadline_date_fg': 'g58',
- 'threadline_date_focus_bg': 'g58',
- 'threadline_date_focus_fg': 'g89',
- 'threadline_fg': 'default',
- 'threadline_focus_bg': 'g58',
- 'threadline_focus_fg': 'white',
- 'threadline_mailcount_bg': 'default',
- 'threadline_mailcount_fg': 'light gray',
- 'threadline_mailcount_focus_bg': 'g58',
- 'threadline_mailcount_focus_fg': 'g89',
- 'threadline_subject_bg': 'default',
- 'threadline_subject_fg': 'g58',
- 'threadline_subject_focus_bg': 'g58',
- 'threadline_subject_focus_fg': 'g89',
- 'threadline_tags_bg': 'default',
- 'threadline_tags_fg': '#a86',
- 'threadline_tags_focus_bg': 'g58',
- 'threadline_tags_focus_fg': '#ff8',
- },
- 'global-maps': {
- '$': 'flush',
- ':': 'prompt',
- ';': 'bufferlist',
- '@': 'refresh',
- '@': 'refresh',
- 'I': 'search tag:inbox AND NOT tag:killed',
- 'L': 'taglist',
- 'U': 'search tag:unread',
- '\\': 'prompt search ',
- 'm': 'compose',
- 'o': 'prompt search ',
- 'q': 'exit',
- 'shift tab': 'bprevious',
- 'tab': 'bnext',
- 'd': 'bclose',
- },
- 'search-maps': {
- '&': 'toggletag killed',
- 'O': 'refineprompt',
- 'a': 'toggletag inbox',
- 'enter': 'openthread',
- 'l': 'retagprompt',
- '|': 'refineprompt',
- },
- 'thread-maps': {
- 'C': 'fold --all',
- 'E': 'unfold --all',
- 'H': 'toggleheaders',
- 'P': 'print --all',
- 'a': 'toggletag inbox',
- 'enter': 'select',
- 'f': 'forward',
- 'g': 'groupreply',
- 'p': 'print',
- 'r': 'reply',
- },
- 'taglist-maps': {
- 'enter': 'select',
- },
- 'envelope-maps': {
- 'a': 'prompt attach ~/',
- 'y': 'send',
- 'enter': 'reedit',
- 't': 'prompt to ',
- 's': 'prompt subject ',
- },
- 'bufferlist-maps': {
- 'x': 'closefocussed',
- 'enter': 'openfocussed',
- },
- 'command-aliases': {
- 'clo': 'close',
- 'bn': 'bnext',
- 'bp': 'bprevious',
- 'ls': 'bufferlist',
- 'quit': 'exit',
- }
-}
-
-NOTMUCH_DEFAULTS = {
- 'maildir': {
- 'synchronize_flags': 'False',
- },
-}
-
-
-class DefaultsConfigParser(SafeConfigParser):
- def __init__(self, defaults):
- self.defaults = defaults
+class FallbackConfigParser(SafeConfigParser):
+ def __init__(self):
SafeConfigParser.__init__(self)
self.optionxform = lambda x: x
- for sec in defaults.keys():
- self.add_section(sec)
def get(self, section, option, fallback=None, *args, **kwargs):
if SafeConfigParser.has_option(self, section, option):
return SafeConfigParser.get(self, section, option, *args, **kwargs)
- elif section in self.defaults:
- if option in self.defaults[section]:
- return self.defaults[section][option]
return fallback
- def has_option(self, section, option, *args, **kwargs):
- if SafeConfigParser.has_option(self, section, option):
- return True
- elif section in self.defaults:
- if option in self.defaults[section]:
- return True
- return False
-
def getstringlist(self, section, option, **kwargs):
value = self.get(section, option, **kwargs)
return [s.strip() for s in value.split(',')]
-class AlotConfigParser(DefaultsConfigParser):
- def __init__(self, defaults):
- DefaultsConfigParser.__init__(self, defaults)
+class AlotConfigParser(FallbackConfigParser):
+ def __init__(self):
+ FallbackConfigParser.__init__(self)
self.hooks = None
def read(self, file):
@@ -329,10 +58,28 @@ class AlotConfigParser(DefaultsConfigParser):
except:
pass
+ # fix quoted keys / values
+ for section in self.sections():
+ for key, value in self.items(section):
+ if value and value[0] in "\"'":
+ value = ast.literal_eval(value)
+
+ transformed_key = False
+ if key[0] in "\"'":
+ transformed_key = ast.literal_eval(key)
+ elif key == 'colon':
+ transformed_key = ':'
+
+ if transformed_key:
+ self.remove_option(section, key)
+ self.set(section, transformed_key, value)
+ else:
+ self.set(section, key, value)
+
def get_palette(self):
mode = self.getint('general', 'colourmode')
ms = "%dc-theme" % mode
- names = self.options(ms) + DEFAULTS[ms].keys()
+ names = self.options(ms)
if mode > 2:
names = set([s[:-3] for s in names])
p = list()
@@ -384,7 +131,7 @@ class AlotConfigParser(DefaultsConfigParser):
return cmdline
-class HookManager:
+class HookManager(object):
def setup(self, hooksfile):
hf = os.path.expanduser(hooksfile)
if os.path.isfile(hf):
@@ -399,14 +146,15 @@ class HookManager:
if self.module:
if key in self.module.__dict__:
return self.module.__dict__[key]
-
- def f(*args, **kwargs):
- msg = 'called undefined hook: %s with arguments'
- return f
+ return None
-config = AlotConfigParser(DEFAULTS)
-notmuchconfig = DefaultsConfigParser(NOTMUCH_DEFAULTS)
+config = AlotConfigParser()
+config.read(os.path.join(os.path.dirname(__file__), 'defaults', 'alot.rc'))
+notmuchconfig = FallbackConfigParser()
+notmuchconfig.read(os.path.join(os.path.dirname(__file__),
+ 'defaults',
+ 'notmuch.rc'))
hooks = HookManager()
mailcaps = mailcap.getcaps()
diff --git a/alot/ui.py b/alot/ui.py
index 74d5aa51..2e58dc86 100644
--- a/alot/ui.py
+++ b/alot/ui.py
@@ -19,18 +19,19 @@ Copyright (C) 2011 Patrick Totzke <patricktotzke@gmail.com>
import urwid
import os
from urwid.command_map import command_map
+from twisted.internet import defer
from settings import config
from buffer import BufferlistBuffer
from command import commandfactory
from command import interpret_commandline
-from widgets import CompleteEdit
+import widgets
from completion import CommandLineCompleter
class MainWidget(urwid.Frame):
def __init__(self, ui, *args, **kwargs):
- urwid.Frame.__init__(self, urwid.SolidFill(' '), *args, **kwargs)
+ urwid.Frame.__init__(self, urwid.SolidFill(), *args, **kwargs)
self.ui = ui
def keypress(self, size, key):
@@ -44,7 +45,7 @@ class MainWidget(urwid.Frame):
urwid.Frame.keypress(self, size, key)
-class UI:
+class UI(object):
buffers = []
current_buffer = None
@@ -61,6 +62,7 @@ class UI:
self.mainloop = urwid.MainLoop(self.mainframe,
config.get_palette(),
handle_mouse=False,
+ event_loop=urwid.TwistedEventLoop(),
unhandled_input=self.keypress)
self.mainloop.screen.set_terminal_properties(colors=colourmode)
@@ -91,59 +93,41 @@ class UI:
:type tab: int
:param history: history to be used for up/down keys
:type history: list of str
+ :returns: a `twisted.defer.Deferred`
"""
- self.logger.info('open prompt')
- history = list(history) # make a local copy
- historypos = None
+ d = defer.Deferred() # create return deferred
+ main = self.mainloop.widget # save main widget
+
+ def select_or_cancel(text):
+ self.mainloop.widget = main # restore main screen
+ d.callback(text)
+
+ #set up widgets
leftpart = urwid.Text(prefix, align='left')
- if completer:
- editpart = CompleteEdit(completer, edit_text=text)
- for i in range(tab):
- editpart.keypress((0,), 'tab')
- else:
- editpart = urwid.Edit(edit_text=text)
+ editpart = widgets.CompleteEdit(completer, on_exit=select_or_cancel,
+ edit_text=text, history=history)
+
+ for i in range(tab): # hit some tabs
+ editpart.keypress((0,), 'tab')
+
+ # build promptwidget
both = urwid.Columns(
[
('fixed', len(prefix), leftpart),
('weight', 1, editpart),
])
prompt_widget = urwid.AttrMap(both, 'prompt', 'prompt')
- footer = self.mainframe.get_footer()
- self.mainframe.set_footer(prompt_widget)
- self.mainframe.set_focus('footer')
- self.mainloop.draw_screen()
- while True:
- keys = None
- while not keys:
- keys = self.mainloop.screen.get_input()
- for key in keys:
- self.logger.debug('prompt got key: %s' % key)
- if command_map[key] == 'select':
- self.mainframe.set_footer(footer)
- self.mainframe.set_focus('body')
- return editpart.get_edit_text()
- elif command_map[key] == 'cancel':
- self.mainframe.set_footer(footer)
- self.mainframe.set_focus('body')
- return None
- elif key in ['up', 'down']:
- if history:
- if historypos == None:
- history.append(editpart.get_edit_text())
- historypos = len(history) - 1
- if key == 'cursor up':
- historypos = (historypos - 1) % len(history)
- else:
- historypos = (historypos + 1) % len(history)
- editpart.set_edit_text(history[historypos])
- self.mainloop.draw_screen()
-
- else:
- size = (20,) # don't know why they want a size here
- editpart.keypress(size, key)
- self.mainloop.draw_screen()
+ # put promptwidget as overlay on main widget
+ overlay = urwid.Overlay(both, main,
+ ('fixed left', 0),
+ ('fixed right', 0),
+ ('fixed bottom', 1),
+ None)
+ self.mainloop.widget = overlay
+ return d # return deferred
+ @defer.inlineCallbacks
def commandprompt(self, startstring):
"""prompt for a commandline and interpret/apply it upon enter
@@ -152,15 +136,15 @@ class UI:
"""
self.logger.info('open command shell')
mode = self.current_buffer.typename
- cmdline = self.prompt(prefix=':',
+ cmdline = yield self.prompt(prefix=':',
text=startstring,
completer=CommandLineCompleter(self.dbman,
self.accountman,
mode),
history=self.commandprompthistory,
)
- if cmdline:
- self.interpret_commandline(cmdline)
+ self.logger.debug('CMDLINE: %s' % cmdline)
+ self.interpret_commandline(cmdline)
def interpret_commandline(self, cmdline):
"""interpret and apply a commandstring
@@ -168,13 +152,14 @@ class UI:
:param cmdline: command string to apply
:type cmdline: str
"""
- mode = self.current_buffer.typename
- self.commandprompthistory.append(cmdline)
- cmd = interpret_commandline(cmdline, mode)
- if cmd:
- self.apply_command(cmd)
- else:
- self.notify('invalid command')
+ if cmdline:
+ mode = self.current_buffer.typename
+ self.commandprompthistory.append(cmdline)
+ cmd = interpret_commandline(cmdline, mode)
+ if cmd:
+ self.apply_command(cmd)
+ else:
+ self.notify('invalid command')
def buffer_open(self, b):
"""
@@ -251,42 +236,57 @@ class UI:
self.notificationbar = None
self.update()
- def choice(self, message, choices={'yes': ['y'], 'no': ['n']}):
+ def choice(self, message, choices={'y': 'yes', 'n': 'no'},
+ select=None, cancel=None, msg_position='above'):
"""prompt user to make a choice
:param message: string to display before list of choices
:type message: unicode
:param choices: dict of possible choices
- :type choices: str->list of keys
+ :type choices: keymap->choice (both str)
+ :param select: choice to return if enter/return is hit.
+ Ignored if set to None.
+ :type select: str
+ :param cancel: choice to return if escape is hit.
+ Ignored if set to None.
+ :type cancel: str
+ :returns: a `twisted.defer.Deferred`
"""
- def build_line(msg, prio):
- cols = urwid.Columns([urwid.Text(msg)])
- return urwid.AttrMap(cols, 'notify_' + prio)
-
- cstrings = ['(%s):%s' % ('/'.join(v), k) for k, v in choices.items()]
- line = ', '.join(cstrings)
- msgs = [build_line(message + ' ' + line, 'normal')]
-
- footer = self.mainframe.get_footer()
- if not self.notificationbar:
- self.notificationbar = urwid.Pile(msgs)
- else:
- newpile = self.notificationbar.widget_list + msgs
- self.notificationbar = urwid.Pile(newpile)
- self.update()
+ assert select in choices.values() + [None]
+ assert cancel in choices.values() + [None]
+ assert msg_position in ['left', 'above']
+
+ d = defer.Deferred() # create return deferred
+ main = self.mainloop.widget # save main widget
+
+ def select_or_cancel(text):
+ self.mainloop.widget = main # restore main screen
+ d.callback(text)
+
+ #set up widgets
+ msgpart = urwid.Text(message)
+ choicespart = widgets.ChoiceWidget(choices, callback=select_or_cancel,
+ select=select, cancel=cancel)
+
+ # build widget
+ if msg_position == 'left':
+ both = urwid.Columns(
+ [
+ ('fixed', len(message), msgpart),
+ ('weight', 1, choicespart),
+ ], dividechars=1)
+ else: # above
+ both = urwid.Pile([msgpart, choicespart])
+ prompt_widget = urwid.AttrMap(both, 'prompt', 'prompt')
- self.mainloop.draw_screen()
- while True:
- result = self.mainloop.screen.get_input()
- self.logger.info('got: %s ' % result)
- if not result:
- self.clear_notify(msgs)
- self.mainloop.screen.get_input()
- return None
- for k, v in choices.items():
- if result[0] in v:
- self.clear_notify(msgs)
- return k
+ # put promptwidget as overlay on main widget
+ overlay = urwid.Overlay(both, main,
+ ('fixed left', 0),
+ ('fixed right', 0),
+ ('fixed bottom', 1),
+ None)
+ self.mainloop.widget = overlay
+ return d # return deferred
def notify(self, message, priority='normal', timeout=0, block=False):
"""notify popup
@@ -377,7 +377,9 @@ class UI:
if cmd.prehook:
self.logger.debug('calling pre-hook')
try:
- cmd.prehook(self, self.dbman, self.accountman, config)
+ cmd.prehook(ui=self, dbm=self.dbman, aman=self.accountman,
+ log=self.logger, config=config)
+
except:
self.logger.exception('prehook failed')
self.logger.debug('apply command: %s' % cmd)
@@ -385,6 +387,7 @@ class UI:
if cmd.posthook:
self.logger.debug('calling post-hook')
try:
- cmd.posthook(self, self.dbman, self.accountman, config)
+ cmd.posthook(ui=self, dbm=self.dbman, aman=self.accountman,
+ log=self.logger, config=config)
except:
self.logger.exception('posthook failed')
diff --git a/alot/widgets.py b/alot/widgets.py
index 2b811146..1645aff5 100644
--- a/alot/widgets.py
+++ b/alot/widgets.py
@@ -63,7 +63,7 @@ class ThreadlineWidget(urwid.AttrMap):
authors = self.thread.get_authors() or '(None)'
maxlength = config.getint('general', 'authors_maxlength')
- authorsstring = shorten(authors, maxlength).strip()
+ authorsstring = shorten(authors, maxlength)
self.authors_w = urwid.AttrMap(urwid.Text(authorsstring),
'threadline_authors')
cols.append(('fixed', len(authorsstring), self.authors_w))
@@ -166,43 +166,91 @@ class TagWidget(urwid.AttrMap):
self.set_attr_map({None: config.get_tagattr(self.tag)})
+class ChoiceWidget(urwid.Text):
+ def __init__(self, choices, callback, cancel=None, select=None):
+ self.choices = choices
+ self.callback = callback
+ self.cancel = cancel
+ self.select = select
+
+ items = []
+ for k, v in choices.items():
+ if v == select and select != None:
+ items.append('[%s]:%s' % (k, v))
+ else:
+ items.append('(%s):%s' % (k, v))
+ urwid.Text.__init__(self, ' '.join(items))
+
+ def selectable(self):
+ return True
+
+ def keypress(self, size, key):
+ cmd = command_map[key]
+ if cmd == 'select' and self.select != None:
+ self.callback(self.select)
+ elif cmd == 'cancel' and self.cancel != None:
+ self.callback(self.cancel)
+ elif key in self.choices:
+ self.callback(self.choices[key])
+ else:
+ return key
+
+
class CompleteEdit(urwid.Edit):
- def __init__(self, completer, edit_text=u'', **kwargs):
+ def __init__(self, completer, on_exit, edit_text=u'',
+ history=None, **kwargs):
self.completer = completer
+ self.on_exit = on_exit
+ self.history = list(history) # we temporarily add stuff here
+ self.historypos = None
+
if not isinstance(edit_text, unicode):
edit_text = unicode(edit_text, errors='replace')
self.start_completion_pos = len(edit_text)
- self.completion_results = None
+ self.completions = None
urwid.Edit.__init__(self, edit_text=edit_text, **kwargs)
def keypress(self, size, key):
cmd = command_map[key]
- if cmd in ['next selectable', 'prev selectable']:
- pos = self.start_completion_pos
- original = self.edit_text[:pos]
- if not self.completion_results: # not in completion mode
- self.completion_results = [''] + \
- self.completer.complete(original)
+ # if we tabcomplete
+ if cmd in ['next selectable', 'prev selectable'] and self.completer:
+ # if not already in completion mode
+ if not self.completions:
+ self.completions = [(self.edit_text, self.edit_pos)] + \
+ self.completer.complete(self.edit_text, self.edit_pos)
self.focus_in_clist = 1
- else:
+ else: # otherwise tab through results
if cmd == 'next selectable':
self.focus_in_clist += 1
else:
self.focus_in_clist -= 1
- if len(self.completion_results) > 1:
- suffix = self.completion_results[self.focus_in_clist %
- len(self.completion_results)]
- self.set_edit_text(original + suffix)
- self.edit_pos += len(suffix)
+ if len(self.completions) > 1:
+ ctext, cpos = self.completions[self.focus_in_clist %
+ len(self.completions)]
+ self.set_edit_text(ctext)
+ self.set_edit_pos(cpos)
else:
- self.set_edit_text(original + ' ')
self.edit_pos += 1
- self.start_completion_pos = self.edit_pos
- self.completion_results = None
+ if self.edit_pos >= len(self.edit_text):
+ self.edit_text += ' '
+ self.completions = None
+ elif key in ['up', 'down']:
+ if self.history:
+ if self.historypos == None:
+ self.history.append(self.edit_text)
+ self.historypos = len(self.history) - 1
+ if key == 'cursor up':
+ self.historypos = (self.historypos + 1) % len(self.history)
+ else:
+ self.historypos = (self.historypos - 1) % len(self.history)
+ self.set_edit_text(self.history[self.historypos])
+ elif cmd == 'select':
+ self.on_exit(self.edit_text)
+ elif cmd == 'cancel':
+ self.on_exit(None)
else:
result = urwid.Edit.keypress(self, size, key)
- self.start_completion_pos = self.edit_pos
- self.completion_results = None
+ self.completions = None
return result
diff --git a/data/example.rc b/data/example.rc
index 699bfd53..897ab4be 100644
--- a/data/example.rc
+++ b/data/example.rc
@@ -2,7 +2,6 @@
colourmode = 256 ; number of colours your terminal supports
hooksfile = hooks.py
editor_cmd = /usr/bin/vim -f -c 'set filetype=mail'
-pager_cmd = /usr/bin/view -f -c 'set filetype=mail'
terminal_cmd = /usr/bin/urxvt -T notmuch -e
spawn_editor = False
authors_maxlength = 30
@@ -10,6 +9,7 @@ displayed_headers = From,To,Cc,Bcc,Subject
ask_subject = True
notify_timeout = 1 # how long (in seconds) notifications are shown
flush_retry_timeout = 5 # timeout in secs after a failed attempt to flush is repeated
+print_cmd = muttprint
[account gmail]
realname = john doe
@@ -21,6 +21,7 @@ signature_filename = sig.txt # filename in attachment part
sendmail_command = msmtp --account=gmail -t
sent_box = maildir:///home/john/mail/gmail/Sent # we accept mbox,maildir,mh,babyl,mmdf here
draft_box = maildir:///home/john/mail/gmail/Drafts # we accept mbox,maildir,mh,babyl,mmdf here
+abook_command = abook --mutt-query
[search-maps]
t = toggletag todo
diff --git a/setup.py b/setup.py
index 62b8fd27..301d6ee3 100755
--- a/setup.py
+++ b/setup.py
@@ -10,6 +10,7 @@ setup(name='alot',
author_email=alot.__author_email__,
url=alot.__url__,
packages=['alot'],
+ package_data={'alot': ['defaults/alot.rc', 'defaults/notmuch.rc']},
scripts=['bin/alot'],
license=alot.__copyright__,
requires=[