From 2828885db093be86ef5b2c58f5c05ac3c4ed3664 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 2 Nov 2019 10:54:13 +0200 Subject: Import historical files --- Changes | 120 +++++ README | 250 +++++++++ index.html | 112 ++++ slime-0.11/Changes | 120 +++++ slime-0.11/Makefile | 47 ++ slime-0.11/README | 250 +++++++++ slime-0.11/StringIO2.py | 159 ++++++ slime-0.11/TODO | 169 ++++++ slime-0.11/doc/slime-manual.sgml | 36 ++ slime-0.11/helpers.py | 85 +++ slime-0.11/mhlib2.py | 915 ++++++++++++++++++++++++++++++++ slime-0.11/slime | 5 + slime-0.11/slime.html | 112 ++++ slime-0.11/slime_abstract.py | 541 +++++++++++++++++++ slime-0.11/slime_draft.py | 52 ++ slime-0.11/slime_folderinfo.py | 77 +++ slime-0.11/slime_mh.py | 245 +++++++++ slime-0.11/slime_pgpmime.py | 168 ++++++ slime-0.11/slime_root.py | 77 +++ slime-0.11/slime_send.py | 171 ++++++ slime-0.11/slime_unix.py | 183 +++++++ slime-0.11/ui.py | 1058 ++++++++++++++++++++++++++++++++++++++ slime-0.11/ui_compose.py | 96 ++++ slime-0.11/ui_config.py | 103 ++++ slime-0.11/ui_helpers.py | 155 ++++++ slime-main.jpg | Bin 0 -> 23875 bytes slime-manual.html/ch1.html | 22 + slime-manual.html/index.html | 51 ++ slime-msg.jpg | Bin 0 -> 23989 bytes uitools/Alerts.py | 206 ++++++++ uitools/ButtonBox.py | 77 +++ uitools/Cursors.py | 104 ++++ uitools/KWDict.py | 35 ++ uitools/LabelEntry.py | 154 ++++++ uitools/Menu.py | 245 +++++++++ uitools/ProgressBar.py | 70 +++ uitools/README | 186 +++++++ uitools/Scrolled.py | 184 +++++++ uitools/Shells.py | 315 ++++++++++++ uitools/StdDialog.py | 136 +++++ uitools/TkDlgWrapper.py | 268 ++++++++++ 41 files changed, 7359 insertions(+) create mode 100644 Changes create mode 100644 README create mode 100644 index.html create mode 100644 slime-0.11/Changes create mode 100644 slime-0.11/Makefile create mode 100644 slime-0.11/README create mode 100644 slime-0.11/StringIO2.py create mode 100644 slime-0.11/TODO create mode 100644 slime-0.11/doc/slime-manual.sgml create mode 100644 slime-0.11/helpers.py create mode 100644 slime-0.11/mhlib2.py create mode 100644 slime-0.11/slime create mode 100644 slime-0.11/slime.html create mode 100644 slime-0.11/slime_abstract.py create mode 100644 slime-0.11/slime_draft.py create mode 100644 slime-0.11/slime_folderinfo.py create mode 100644 slime-0.11/slime_mh.py create mode 100644 slime-0.11/slime_pgpmime.py create mode 100644 slime-0.11/slime_root.py create mode 100644 slime-0.11/slime_send.py create mode 100644 slime-0.11/slime_unix.py create mode 100644 slime-0.11/ui.py create mode 100644 slime-0.11/ui_compose.py create mode 100644 slime-0.11/ui_config.py create mode 100644 slime-0.11/ui_helpers.py create mode 100644 slime-main.jpg create mode 100644 slime-manual.html/ch1.html create mode 100644 slime-manual.html/index.html create mode 100644 slime-msg.jpg create mode 100644 uitools/Alerts.py create mode 100644 uitools/ButtonBox.py create mode 100644 uitools/Cursors.py create mode 100644 uitools/KWDict.py create mode 100644 uitools/LabelEntry.py create mode 100644 uitools/Menu.py create mode 100644 uitools/ProgressBar.py create mode 100644 uitools/README create mode 100644 uitools/Scrolled.py create mode 100644 uitools/Shells.py create mode 100644 uitools/StdDialog.py create mode 100644 uitools/TkDlgWrapper.py diff --git a/Changes b/Changes new file mode 100644 index 0000000..ca73d00 --- /dev/null +++ b/Changes @@ -0,0 +1,120 @@ +Version 0.11 (released Mon, 15 Sep 1997 00:36:15 +0300): + + * Added slime_folderinfo.py to CVS tree (and, therefore, + the distribution). + * Confused notes about new abstract classes for folders and messages. + +Version 0.10 (released Sun, 17 Aug 1997 18:49:31 +0300): + + * Fixed bug in rescanning MH subfolders that broke things for + the second time the folder was scanned. + * Show message count in folder list only for inboxes (speeds + things up quite a bit). + * Let user edit folder-specific info. At the moment the only + available bit for editing is whether a folder should be scanned + for new messages, but more will follow. + * Remove user's address(es) from recipients in replies. + * Composition window ask for conformation for send, abort, etc. + * MH folders are only rescanned if the directory has been modified + since the previous scan. This makes new message check fast. + * helpers.find_folder_by_name caches results. + * Made the frequency of new message checks settable. + * Main window shows total number of unread messages. + * Folder count is updated in main window. + +Version 0.9 (released Fri, 15 Aug 1997 00:32:23 +0300): + + * Added support for signature files. + * Added support for a default Reply-To header. + * Program is now started with the command `slime'. + * Added `make install'. + * Configuration can be saved. Note: Old version of pyliw had a + bug, and a newer version is needed. + * Show image/gif inline. + * Folders can be opened and closed in the folder list without + selecting them, by clicking on them with mouse button 2 (middle + one on a three-button mouse). + * Replies: Quotes are attributed. + * Folder list shows number of messages in each folder. + * Separated SlimeFolder.rescan_folder into .rescan_messages and + .rescan_subfolders. This speeds up searching for a specific + folder (the FCC folder) rather a bit. + * Use full pathnames when comparing folder names. + * User can enter names of inboxes. These are scanned for new + messages at program startup and periodically while running + (every five minutes, not yet settable). The user interface is + stupid (user must enter filenames), and will be changed + in a future version. + +Version 0.8 (released Sun, 10 Aug 1997 20:02:36 +0300) + + * Added the beginnings of a manual. Well, a skeleton to hold what + will be written later. + * PGP passphrase is remembered for N minutes (default is 60). + * Added support for an external editor. + * Copies of saved messages can be saved to a folder. + +Version 0.7 (released Sat, 9 Aug 1997 17:20:04 +0300) + + * Added `clean' target to Makefile. + * Modal dialog (YesNoDialog) releases grab immediately after + user has selected something. + * Added rudimentary configuration menu. + * Remove duplicate addresses when creating reply template. + * Remove empty headers when sending. + * Drafts are removed from folder when sent. + * "Compose message" will re-edit saved draft, if one is selected. + * Add date to message being sent. + * Added rudimentary PGP/MIME signed message creation. + * Temporary files are created with secretive permissions. + +Version 0.6 (released Sun, 27 Jul 1997 23:09:45 +0300) + + * Beginnings of support to send and reply to mail. Works, but + is still quite clumsy. Requires that localhost runs an SMTP + server. + +Version 0.5 (released Sat, 26 Jul 1997 20:08:08 +0300) + + * Put the Slime homepage under CVS. + * Added basic PGP/MIME support. May be specific Exmh messages, + but we'll see. + +Version 0.4 (released Fri, 25 Jul 1997 10:21:03 +0300) + + * Implemented folder.add_message, copy_message_here, and + move_message_here. + * Mouse button 3 moves current message to different folder + (as in exmh). + +Version 0.3 (released Wed, 23 Jul 1997 01:59:01 +0300) + + * Rescan in menu actually rescans. + * Identifiers for class-internal use start with underscore. + * Show the whole line in message list in bold for new articles. + * Exit asks for confirmation. + * Ask for permission before committing a folder. + +Version 0.2 (released Tue, 22 Jul 1997 01:06:46 +0300) + + * Use References in addition to In-Reply-To when building threads. + * Added command button for "Delete" to message window. + * Don't display empty "important" headers in message window. + * Call them "selected", not "important" headers. + * Automatic recognition whether ~/Mail is MH or unix top folder. + * Replace a newline in a subject with a space, for folder table + of contents purposes. + * Format dates correctly, even if they are just two digits. + * Message window scrolls by height-2, not height-3 lines. + * Subject in window title of message window has maximum length. + * Messages try to make sure they can't be used while the folder + they're in is closed. + * Unix messages use the file handle in the parent folder, instead + of keeping a copy for themselves. This way, if the parent wants + to close it, the messages will cause errors if they try to use it. + * Scrolling up and down one line at a time with cursor keys. + * In list of folders, subtrees can now be opened and closed. + * Added dirty bit to folder. This allow commit to do nothing, unless + necessary. + * Added and fixed caching of headers, to speed up threading, + among other things. diff --git a/README b/README new file mode 100644 index 0000000..41ef3b6 --- /dev/null +++ b/README @@ -0,0 +1,250 @@ +README for Stupid little mailer, or Slime for short +version 0.11 +by Lars Wirzenius, liw+slime@iki.fi + + +At the moment, this file is a random collection of notes for a new +mailer, code name Slime, which may or may not become a reality. + +Note that Slime is very much alpha level code, and is quite fragile. +Be careful. + +Installation and use + + 0. Make sure you have Python (with Tkinter) and Mitch Chapman's + UITools package installed, and that Python can find UITools. + You can find a link to UITools on the Slime home page. Set + the PYTHONPATH environment variable to include the directory + where it is installed, if necessary. + + 1. Unpack slime-.tar.gz. + + 2. Run "python ui.py". + + 3. Play with it and watch your mail disappear. + + 4. Report any bugs and ideas to author. + +Background: Why a new mailer? What is missing from existing ones? + + I have not been satisfied by existing mailers, because + they are not very suitable for large amounts of mail, + do not support PGP or MIME well, don't have both a text + and an X interface, are too slow, or are otherwise + unsuitable. For example, I currently use Exmh, which + is good, but slow, and lacks a text interface. Emacs + has at least two fairly good mailers, but I won't use + Emacs as my editor. + + I could find a fairly good one and improve that, but most + good mailers are huge programs, and learning them well + enough to improve them significantly is a large task. + Some would also require me to learn a new language. For + example, Exmh is written in Tcl/Tk, but I refuse to + learn Tcl, since I don't like the language. + + Most of my requirements are simple enough to implement, + it's just that no mailer happens to satisfy all of them. + So, lured by the simplicity and hopefully complete + supporting libraries of Python, I'm considering to + write yet another mailer. + + That won't surprise my friends. After all, I wrote my + own editor, as well. :) + +Basic requirements + + * Must be simple to implement, since I can't afford to + spend lots of time on this. + + * Fast and efficient, within reason: if it feels fast enough, + it's fast enough, the point is not to optimize it for speed. + + * The implementation must be modular, so that support for new + mail folder formats, mail retrieval and sending mechanisms, + user interfaces, and so on can be easily added. + + I will probably implement only stuff that I need myself, + but other people should have the possiblity to extend it. + + * Excellent PGP integration, including PGP/MIME, application/pgp, + plain PGP messages, key extraction, fetching missing keys, + sending keys as attachments or in mail bodies, and so on. + Users should be able to basically ignore PGP support, as long + as everything works (but the mailer should scream when a letter + has a bad signature, for example). + + * Excellent MIME support, at least as good as Exmh. Attachments + and stuff should "just work". The mailer should show unknown + content types as well as it can, e.g., by showing all printable + characters, at least as an option. + + * Must work for huge amounts of e-mail correspondence, on the order of + thousands per day (e.g., via many large-volume mailing lists). + This means supporting arbitrary numbers of "inbox" folders + (including many APOP or IMAP folders) at the same time, doing + real message threading, supporting archiving and full text + searches, finding addresses easily, and keeping track of + messages that need to be processed still (this is different + from being unread). + + * The user interface should be simple to learn and use. Typical + operations should be a single mouse click or key press. All + dangerous operations (including deletion of mail) should be + difficult to invoke by accident. Going through unread + mail should happen just by pressing a space bar, + even if the mail is in many folders. + + * Configuration must be easy (not just via editing a text file) + for users. This means something like the Pine configuration + menu. Configuration should not be necessary to use the software, + however. + + * It must be possible to view any number of folders and messages + at once, and to edit any number of replies at once. A draft + folder a la MH must be supported. + + * Must be compatible with other mailers, i.e., not require that + users convert their folders to a particular folder format. + This makes it possible to use Slime concurrently with another + mailer (important during development and when users try it out). + + * Maybe: virtual folders, annotations, links between messages. + But these will probably be too much work to implement. + +Design + + Slime will be implemented in Python, if at all, and + will consist of some number of classes that implement + various features. Some of these classes will be abstract, + they will just define an interface to a large number + of similar objects. For example, there will be some + kind of Folder class, which defines the interface to a + mail folder, and different subclasses will implement + the interface to allow access to mailbox files, MH + directories, Maildir directories, IMAP folders, and + so on. The rest of the program will then not need to + care what the actual folder format is. + +Folder hierarchy + + A folder collection, such as ~/Mail for MH, or ~/mail for + Pine, is a folder, with no messages, just subfolders. + This way the folders in a collection form a natural + hierarchy. + + There is a SlimeFolderRoot class that lists all + folder collections. The user may add and remove folder + collections, represented as subfolders (of course), using + some sort of menu selection. An instance of this class + is made persistent (loaded at startup, saved at exit). + +Status header + + The following is gleaned from mutt sources: + + D = deleted + * = flagged + r = replied + - = read + O = old + N = new? + + And only one character? + + Unknown status chars: + + R + A + + +Redesigned abstract classes + + module is slime.py + + Exception: + error = 'slime.error' + + def thread_messages(list_of_messages): + def find_folder(folder_name): + + class Folder: + def __init__(self, full_name): + + def get_full_name(self): + def get_short_name(self): + + def delete(self): + + def lock(self): + def unlock(self): + + def scan_messages(self, smart=1): + def list_messages(self, choose=None): + def create_message(self, file): + def copy_message(self, msg): + def move_message(self, msg): + + def scan_subfolders(self, smart=1): + def list_subfolders(self): + def scan_subfolders(self): + def create_subfolder(self, relative_name): + + def is_clean(self): + def make_dirty(self): + def make_clean(self): + def commit(self): + + class Message: + def __init__(self, folder): + + def forget_headers(self): + def read_headers(self): + + def is_dirty(self): + def make_clean(self): + + def set_status(self, new_status): + def get_status(self): + def has_status(self, status): + def give_status(self, status): + def remove_status(self, status): + + def set_contents(self, file): + + def get_file(self): + + # union of the interfaces of rfc822.Message, mimetools.Message, + # and mhlib.Message. + ... + # or maybe there is a better interface that could be + # implemented by scratch to avoid using multiple layers? + + def test(): + - open folder + - count # of messages + - list some of the headers of each message + - thread messages + - delete message + - add message + - copy/move message to another folder (to each folder type) + - have N processes try to modify the folder at once + (some create messages, others delete them, some just + read the folder) + + + - communication between different windows? + + class MessageWindow: + def __init__(self): + def set(self, message): + + class MessageListWindow: + def __init__(self): + def set(self, list_of_messages): + def update(self, message): + + class FolderListWindow: + def __init__(self): + def set(self, list_of_folders): + def update(self, folder): diff --git a/index.html b/index.html new file mode 100644 index 0000000..4ffea49 --- /dev/null +++ b/index.html @@ -0,0 +1,112 @@ + + +Slime - the Stupid little mailer + + + +

Slime - the Stupid little mailer

+ +

See the README for more info, and Changes, if you've seen any previous version. +The manual is not +yet written. + +

Screenshots: main window (jpeg, 25 kB), +message window (jpeg, 25 kB). These are +of version 0.2, or something. + +

You need the Slime source package, +my pyliw package (version 0.3), +Mitch Chapman's UITools, and +Python. Note +that version 0.9 of Slime needs version 0.3 of pyliw (older versions +had a bug, now fixed). + + +

A small collection of links

+ +

Message formats: +

+ +

Protocol related stuff: +

+ +

Mailbox formats +

+ +

Other mailers and mail-related software: +

+ +

Other stuff +

+ + + diff --git a/slime-0.11/Changes b/slime-0.11/Changes new file mode 100644 index 0000000..ca73d00 --- /dev/null +++ b/slime-0.11/Changes @@ -0,0 +1,120 @@ +Version 0.11 (released Mon, 15 Sep 1997 00:36:15 +0300): + + * Added slime_folderinfo.py to CVS tree (and, therefore, + the distribution). + * Confused notes about new abstract classes for folders and messages. + +Version 0.10 (released Sun, 17 Aug 1997 18:49:31 +0300): + + * Fixed bug in rescanning MH subfolders that broke things for + the second time the folder was scanned. + * Show message count in folder list only for inboxes (speeds + things up quite a bit). + * Let user edit folder-specific info. At the moment the only + available bit for editing is whether a folder should be scanned + for new messages, but more will follow. + * Remove user's address(es) from recipients in replies. + * Composition window ask for conformation for send, abort, etc. + * MH folders are only rescanned if the directory has been modified + since the previous scan. This makes new message check fast. + * helpers.find_folder_by_name caches results. + * Made the frequency of new message checks settable. + * Main window shows total number of unread messages. + * Folder count is updated in main window. + +Version 0.9 (released Fri, 15 Aug 1997 00:32:23 +0300): + + * Added support for signature files. + * Added support for a default Reply-To header. + * Program is now started with the command `slime'. + * Added `make install'. + * Configuration can be saved. Note: Old version of pyliw had a + bug, and a newer version is needed. + * Show image/gif inline. + * Folders can be opened and closed in the folder list without + selecting them, by clicking on them with mouse button 2 (middle + one on a three-button mouse). + * Replies: Quotes are attributed. + * Folder list shows number of messages in each folder. + * Separated SlimeFolder.rescan_folder into .rescan_messages and + .rescan_subfolders. This speeds up searching for a specific + folder (the FCC folder) rather a bit. + * Use full pathnames when comparing folder names. + * User can enter names of inboxes. These are scanned for new + messages at program startup and periodically while running + (every five minutes, not yet settable). The user interface is + stupid (user must enter filenames), and will be changed + in a future version. + +Version 0.8 (released Sun, 10 Aug 1997 20:02:36 +0300) + + * Added the beginnings of a manual. Well, a skeleton to hold what + will be written later. + * PGP passphrase is remembered for N minutes (default is 60). + * Added support for an external editor. + * Copies of saved messages can be saved to a folder. + +Version 0.7 (released Sat, 9 Aug 1997 17:20:04 +0300) + + * Added `clean' target to Makefile. + * Modal dialog (YesNoDialog) releases grab immediately after + user has selected something. + * Added rudimentary configuration menu. + * Remove duplicate addresses when creating reply template. + * Remove empty headers when sending. + * Drafts are removed from folder when sent. + * "Compose message" will re-edit saved draft, if one is selected. + * Add date to message being sent. + * Added rudimentary PGP/MIME signed message creation. + * Temporary files are created with secretive permissions. + +Version 0.6 (released Sun, 27 Jul 1997 23:09:45 +0300) + + * Beginnings of support to send and reply to mail. Works, but + is still quite clumsy. Requires that localhost runs an SMTP + server. + +Version 0.5 (released Sat, 26 Jul 1997 20:08:08 +0300) + + * Put the Slime homepage under CVS. + * Added basic PGP/MIME support. May be specific Exmh messages, + but we'll see. + +Version 0.4 (released Fri, 25 Jul 1997 10:21:03 +0300) + + * Implemented folder.add_message, copy_message_here, and + move_message_here. + * Mouse button 3 moves current message to different folder + (as in exmh). + +Version 0.3 (released Wed, 23 Jul 1997 01:59:01 +0300) + + * Rescan in menu actually rescans. + * Identifiers for class-internal use start with underscore. + * Show the whole line in message list in bold for new articles. + * Exit asks for confirmation. + * Ask for permission before committing a folder. + +Version 0.2 (released Tue, 22 Jul 1997 01:06:46 +0300) + + * Use References in addition to In-Reply-To when building threads. + * Added command button for "Delete" to message window. + * Don't display empty "important" headers in message window. + * Call them "selected", not "important" headers. + * Automatic recognition whether ~/Mail is MH or unix top folder. + * Replace a newline in a subject with a space, for folder table + of contents purposes. + * Format dates correctly, even if they are just two digits. + * Message window scrolls by height-2, not height-3 lines. + * Subject in window title of message window has maximum length. + * Messages try to make sure they can't be used while the folder + they're in is closed. + * Unix messages use the file handle in the parent folder, instead + of keeping a copy for themselves. This way, if the parent wants + to close it, the messages will cause errors if they try to use it. + * Scrolling up and down one line at a time with cursor keys. + * In list of folders, subtrees can now be opened and closed. + * Added dirty bit to folder. This allow commit to do nothing, unless + necessary. + * Added and fixed caching of headers, to speed up threading, + among other things. diff --git a/slime-0.11/Makefile b/slime-0.11/Makefile new file mode 100644 index 0000000..e222167 --- /dev/null +++ b/slime-0.11/Makefile @@ -0,0 +1,47 @@ +prefix=/usr/local +bindir=$(prefix)/bin +libdir=$(prefix)/lib/slime + +# The following is for my private use, not for other people. +release_root=$(HOME)/public_html/Slime + +all: + echo foo + +install: + test -d $(bindir) || mkdir -p $(bindir) + test -d $(libdir) || mkdir -p $(libdir) + sed 's @slimedir@ $(libdir) g' slime > $(bindir)/slime + chmod a+rx $(bindir)/slime + cp *.py $(libdir) + chmod a+r $(libdir)/* + python -c 'import compileall; compileall.main()' $(libdir) + +manual: + cd doc; debiandoc2html slime-manual.sgml + +release: + if [ -z "$(version)" ]; then \ + echo "Use 'make release version=1.2.3' to make a release."; \ + exit 1; fi + rm -rf x + mkdir x + cd x && tag=version_`echo $(version) | tr . _` && \ + cvs -q export -d slime-$(version) -r $$tag slime && \ + tar cf - slime-$(version) | gzip -9 > ../slime-$(version).tar.gz + cp slime-$(version).tar.gz $(release_root)/slime.tar.gz + cp x/slime-$(version)/README $(release_root)/README + cp x/slime-$(version)/Changes $(release_root)/Changes + cp x/slime-$(version)/slime.html $(release_root)/index.html + cd x/slime-$(version) && $(MAKE) manual && \ + rm -rf $(release_root)/slime-manual.html && \ + cp -a doc/slime-manual.html $(release_root) + rm -rf x + +profile: + python -c 'import profile, slime_unix; profile.run("slime_unix.profile_main()", "profile.out")' + python -c 'import pstats; p=pstats.Stats("profile.out"); p.strip_dirs().sort_stats("stdname").print_stats(); p.print_callers(); p.print_callees()' > profile.txt + +clean: + rm -f *.pyc + rm -rf doc/slime-manual.html diff --git a/slime-0.11/README b/slime-0.11/README new file mode 100644 index 0000000..41ef3b6 --- /dev/null +++ b/slime-0.11/README @@ -0,0 +1,250 @@ +README for Stupid little mailer, or Slime for short +version 0.11 +by Lars Wirzenius, liw+slime@iki.fi + + +At the moment, this file is a random collection of notes for a new +mailer, code name Slime, which may or may not become a reality. + +Note that Slime is very much alpha level code, and is quite fragile. +Be careful. + +Installation and use + + 0. Make sure you have Python (with Tkinter) and Mitch Chapman's + UITools package installed, and that Python can find UITools. + You can find a link to UITools on the Slime home page. Set + the PYTHONPATH environment variable to include the directory + where it is installed, if necessary. + + 1. Unpack slime-.tar.gz. + + 2. Run "python ui.py". + + 3. Play with it and watch your mail disappear. + + 4. Report any bugs and ideas to author. + +Background: Why a new mailer? What is missing from existing ones? + + I have not been satisfied by existing mailers, because + they are not very suitable for large amounts of mail, + do not support PGP or MIME well, don't have both a text + and an X interface, are too slow, or are otherwise + unsuitable. For example, I currently use Exmh, which + is good, but slow, and lacks a text interface. Emacs + has at least two fairly good mailers, but I won't use + Emacs as my editor. + + I could find a fairly good one and improve that, but most + good mailers are huge programs, and learning them well + enough to improve them significantly is a large task. + Some would also require me to learn a new language. For + example, Exmh is written in Tcl/Tk, but I refuse to + learn Tcl, since I don't like the language. + + Most of my requirements are simple enough to implement, + it's just that no mailer happens to satisfy all of them. + So, lured by the simplicity and hopefully complete + supporting libraries of Python, I'm considering to + write yet another mailer. + + That won't surprise my friends. After all, I wrote my + own editor, as well. :) + +Basic requirements + + * Must be simple to implement, since I can't afford to + spend lots of time on this. + + * Fast and efficient, within reason: if it feels fast enough, + it's fast enough, the point is not to optimize it for speed. + + * The implementation must be modular, so that support for new + mail folder formats, mail retrieval and sending mechanisms, + user interfaces, and so on can be easily added. + + I will probably implement only stuff that I need myself, + but other people should have the possiblity to extend it. + + * Excellent PGP integration, including PGP/MIME, application/pgp, + plain PGP messages, key extraction, fetching missing keys, + sending keys as attachments or in mail bodies, and so on. + Users should be able to basically ignore PGP support, as long + as everything works (but the mailer should scream when a letter + has a bad signature, for example). + + * Excellent MIME support, at least as good as Exmh. Attachments + and stuff should "just work". The mailer should show unknown + content types as well as it can, e.g., by showing all printable + characters, at least as an option. + + * Must work for huge amounts of e-mail correspondence, on the order of + thousands per day (e.g., via many large-volume mailing lists). + This means supporting arbitrary numbers of "inbox" folders + (including many APOP or IMAP folders) at the same time, doing + real message threading, supporting archiving and full text + searches, finding addresses easily, and keeping track of + messages that need to be processed still (this is different + from being unread). + + * The user interface should be simple to learn and use. Typical + operations should be a single mouse click or key press. All + dangerous operations (including deletion of mail) should be + difficult to invoke by accident. Going through unread + mail should happen just by pressing a space bar, + even if the mail is in many folders. + + * Configuration must be easy (not just via editing a text file) + for users. This means something like the Pine configuration + menu. Configuration should not be necessary to use the software, + however. + + * It must be possible to view any number of folders and messages + at once, and to edit any number of replies at once. A draft + folder a la MH must be supported. + + * Must be compatible with other mailers, i.e., not require that + users convert their folders to a particular folder format. + This makes it possible to use Slime concurrently with another + mailer (important during development and when users try it out). + + * Maybe: virtual folders, annotations, links between messages. + But these will probably be too much work to implement. + +Design + + Slime will be implemented in Python, if at all, and + will consist of some number of classes that implement + various features. Some of these classes will be abstract, + they will just define an interface to a large number + of similar objects. For example, there will be some + kind of Folder class, which defines the interface to a + mail folder, and different subclasses will implement + the interface to allow access to mailbox files, MH + directories, Maildir directories, IMAP folders, and + so on. The rest of the program will then not need to + care what the actual folder format is. + +Folder hierarchy + + A folder collection, such as ~/Mail for MH, or ~/mail for + Pine, is a folder, with no messages, just subfolders. + This way the folders in a collection form a natural + hierarchy. + + There is a SlimeFolderRoot class that lists all + folder collections. The user may add and remove folder + collections, represented as subfolders (of course), using + some sort of menu selection. An instance of this class + is made persistent (loaded at startup, saved at exit). + +Status header + + The following is gleaned from mutt sources: + + D = deleted + * = flagged + r = replied + - = read + O = old + N = new? + + And only one character? + + Unknown status chars: + + R + A + + +Redesigned abstract classes + + module is slime.py + + Exception: + error = 'slime.error' + + def thread_messages(list_of_messages): + def find_folder(folder_name): + + class Folder: + def __init__(self, full_name): + + def get_full_name(self): + def get_short_name(self): + + def delete(self): + + def lock(self): + def unlock(self): + + def scan_messages(self, smart=1): + def list_messages(self, choose=None): + def create_message(self, file): + def copy_message(self, msg): + def move_message(self, msg): + + def scan_subfolders(self, smart=1): + def list_subfolders(self): + def scan_subfolders(self): + def create_subfolder(self, relative_name): + + def is_clean(self): + def make_dirty(self): + def make_clean(self): + def commit(self): + + class Message: + def __init__(self, folder): + + def forget_headers(self): + def read_headers(self): + + def is_dirty(self): + def make_clean(self): + + def set_status(self, new_status): + def get_status(self): + def has_status(self, status): + def give_status(self, status): + def remove_status(self, status): + + def set_contents(self, file): + + def get_file(self): + + # union of the interfaces of rfc822.Message, mimetools.Message, + # and mhlib.Message. + ... + # or maybe there is a better interface that could be + # implemented by scratch to avoid using multiple layers? + + def test(): + - open folder + - count # of messages + - list some of the headers of each message + - thread messages + - delete message + - add message + - copy/move message to another folder (to each folder type) + - have N processes try to modify the folder at once + (some create messages, others delete them, some just + read the folder) + + + - communication between different windows? + + class MessageWindow: + def __init__(self): + def set(self, message): + + class MessageListWindow: + def __init__(self): + def set(self, list_of_messages): + def update(self, message): + + class FolderListWindow: + def __init__(self): + def set(self, list_of_folders): + def update(self, folder): diff --git a/slime-0.11/StringIO2.py b/slime-0.11/StringIO2.py new file mode 100644 index 0000000..505f057 --- /dev/null +++ b/slime-0.11/StringIO2.py @@ -0,0 +1,159 @@ +# class StringIO implements file-like objects that read/write a +# string buffer (a.k.a. "memory files"). +# +# This implements (nearly) all stdio methods. +# +# f = StringIO() # ready for writing +# f = StringIO(buf) # ready for reading +# f.close() # explicitly release resources held +# flag = f.isatty() # always false +# pos = f.tell() # get current position +# f.seek(pos) # set current position +# f.seek(pos, mode) # mode 0: absolute; 1: relative; 2: relative to EOF +# buf = f.read() # read until EOF +# buf = f.read(n) # read up to n bytes +# buf = f.readline() # read until end of line ('\n') or EOF +# list = f.readlines()# list of f.readline() results until EOF +# f.write(buf) # write at current position +# f.writelines(list) # for line in list: f.write(line) +# f.getvalue() # return whole file's contents as a string +# +# Notes: +# - Using a real file is often faster (but less convenient). +# - fileno() is left unimplemented so that code which uses it triggers +# an exception early. +# - Seeking far beyond EOF and then writing will insert real null +# bytes that occupy space in the buffer. +# - There's a simple test set (see end of this file). + +import string + +class StringIO: + def __init__(self, buf = ''): + self.buf = buf + self.len = len(buf) + self.buflist = [] + self.pos = 0 + self.closed = 0 + self.softspace = 0 + def close(self): + if not self.closed: + self.closed = 1 + del self.buf, self.pos + def isatty(self): + return 0 + def seek(self, pos, mode = 0): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + if mode == 1: + pos = pos + self.pos + elif mode == 2: + pos = pos + self.len + self.pos = max(0, pos) + def tell(self): + return self.pos + def read(self, n = -1): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + if n < 0: + newpos = self.len + else: + newpos = min(self.pos+n, self.len) + r = self.buf[self.pos:newpos] + self.pos = newpos + return r + def readline(self, length = None): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + i = string.find(self.buf, '\n', self.pos) + if i < 0: + newpos = self.len + else: + newpos = i+1 + if not length is None: + if self.pos + length < newpos: + newpos = self.pos + length + r = self.buf[self.pos:newpos] + self.pos = newpos + return r + def readlines(self): + lines = [] + line = self.readline() + while line: + lines.append(line) + line = self.readline() + return lines + def write(self, s): + if not s: return + if self.pos > self.len: + self.buflist.append('\0'*(self.pos - self.len)) + self.len = self.pos + newpos = self.pos + len(s) + if self.pos < self.len: + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + self.buflist = [self.buf[:self.pos], s, self.buf[newpos:]] + self.buf = '' + else: + self.buflist.append(s) + self.len = newpos + self.pos = newpos + def writelines(self, list): + self.write(string.joinfields(list, '')) + def flush(self): + pass + def getvalue(self): + if self.buflist: + self.buf = self.buf + string.joinfields(self.buflist, '') + self.buflist = [] + return self.buf + + +# A little test suite + +def test(): + import sys + if sys.argv[1:]: + file = sys.argv[1] + else: + file = '/etc/passwd' + lines = open(file, 'r').readlines() + text = open(file, 'r').read() + f = StringIO() + for line in lines[:-2]: + f.write(line) + f.writelines(lines[-2:]) + if f.getvalue() != text: + raise RuntimeError, 'write failed' + length = f.tell() + print 'File length =', length + f.seek(len(lines[0])) + f.write(lines[1]) + f.seek(0) + print 'First line =', `f.readline()` + here = f.tell() + line = f.readline() + print 'Second line =', `line` + f.seek(-len(line), 1) + line2 = f.read(len(line)) + if line != line2: + raise RuntimeError, 'bad result after seek back' + f.seek(len(line2), 1) + list = f.readlines() + line = list[-1] + f.seek(f.tell() - len(line)) + line2 = f.read() + if line != line2: + raise RuntimeError, 'bad result after seek back from EOF' + print 'Read', len(list), 'more lines' + print 'File length =', f.tell() + if f.tell() != length: + raise RuntimeError, 'bad length' + f.close() + +if __name__ == '__main__': + test() diff --git a/slime-0.11/TODO b/slime-0.11/TODO new file mode 100644 index 0000000..c4bc5c4 --- /dev/null +++ b/slime-0.11/TODO @@ -0,0 +1,169 @@ +Slimy things to do, and various notes +------------------------------------- + +Things to add or change before next release: + + * mouse-3 doesn't always work (if there's new mail?); don't use + integer indexes into list? + * error messages with popups, not pring + * rescan the current folder as well, periodically + * decrement_unread: only if message is in inbox folder; and other + problems leading to -1 unread + * put send and abort in compose window further away from each other + * msgwin title: just name or addr, not both + +Later stuff: + +* Misc: + * add X-Mailer header? + * mark replied messages with special status + * Copy, move should be done at commit time, and should update the + folder window. Copied/moved should be marked with some other + status character then D (C and M, perhaps). + * allow editing of signature-file from within slime (new option + to Config and ConfigEditor: CONFIG_STRING_LONG, which is same + as CONFIG_STRING, but ConfigEditor gives a multiline editor) + * speed up threading and other slow operations + * _intern_string should make sure it doesn't grow too big + * run PGP and other external program async. (allow using slime + while they're running, e.g., to kill the external program); + ditto for scanning inboxes; separate engine and ui to different + processes? + * implement locking: folder.open probably shouldn't lock folder (folder + is kept open while viewed, so that would prevent new mail from + arriving); instead, lock when operations on folder require it (rescan, + commit, others?), and raise exception LockException if can't get lock + - open imap folder locks it? + - some lockfiles should be refreshed every N minutes; what if + rescan/commit is slow? even just reading one message might + be too slow! use separate thread? + - after getting lock, make sure the folder is as we expect it to be + * spool should be mailbox + * automatic fcc to folder of replied-to message for replies? or always + a given folder? or never? (settable) + * quote: ignore signature (optional) + * long references: remove from middle, not end + * installation: ask for and set #!-headers in scripts + * address parsing breaks if addresses have commas (and in other ways) + * avoid using msg.getfulltext; implement msg.getfile + instead; this should reduce memory usage + * settable: ask whether to move read mail to received (default=no?) + or have an MH-style inbox and an "inc"? + * print letters in some nice way +* Unix folders: + * unix mailbox commit rewrites 'From ' lines (and wrongly!); + when reading a mailbox, store the From_ lines; invent one for new + messages (abstract class should have function to return it so it + is kept when moving/copying messages from one mbox to another) + * unix mailbox commit: use better filename and make sure it doesn't exist yet + * unix mailboxes do not handle messages with identical headers; + invent a better way to recognize new messages (just scan + after last message? but that doesn't notice deleted ones); + just use offset in file? + * unix mailbox commit: should check that file hasn't been modified since + last scan, to prevent losing stuff +* MH: + * MH copy_message, move_message: if source is also in MH folder, use + MH routines (much faster); for other message types, add and use + a msg.get_file that returns msg._fp; do _not_ read everything into + memory + * MH folders don't handle renumbering of folders while program is running; + use inode numbers for message identity, not message numbers; this + makes it robust against external folder packing + * MH: should folder be packed? make it an option later? +* MIME: + * mimify at send: deduce and set character set correctly, and other + MIME headers too + * decode header mime encodings + * handle uuencoded stuff + * support other charsets than latin1/usascii + * save part to file/pipe +* PGP: + * send pgp/mime signed messages: canonicalize message correctly + * support application/pgp (read only), text/plain that begins with + (empty lines) and ^---BEGIN PGP; showing and sending + * learn about S/MIME +* User interface: + * move, copy message: select folder from menu (in absence of dnd) + * keyboard controls should work in both windows + * It should be possible to change folders without committing + (i.e., to forget changes to folder). + * Do cursor magic so that a suitable cursor is set everywhere, + and so that a busy cursor is shown, when doing slow stuff. + * selection list: move barcursor first + * set mouse cursor to clock while busy + * allow selecting multiple lines in ftoc, and only allow selecting lines + * space should go to next new message, in any folder, if at end of one + folder + * there should be some key for "go to next new message, anywhere" + * handle WM_DELETE_WINDOW protocol + self.top.protocol("WM_DELETE_WINDOW", self.deleteWindow) + * improve error messages (catch exceptions, popup error message box) + * mime multiparts: toggle showing each part separately + * horizontal scrollbars in selectionlists (put into ScrolledText) + * configuration window: + - show threaded or sort by date/subject/subject+date/from + (should be settable per ftoc-window as well) + - hide only headers known to be unimportant? settable! + - have one "all options" window, and make cfgwins scrollable + * make subwindows in main window resizable with mouse + * make columns in ftoc resizeable by mouse and configurable + * make dates in ftoc nicer and configurable + * ftoc: more info per message (to, first N lines of message (after + quotes)) + * clone main window + * msgwin: optionally do paste-source with quotes, and paste-sink + as well + * "catchup" +* Documentation: + * start a manual + * write description of overall structure (for programmers) + * find good specification for "Status:" and "X-Status:" headers +* Python: + * early "SeX" messages from da Silva have bad dates, but should be + parseable; Python's getdate doesn't do it, though + * pythons getdate doesn't do timezone? runtime patch? + * python mailbox.py: of what use is _Mailbox.seek, and does it work + at all? + +Text mode interface (someone else will have to write it): + + * text interface should have a `really terse' mode for slow + modems (<=14k :) + * text interface: two windows, one shows a letter, one writes reply + +Lunatic ideas for after 1.0: + + * simple inline-html support (incoming) + * good printer support + * mixmaster support (and other anon mail support) + * internal editor should allow creation of MIME messages + * picons + * change pop/imap password securely from mailer (=> user does not need + to manually log into server at all) + * put ui and engine in different threads? + * address book a la exmh (and Emacs bbdb?): grab addresses from every + shown message, and have search tool (ctrl-tab) in mail editor; a + trad. alias system as well; grab data to aliases from mail, including + signature + * have address book integrate into external (non-existing) database of + contact info (names, phone numbers, e-mail addresses, etc); grab data + from signature + * folder specific signatures + * grab url's from letters, store in suitable file + * show text/html + * netiquette stuff: check for too much quotes, etc + * spam filter on inbox(es)? (move spam-looking stuff into separate folder, + or something); generic filtering? + * global operations (based on filters): set/reset flag, delete/copy/forward + /whatever flagged mail + * user defined shortcuts + * reply to many letters at once (quote all of them) + * interface for subscribing and unsubscribing to mailing lists + * compress folders + * killfile and scoring + * remove duplicates based on message-id + * mailing list (un)subscription tool + * mark as read/deleted in all folders, when many inboxes for + different lists have the same message + diff --git a/slime-0.11/doc/slime-manual.sgml b/slime-0.11/doc/slime-manual.sgml new file mode 100644 index 0000000..a52c814 --- /dev/null +++ b/slime-0.11/doc/slime-manual.sgml @@ -0,0 +1,36 @@ + + + +Stupid Little Mailer: User's Guide +<author>Lars Wirzenius <email>liw@iki.fi +<version>Version very-pre-alpha + +<abstract>Stupid Little Mailer, or Slime for short, is a mail user +agent. + +<copyright>Copyright © 1997 Lars Wirzenius. + +<p>Slime, including this manual, is free software; +you may redistribute it and/or modify it under the terms +of the GNU General Public License as published by the Free +Software Foundation; either version 2, or (at your option) +any later version. + +<p>This is distributed in the hope that it will be useful, +but without any warranty; without even the implied warranty of +merchantability or fitness for a particular purpose. See the +GNU General Public License for more details. + +<p>You should have received a copy of the GNU General +Public License with your Debian GNU/Linux system, in +/usr/doc/copyright/GPL, or with the SeX source +package as the file COPYING. If not, write to the Free Software +Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + +<toc> + +<chapt>Introduction + +<p>This manual will be written later. + +</book> diff --git a/slime-0.11/helpers.py b/slime-0.11/helpers.py new file mode 100644 index 0000000..46a5e11 --- /dev/null +++ b/slime-0.11/helpers.py @@ -0,0 +1,85 @@ +import spawn, tempfile, time, os, FCNTL + +def run_pgp(path, username, password, options, files, stdin=""): + env = {} + for key in os.environ.keys(): + env[key] = os.environ[key] + env["PGPPASSFD"] = "0" + + if password: + stdin = password + "\n" + stdin + + args = options[:] + if username: + args.append("+myname=" + username) + args = args + files + + return spawn.run(path, stdin=stdin, args=args, env=env) + +def read_text_from_named_file(filename): + f = open(filename, "r") + text = f.read() + f.close() + return text + +def write_text_to_named_file(filename, text): + fd = os.open(filename, + FCNTL.O_WRONLY | FCNTL.O_CREAT | FCNTL.O_EXCL, 0600) + os.write(fd, text) + os.close(fd) + +def rfc822_date(): + mon = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + dow = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + if time.daylight: + date = time.localtime(time.time()) + if time.altzone < 0: + tzsign = '+' + secs = -time.altzone + else: + tzsign = '-' + secs = time.altzone + tzhours = secs / (60*60) + tzmins = secs % (60*60) + else: + date = time.gmtime(time.time()) + tzsign = '+' + tzhours = 0 + tzmins = 0 + + return "%s, %2d %s %d %02d:%02d:%02d %c%02d%02d" % \ + (dow[date[6]], date[2], mon[date[1]], date[0], + date[3], date[4], date[5], tzsign, tzhours, tzmins) + +_name_to_folder_cache = {} + +def clear_name_to_folder_cache(): + global _name_to_folder_cache + _name_to_folder_cache = {} + +def find_folder_by_name(folder_name, folder): + global _name_to_folder_cache + if _name_to_folder_cache.has_key(folder_name): + return _name_to_folder_cache[folder_name] + return recursive_find_folder_by_name(folder_name, folder) + +def recursive_find_folder_by_name(folder_name, folder): + global _name_to_folder_cache + _name_to_folder_cache[repr(folder)] = folder + + if repr(folder) == folder_name: + return folder + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_subfolders() + f = None + for subfolder in folder.list_all_subfolders(): + f = recursive_find_folder_by_name(folder_name, subfolder) + if f: + break + if not was_open: + folder.close() + return f diff --git a/slime-0.11/mhlib2.py b/slime-0.11/mhlib2.py new file mode 100644 index 0000000..c932441 --- /dev/null +++ b/slime-0.11/mhlib2.py @@ -0,0 +1,915 @@ +# MH interface -- purely object-oriented (well, almost) +# +# Executive summary: +# +# import mhlib +# +# mh = mhlib.MH() # use default mailbox directory and profile +# mh = mhlib.MH(mailbox) # override mailbox location (default from profile) +# mh = mhlib.MH(mailbox, profile) # override mailbox and profile +# +# mh.error(format, ...) # print error message -- can be overridden +# s = mh.getprofile(key) # profile entry (None if not set) +# path = mh.getpath() # mailbox pathname +# name = mh.getcontext() # name of current folder +# mh.setcontext(name) # set name of current folder +# +# list = mh.listfolders() # names of top-level folders +# list = mh.listallfolders() # names of all folders, including subfolders +# list = mh.listsubfolders(name) # direct subfolders of given folder +# list = mh.listallsubfolders(name) # all subfolders of given folder +# +# mh.makefolder(name) # create new folder +# mh.deletefolder(name) # delete folder -- must have no subfolders +# +# f = mh.openfolder(name) # new open folder object +# +# f.error(format, ...) # same as mh.error(format, ...) +# path = f.getfullname() # folder's full pathname +# path = f.getsequencesfilename() # full pathname of folder's sequences file +# path = f.getmessagefilename(n) # full pathname of message n in folder +# +# list = f.listmessages() # list of messages in folder (as numbers) +# n = f.getcurrent() # get current message +# f.setcurrent(n) # set current message +# list = f.parsesequence(seq) # parse msgs syntax into list of messages +# n = f.getlast() # get last message (0 if no messagse) +# f.setlast(n) # set last message (internal use only) +# +# dict = f.getsequences() # dictionary of sequences in folder {name: list} +# f.putsequences(dict) # write sequences back to folder +# +# f.removemessages(list) # remove messages in list from folder +# f.refilemessages(list, tofolder) # move messages in list to other folder +# f.movemessage(n, tofolder, ton) # move one message to a given destination +# f.copymessage(n, tofolder, ton) # copy one message to a given destination +# +# m = f.openmessage(n) # new open message object (costs a file descriptor) +# m is a derived class of mimetools.Message(rfc822.Message), with: +# s = m.getheadertext() # text of message's headers +# s = m.getheadertext(pred) # text of message's headers, filtered by pred +# s = m.getbodytext() # text of message's body, decoded +# s = m.getbodytext(0) # text of message's body, not decoded +# +# XXX To do, functionality: +# - annotate messages +# - create, send messages +# +# XXX To do, organization: +# - move IntSet to separate file +# - move most Message functionality to module mimetools + + +# Customizable defaults + +MH_PROFILE = '~/.mh_profile' +PATH = '~/Mail' +MH_SEQUENCES = '.mh_sequences' +FOLDER_PROTECT = 0700 + + +# Imported modules + +import os +import sys +from stat import ST_NLINK +import regex +import string +import mimetools +import multifile +import shutil + + +# Exported constants + +Error = 'mhlib.Error' + + +# Class representing a particular collection of folders. +# Optional constructor arguments are the pathname for the directory +# containing the collection, and the MH profile to use. +# If either is omitted or empty a default is used; the default +# directory is taken from the MH profile if it is specified there. + +class MH: + + # Constructor + def __init__(self, path = None, profile = None): + if not profile: profile = MH_PROFILE + self.profile = os.path.expanduser(profile) + if not path: path = self.getprofile('Path') + if not path: path = PATH + if not os.path.isabs(path) and path[0] != '~': + path = os.path.join('~', path) + path = os.path.expanduser(path) + if not os.path.isdir(path): raise Error, 'MH() path not found' + self.path = path + + # String representation + def __repr__(self): + return 'MH(%s, %s)' % (`self.path`, `self.profile`) + + # Routine to print an error. May be overridden by a derived class + def error(self, msg, *args): + sys.stderr.write('MH error: %s\n' % (msg % args)) + + # Return a profile entry, None if not found + def getprofile(self, key): + return pickline(self.profile, key) + + # Return the path (the name of the collection's directory) + def getpath(self): + return self.path + + # Return the name of the current folder + def getcontext(self): + context = pickline(os.path.join(self.getpath(), 'context'), + 'Current-Folder') + if not context: context = 'inbox' + return context + + # Set the name of the current folder + def setcontext(self, context): + fn = os.path.join(self.getpath(), 'context') + f = open(fn, "w") + f.write("Current-Folder: %s\n" % context) + f.close() + + # Return the names of the top-level folders + def listfolders(self): + folders = [] + path = self.getpath() + for name in os.listdir(path): + if name in (os.curdir, os.pardir): continue + fullname = os.path.join(path, name) + if os.path.isdir(fullname): + folders.append(name) + folders.sort() + return folders + + # Return the names of the subfolders in a given folder + # (prefixed with the given folder name) + def listsubfolders(self, name): + fullname = os.path.join(self.path, name) + # Get the link count so we can avoid listing folders + # that have no subfolders. + st = os.stat(fullname) + nlinks = st[ST_NLINK] + if nlinks <= 2: + return [] + subfolders = [] + subnames = os.listdir(fullname) + for subname in subnames: + if subname in (os.curdir, os.pardir): continue + fullsubname = os.path.join(fullname, subname) + if os.path.isdir(fullsubname): + name_subname = os.path.join(name, subname) + subfolders.append(name_subname) + # Stop looking for subfolders when + # we've seen them all + nlinks = nlinks - 1 + if nlinks <= 2: + break + subfolders.sort() + return subfolders + + # Return the names of all folders, including subfolders, recursively + def listallfolders(self): + return self.listallsubfolders('') + + # Return the names of subfolders in a given folder, recursively + def listallsubfolders(self, name): + fullname = os.path.join(self.path, name) + # Get the link count so we can avoid listing folders + # that have no subfolders. + st = os.stat(fullname) + nlinks = st[ST_NLINK] + if nlinks <= 2: + return [] + subfolders = [] + subnames = os.listdir(fullname) + for subname in subnames: + if subname in (os.curdir, os.pardir): continue + if subname[0] == ',' or isnumeric(subname): continue + fullsubname = os.path.join(fullname, subname) + if os.path.isdir(fullsubname): + name_subname = os.path.join(name, subname) + subfolders.append(name_subname) + if not os.path.islink(fullsubname): + subsubfolders = self.listallsubfolders( + name_subname) + subfolders = subfolders + subsubfolders + # Stop looking for subfolders when + # we've seen them all + nlinks = nlinks - 1 + if nlinks <= 2: + break + subfolders.sort() + return subfolders + + # Return a new Folder object for the named folder + def openfolder(self, name): + return Folder(self, name) + + # Create a new folder. This raises os.error if the folder + # cannot be created + def makefolder(self, name): + protect = pickline(self.profile, 'Folder-Protect') + if protect and isnumeric(protect): + mode = string.atoi(protect, 8) + else: + mode = FOLDER_PROTECT + os.mkdir(os.path.join(self.getpath(), name), mode) + + # Delete a folder. This removes files in the folder but not + # subdirectories. If deleting the folder itself fails it + # raises os.error + def deletefolder(self, name): + fullname = os.path.join(self.getpath(), name) + for subname in os.listdir(fullname): + if subname in (os.curdir, os.pardir): continue + fullsubname = os.path.join(fullname, subname) + try: + os.unlink(fullsubname) + except os.error: + self.error('%s not deleted, continuing...' % + fullsubname) + os.rmdir(fullname) + + +# Class representing a particular folder + +numericprog = regex.compile('[1-9][0-9]*') +def isnumeric(str): + return numericprog.match(str) == len(str) + +class Folder: + + # Constructor + def __init__(self, mh, name): + self.mh = mh + self.name = name + if not os.path.isdir(self.getfullname()): + raise Error, 'no folder %s' % name + + # String representation + def __repr__(self): + return 'Folder(%s, %s)' % (`self.mh`, `self.name`) + + # Error message handler + def error(self, *args): + apply(self.mh.error, args) + + # Return the full pathname of the folder + def getfullname(self): + return os.path.join(self.mh.path, self.name) + + # Return the full pathname of the folder's sequences file + def getsequencesfilename(self): + return os.path.join(self.getfullname(), MH_SEQUENCES) + + # Return the full pathname of a message in the folder + def getmessagefilename(self, n): + return os.path.join(self.getfullname(), str(n)) + + # Return list of direct subfolders + def listsubfolders(self): + return self.mh.listsubfolders(self.name) + + # Return list of all subfolders + def listallsubfolders(self): + return self.mh.listallsubfolders(self.name) + + # Return the list of messages currently present in the folder. + # As a side effect, set self.last to the last message (or 0) + def listmessages(self): + messages = [] + for name in os.listdir(self.getfullname()): + if name[0] != "," and \ + numericprog.match(name) == len(name): + messages.append(string.atoi(name)) + messages.sort() + if messages: + self.last = max(messages) + else: + self.last = 0 + return messages + + # Return the set of sequences for the folder + def getsequences(self): + sequences = {} + fullname = self.getsequencesfilename() + try: + f = open(fullname, 'r') + except IOError: + return sequences + while 1: + line = f.readline() + if not line: break + fields = string.splitfields(line, ':') + if len(fields) <> 2: + self.error('bad sequence in %s: %s' % + (fullname, string.strip(line))) + key = string.strip(fields[0]) + value = IntSet(string.strip(fields[1]), ' ').tolist() + sequences[key] = value + return sequences + + # Write the set of sequences back to the folder + def putsequences(self, sequences): + fullname = self.getsequencesfilename() + f = None + for key in sequences.keys(): + s = IntSet('', ' ') + s.fromlist(sequences[key]) + if not f: f = open(fullname, 'w') + f.write('%s: %s\n' % (key, s.tostring())) + if not f: + try: + os.unlink(fullname) + except os.error: + pass + else: + f.close() + + # Return the current message. Raise KeyError when there is none + def getcurrent(self): + return min(self.getsequences()['cur']) + + # Set the current message + def setcurrent(self, n): + updateline(self.getsequencesfilename(), 'cur', str(n), 0) + + # Parse an MH sequence specification into a message list. + def parsesequence(self, seq): + if seq == "all": + return self.listmessages() + try: + n = string.atoi(seq, 10) + except string.atoi_error: + n = 0 + if n > 0: + return [n] + if regex.match("^last:", seq) >= 0: + try: + n = string.atoi(seq[5:]) + except string.atoi_error: + n = 0 + if n > 0: + return self.listmessages()[-n:] + if regex.match("^first:", seq) >= 0: + try: + n = string.atoi(seq[6:]) + except string.atoi_error: + n = 0 + if n > 0: + return self.listmessages()[:n] + dict = self.getsequences() + if dict.has_key(seq): + return dict[seq] + context = self.mh.getcontext() + # Complex syntax -- use pick + pipe = os.popen("pick +%s %s 2>/dev/null" % (self.name, seq)) + data = pipe.read() + sts = pipe.close() + self.mh.setcontext(context) + if sts: + raise Error, "unparseable sequence %s" % `seq` + items = string.split(data) + return map(string.atoi, items) + + # Open a message -- returns a Message object + def openmessage(self, n): + return Message(self, n) + + # Remove one or more messages -- may raise os.error + def removemessages(self, list): + errors = [] + deleted = [] + for n in list: + path = self.getmessagefilename(n) + commapath = self.getmessagefilename(',' + str(n)) + try: + os.unlink(commapath) + except os.error: + pass + try: + os.rename(path, commapath) + except os.error, msg: + errors.append(msg) + else: + deleted.append(n) + if deleted: + self.removefromallsequences(deleted) + if errors: + if len(errors) == 1: + raise os.error, errors[0] + else: + raise os.error, ('multiple errors:', errors) + + # Refile one or more messages -- may raise os.error. + # 'tofolder' is an open folder object + def refilemessages(self, list, tofolder, keepsequences=0): + errors = [] + refiled = {} + for n in list: + ton = tofolder.getlast() + 1 + path = self.getmessagefilename(n) + topath = tofolder.getmessagefilename(ton) + try: + os.rename(path, topath) + except os.error: + # Try copying + try: + shutil.copy2(path, topath) + os.unlink(path) + except (IOError, os.error), msg: + errors.append(msg) + try: + os.unlink(topath) + except os.error: + pass + continue + tofolder.setlast(ton) + refiled[n] = ton + if refiled: + if keepsequences: + tofolder._copysequences(self, refiled.items()) + self.removefromallsequences(refiled.keys()) + if errors: + if len(errors) == 1: + raise os.error, errors[0] + else: + raise os.error, ('multiple errors:', errors) + + # Helper for refilemessages() to copy sequences + def _copysequences(self, fromfolder, refileditems): + fromsequences = fromfolder.getsequences() + tosequences = self.getsequences() + changed = 0 + for name, seq in fromsequences.items(): + try: + toseq = tosequences[name] + new = 0 + except: + toseq = [] + new = 1 + for fromn, ton in refileditems: + if fromn in seq: + toseq.append(ton) + changed = 1 + if new and toseq: + tosequences[name] = toseq + if changed: + self.putsequences(tosequences) + + # Move one message over a specific destination message, + # which may or may not already exist. + def movemessage(self, n, tofolder, ton): + path = self.getmessagefilename(n) + # Open it to check that it exists + f = open(path) + f.close() + del f + topath = tofolder.getmessagefilename(ton) + backuptopath = tofolder.getmessagefilename(',%d' % ton) + try: + os.rename(topath, backuptopath) + except os.error: + pass + try: + os.rename(path, topath) + except os.error: + # Try copying + ok = 0 + try: + tofolder.setlast(None) + shutil.copy2(path, topath) + ok = 1 + finally: + if not ok: + try: + os.unlink(topath) + except os.error: + pass + os.unlink(path) + self.removefromallsequences([n]) + + # Copy one message over a specific destination message, + # which may or may not already exist. + def copymessage(self, n, tofolder, ton): + path = self.getmessagefilename(n) + # Open it to check that it exists + f = open(path) + f.close() + del f + topath = tofolder.getmessagefilename(ton) + backuptopath = tofolder.getmessagefilename(',%d' % ton) + try: + os.rename(topath, backuptopath) + except os.error: + pass + ok = 0 + try: + tofolder.setlast(None) + shutil.copy2(path, topath) + ok = 1 + finally: + if not ok: + try: + os.unlink(topath) + except os.error: + pass + + # Create a message, with text from the open file txt. + def createmessage(self, n, txt): + path = self.getmessagefilename(n) + backuppath = self.getmessagefilename(',%d' % n) + try: + os.rename(path, backuppath) + except os.error: + pass + ok = 0 + try: + f = open(path, "w") + while 1: + buf = txt.read(16*1024) + if not buf: + break + f.write(buf) + f.close() + ok = 1 + finally: + if not ok: + try: + os.unlink(path) + except os.error: + pass + + # Remove one or more messages from all sequeuces (including last) + def removefromallsequences(self, list): + if hasattr(self, 'last') and self.last in list: + del self.last + sequences = self.getsequences() + changed = 0 + for name, seq in sequences.items(): + for n in list: + if n in seq: + seq.remove(n) + changed = 1 + if not seq: + del sequences[name] + if changed: + self.putsequences(sequences) + + # Return the last message number + def getlast(self): + if not hasattr(self, 'last'): + messages = self.listmessages() + return self.last + + # Set the last message number + def setlast(self, last): + if last is None: + if hasattr(self, 'last'): + del self.last + else: + self.last = last + +class Message(mimetools.Message): + + # Constructor + def __init__(self, f, n, fp = None): + self.folder = f + self.number = n + if not fp: + path = f.getmessagefilename(n) + fp = open(path, 'r') + mimetools.Message.__init__(self, fp) + + # String representation + def __repr__(self): + return 'Message(%s, %s)' % (repr(self.folder), self.number) + + # Return the message's header text as a string. If an + # argument is specified, it is used as a filter predicate to + # decide which headers to return (its argument is the header + # name converted to lower case). + def getheadertext(self, pred = None): + if not pred: + return string.joinfields(self.headers, '') + headers = [] + hit = 0 + for line in self.headers: + if line[0] not in string.whitespace: + i = string.find(line, ':') + if i > 0: + hit = pred(string.lower(line[:i])) + if hit: headers.append(line) + return string.joinfields(headers, '') + + # Return the message's body text as string. This undoes a + # Content-Transfer-Encoding, but does not interpret other MIME + # features (e.g. multipart messages). To suppress to + # decoding, pass a 0 as argument + def getbodytext(self, decode = 1): + self.fp.seek(self.startofbody) + encoding = self.getencoding() + if not decode or encoding in ('7bit', '8bit', 'binary'): + return self.fp.read() + from StringIO import StringIO + output = StringIO() + mimetools.decode(self.fp, output, encoding) + return output.getvalue() + + # Only for multipart messages: return the message's body as a + # list of SubMessage objects. Each submessage object behaves + # (almost) as a Message object. + def getbodyparts(self): + if self.getmaintype() != 'multipart': + raise Error, \ + 'Content-Type is not multipart/*' + bdry = self.getparam('boundary') + if not bdry: + raise Error, 'multipart/* without boundary param' + self.fp.seek(self.startofbody) + mf = multifile.MultiFile(self.fp) + mf.push(bdry) + parts = [] + while mf.next(): + n = str(self.number) + '.' + `1 + len(parts)` + part = SubMessage(self.folder, n, mf) + parts.append(part) + mf.pop() + return parts + + # Return body, either a string or a list of messages + def getbody(self): + if self.getmaintype() == 'multipart': + return self.getbodyparts() + else: + return self.getbodytext() + + +class SubMessage(Message): + + # Constructor + def __init__(self, f, n, fp): + Message.__init__(self, f, n, fp) + if self.getmaintype() == 'multipart': + self.body = Message.getbodyparts(self) + else: + self.body = Message.getbodytext(self) + # XXX If this is big, should remember file pointers + + # String representation + def __repr__(self): + f, n, fp = self.folder, self.number, self.fp + return 'SubMessage(%s, %s, %s)' % (f, n, fp) + + def getbodytext(self): + if type(self.body) == type(''): + return self.body + + def getbodyparts(self): + if type(self.body) == type([]): + return self.body + + def getbody(self): + return self.body + + +# Class implementing sets of integers. +# +# This is an efficient representation for sets consisting of several +# continuous ranges, e.g. 1-100,200-400,402-1000 is represented +# internally as a list of three pairs: [(1,100), (200,400), +# (402,1000)]. The internal representation is always kept normalized. +# +# The constructor has up to three arguments: +# - the string used to initialize the set (default ''), +# - the separator between ranges (default ',') +# - the separator between begin and end of a range (default '-') +# The separators may be regular expressions and should be different. +# +# The tostring() function yields a string that can be passed to another +# IntSet constructor; __repr__() is a valid IntSet constructor itself. +# +# XXX The default begin/end separator means that negative numbers are +# not supported very well. +# +# XXX There are currently no operations to remove set elements. + +class IntSet: + + def __init__(self, data = None, sep = ',', rng = '-'): + self.pairs = [] + self.sep = sep + self.rng = rng + if data: self.fromstring(data) + + def reset(self): + self.pairs = [] + + def __cmp__(self, other): + return cmp(self.pairs, other.pairs) + + def __hash__(self): + return hash(self.pairs) + + def __repr__(self): + return 'IntSet(%s, %s, %s)' % (`self.tostring()`, + `self.sep`, `self.rng`) + + def normalize(self): + self.pairs.sort() + i = 1 + while i < len(self.pairs): + alo, ahi = self.pairs[i-1] + blo, bhi = self.pairs[i] + if ahi >= blo-1: + self.pairs[i-1:i+1] = [ + (alo, max(ahi, bhi))] + else: + i = i+1 + + def tostring(self): + s = '' + for lo, hi in self.pairs: + if lo == hi: t = `lo` + else: t = `lo` + self.rng + `hi` + if s: s = s + (self.sep + t) + else: s = t + return s + + def tolist(self): + l = [] + for lo, hi in self.pairs: + m = range(lo, hi+1) + l = l + m + return l + + def fromlist(self, list): + for i in list: + self.append(i) + + def clone(self): + new = IntSet() + new.pairs = self.pairs[:] + return new + + def min(self): + return self.pairs[0][0] + + def max(self): + return self.pairs[-1][-1] + + def contains(self, x): + for lo, hi in self.pairs: + if lo <= x <= hi: return 1 + return 0 + + def append(self, x): + for i in range(len(self.pairs)): + lo, hi = self.pairs[i] + if x < lo: # Need to insert before + if x+1 == lo: + self.pairs[i] = (x, hi) + else: + self.pairs.insert(i, (x, x)) + if i > 0 and x-1 == self.pairs[i-1][1]: + # Merge with previous + self.pairs[i-1:i+1] = [ + (self.pairs[i-1][0], + self.pairs[i][1]) + ] + return + if x <= hi: # Already in set + return + i = len(self.pairs) - 1 + if i >= 0: + lo, hi = self.pairs[i] + if x-1 == hi: + self.pairs[i] = lo, x + return + self.pairs.append((x, x)) + + def addpair(self, xlo, xhi): + if xlo > xhi: return + self.pairs.append((xlo, xhi)) + self.normalize() + + def fromstring(self, data): + import string, regsub + new = [] + for part in regsub.split(data, self.sep): + list = [] + for subp in regsub.split(part, self.rng): + s = string.strip(subp) + list.append(string.atoi(s)) + if len(list) == 1: + new.append((list[0], list[0])) + elif len(list) == 2 and list[0] <= list[1]: + new.append((list[0], list[1])) + else: + raise ValueError, 'bad data passed to IntSet' + self.pairs = self.pairs + new + self.normalize() + + +# Subroutines to read/write entries in .mh_profile and .mh_sequences + +def pickline(file, key, casefold = 1): + try: + f = open(file, 'r') + except IOError: + return None + pat = key + ':' + if casefold: + prog = regex.compile(pat, regex.casefold) + else: + prog = regex.compile(pat) + while 1: + line = f.readline() + if not line: break + if prog.match(line) >= 0: + text = line[len(key)+1:] + while 1: + line = f.readline() + if not line or \ + line[0] not in string.whitespace: + break + text = text + line + return string.strip(text) + return None + +def updateline(file, key, value, casefold = 1): + try: + f = open(file, 'r') + lines = f.readlines() + f.close() + except IOError: + lines = [] + pat = key + ':\(.*\)\n' + if casefold: + prog = regex.compile(pat, regex.casefold) + else: + prog = regex.compile(pat) + if value is None: + newline = None + else: + newline = '%s: %s\n' % (key, value) + for i in range(len(lines)): + line = lines[i] + if prog.match(line) == len(line): + if newline is None: + del lines[i] + else: + lines[i] = newline + break + else: + if newline is not None: + lines.append(newline) + tempfile = file + "~" + f = open(tempfile, 'w') + for line in lines: + f.write(line) + f.close() + os.rename(tempfile, file) + + +# Test program + +def test(): + global mh, f + os.system('rm -rf $HOME/Mail/@test') + mh = MH() + def do(s): print s; print eval(s) + do('mh.listfolders()') + do('mh.listallfolders()') + testfolders = ['@test', '@test/test1', '@test/test2', + '@test/test1/test11', '@test/test1/test12', + '@test/test1/test11/test111'] + for t in testfolders: do('mh.makefolder(%s)' % `t`) + do('mh.listsubfolders(\'@test\')') + do('mh.listallsubfolders(\'@test\')') + f = mh.openfolder('@test') + do('f.listsubfolders()') + do('f.listallsubfolders()') + do('f.getsequences()') + seqs = f.getsequences() + seqs['foo'] = IntSet('1-10 12-20', ' ').tolist() + print seqs + f.putsequences(seqs) + do('f.getsequences()') + testfolders.reverse() + for t in testfolders: do('mh.deletefolder(%s)' % `t`) + do('mh.getcontext()') + context = mh.getcontext() + f = mh.openfolder(context) + do('f.listmessages()') + do('f.getcurrent()') + + +if __name__ == '__main__': + test() diff --git a/slime-0.11/slime b/slime-0.11/slime new file mode 100644 index 0000000..d699796 --- /dev/null +++ b/slime-0.11/slime @@ -0,0 +1,5 @@ +#!/bin/sh + +PYTHONPATH="@slimedir@:$PYTHONPATH" +export PYTHONPATH +python @slimedir@/ui.py diff --git a/slime-0.11/slime.html b/slime-0.11/slime.html new file mode 100644 index 0000000..575ed0d --- /dev/null +++ b/slime-0.11/slime.html @@ -0,0 +1,112 @@ +<html> +<head> +<title>Slime - the Stupid little mailer + + + +

Slime - the Stupid little mailer

+ +

See the README for more info, and Changes, if you've seen any previous version. +The manual is not +yet written. + +

Screenshots: main window (jpeg, 25 kB), +message window (jpeg, 25 kB). These are +of version 0.2, or something. + +

You need the Slime source package, +my pyliw package (version 0.3), +Mitch Chapman's UITools, and +Python. Note +that version 0.9 of Slime needs version 0.3 of pyliw (older versions +had a bug, now fixed). + + +

A small collection of links

+ +

Message formats: +

+ +

Protocol related stuff: +

+ +

Mailbox formats +

+ +

Other mailers and mail-related software: +

+ +

Other stuff +

+ + + diff --git a/slime-0.11/slime_abstract.py b/slime-0.11/slime_abstract.py new file mode 100644 index 0000000..4de3b82 --- /dev/null +++ b/slime-0.11/slime_abstract.py @@ -0,0 +1,541 @@ +"""Abstract class definition for mail folders and messages. + +This module defines two classes, SlimeFolder and SlimeMessage, which +define an interface to mail folders and messages, respectively. +Classes derived from SlimeFolder and SlimeMessage give access to +different kinds of folders, e.g., slime_mh.py defines SlimeFolder_MH +and SlimeMessage_MH for MH folders. + +Other parts of Slime use the interface defined by this module +to access folders and messags, without having to care about the +details of each folder type. + +The interface is intended to become generic enough for any kind of +folder, including all types of local files, and remote ones as +defined by POP and IMAP. It will certainly require refinement. + +Lars Wirzenius """ + + +import string, time, regsub +import StringIO + + +# +# Exceptions that may be raised by SlimeFolder and SlimeMessage subclasses. +# +UnimplementedError = "UnimplementedError" +Error = "Error" + + +def notdone(): + """Raise hell, because subclass didn't implement all methods.""" + raise UnimplementedError, "Subclass didn't implement all methods." + +class SlimeFolder: + """Abstract interface to a mail folder.""" + + def __init__(self): + self._is_open = 0 + self._name = None + self._messages = [] + self._last_new_messages = [] + self._last_deleted_messages = [] + self._subfolders = [] + self._last_new_folders = [] + self._last_deleted_folders = [] + self._threads = [] + self._dirty = 0 + + def __repr__(self): + """Return textual name of subfolder.""" + return self._name + + def get_nice_name(self): + """Return a user-friendly representation of the name.""" + x = string.split(self._name, "/") + return x[-1] + + def is_clean(self): + """Is this folder dirty?""" + return not self._dirty + + def make_dirty(self): + """Mark the folder as dirty; commit needs to be done.""" + self._dirty = 1 + + def make_clean(self): + """Mark the folder as clean; commit doesn't need to done.""" + self._dirty = 0 + + def open(self): + """Open the folder. Operations below require an open folder.""" + notdone() + + def close(self): + """Close the folder. + + This should call msg.uncache_headers for every message.""" + notdone() + + def is_open(self): + """Is this folder open?""" + return self._is_open + + def _assert_open(self): + """Make sure this folder is open.""" + if not self._is_open: + raise Error, "Using closed folder " + repr(self) + + def rescan_subfolders(self): + """Check for changes to subfolders. + + This method sets self._subfolders.""" + notdone() + + def rescan_messages(self): + """Check for changes to messages in folder. + + This method sets self._messages.""" + notdone() + + def list_all_messages(self): + """Return list of SlimeMessages for all messages here.""" + self._assert_open() + return self._messages + + def add_message(self, file): + """Add message in file to folder, return new SlimeMessage. + + This will change the folder immediately (i.e., doesn't + wait until commit).""" + notdone() + + def move_message_here(self, msg): + """Move a message from another folder to this folder. + + This will change the target folder immediately (i.e., doesn't + wait until commit), but the message won't be deleted from + the other folder until commit.""" + self._assert_open() + newmsg = self.copy_message_here(msg) + msg.delete_self() + return newmsg + + def copy_message_here(self, msg): + """Copy a message from another folder to this folder. + + This will change the folder immediately (i.e., doesn't + wait until commit).""" + self._assert_open() + txt = msg.getfulltext() + return self.add_message(StringIO.StringIO(txt)) + + def list_all_threads(self): + """Return the thread forest of a folder.""" + self._assert_open() + if not self._threads: + self.rethread_folder() + return self._threads + + def rethread_folder(self): + """Build new thread data structure.""" + self._assert_open() + children = {} + ids = {} + roots = [] + for msg in self._messages: + parent = msg.get_parent_id() + date = msg.get_linear_time() + subj = string.lower(msg["subject"]) + subj = regsub.gsub("^re: *", "", subj) + if parent: + if children.has_key(parent): + children[parent].append(date, subj, msg) + else: + children[parent] = [(date, subj, msg)] + else: + roots.append(date, subj, msg) + id = msg["message-id"] + if id: + ids[id] = msg + for id in children.keys(): + children[id].sort() + if not ids.has_key(id): + for date, subj, msg in children[id]: + roots.append(date, subj, msg) + + subjects = {} + for date, subj, msg in roots: + subjects[subj] = (date, subj, msg) + aux_roots = [] + for subj in subjects.keys(): + aux_roots.append(subjects[subj]) + aux_roots.sort() + real_roots = [] + for date, subj, msg in aux_roots: + list = [] + for date2, subj2, msg2 in roots: + if subj == subj2: + list.append(date2, msg2) + list.sort() + real_roots = real_roots + list + + self._threads = [] + for date, msg in real_roots: + self.append_thread(0, msg, children) + + def append_thread(self, level, msg, children): + """Append msg to self._threads, and then all its children.""" + self._threads.append(level, msg) + id = msg["message-id"] + if children.has_key(id): + level = level + 1 + for date, subj, child in children[id]: + self.append_thread(level, child, children) + + def get_msg_level(self, msg): + """Return indentation level of message in thread display.""" + self._assert_open() + for i, m in self._threads: + if m == msg: + return i + return 0 + + def list_all_subfolders(self): + """Return list of SlimeFolder objects for subfolders.""" + self._assert_open() + return self._subfolders + + def add_subfolder(self, name): + """Add or create a new subfolder named `name'.""" + notdone() + + def move_subfolder_here(self, subfolder_object): + """Move the subfolder to this folder.""" + # xxx placeholder + notdone() + + def delete_self(self): + """Remove this folder.""" + # xxx place holder + notdone() + + def rename_self(self, new_name): + """Rename this folder to have a new name.""" + # xxx place holder + notdone() + + def commit_changes(self): + """Commit all changes to folder and its messages.""" + notdone() + +_interned_strings = {} +def _intern_string(str): + """Make sure there is only one instance of a given string.""" + try: + str = _interned_strings[str] + except KeyError: + _interned_strings[str] = str + return str + +interesting_headers = ["from", "subject", "to", "cc", "date", "status", + "in-reply-to", "message-id", "references"] + +class SlimeMessage: + """Abstract interface to a single e-mail message. + + Status: Every message has a status, which is a string consisting + of single-character flags (graphical flags only): + + N = new message + D = message should be deleted at commit time + (still searching for good info on the chars) + + """ + + # Note that some operations may require changes to the folder + # the message resides in. Changes are commited only after the + # folder is commited. + + + def __init__(self, folder): + """Initialize the message. + + Derived classes must call this.""" + self._folder = folder + self._msg = None + self._status = "" + self._cached_headers = {} + self._cached_date = None + self._open_count = 0 + + def _open(self): + """Open the message. Done automatically in background.""" + notdone() + + def _close(self): + """Close the message. Done automatically in background.""" + notdone() + + def _assert_open(self): + """Assert that the folder this message is in is open.""" + self._folder._assert_open() + + def uncache_headers(self): + """Forget about cached headers.""" + self._assert_open() + self._cached_headers = {} + + def get_status(self): + """Return status of this message (read/unread, etc).""" + self._assert_open() + return self._status + + def _clean_status(self, status): + cleaned = "" + for c in "ND": + if c in status: + cleaned = cleaned + c + return cleaned + + def set_status(self, new_status): + """Change the status of this message, don't mark folder dirty.""" + self._assert_open() + self._status = self._clean_status(new_status) + + def has_status(self, statuschar): + """Does the message status have statuschar?""" + self._assert_open() + return string.find(self._status, statuschar) >= 0 + + def give_status(self, statuschar): + """Add statuschar to status of message.""" + self._assert_open() + cleaned = self._clean_status(statuschar) + if statuschar and string.find(self._status, cleaned) == -1: + self._folder.make_dirty() + self._status = self._status + cleaned + + def remove_status(self, statuschar): + """Remove statuschar from status of message.""" + self._assert_open() + if statuschar: + i = string.find(self._status, statuschar) + if i >= 0: + self._folder.make_dirty() + self._status = self._status[:i] + \ + self._status[i+1:] + + def change_text(self, file): + """Change the contents of the message to that in file. + + Note that not all folder formats support this + functionality easily. It is needed for the draft + folder only, and that one is always an MH folder, + so it gives an error for other folder types.""" + raise Error, "change_text is not supported for this folder" + + def delete_self(self): + """Delete this message from the folder it is in.""" + self._assert_open() + self.give_status("D") + self._folder.make_dirty() + + def undelete_self(self): + """Undelete this message from the folder it is in.""" + self._assert_open() + self.remove_status("D") + + def _find_ids(self, str): + """Return list of Message-ID's in a string.""" + str = string.join(string.split(str, "\n"), " ") + str = string.join(string.split(str, "\t"), " ") + str = regsub.sub("^[^<]*", "", str) + str = regsub.gsub(">[^<]*<", "> <", str) + str = regsub.sub(">[^<]*$", ">", str) + str = regsub.sub(" *", " ", str) + return string.split(str) + + def get_parent_id(self): + """Return the Message-ID of the message this is a reply to.""" + self._assert_open() + irt = self["in-reply-to"] + if irt: + ids = self._find_ids(irt) + if ids: + return ids[0] + refs = self["references"] + if refs: + ids = self._find_ids(refs) + if ids: + return ids[-1] + return "" + + def get_linear_time(self): + """Return the Date: header converted to seconds since epoch.""" + self._assert_open() + date = self.getdate("date") + if date is None: + return 0 + return time.mktime(date) + + def getfulltext(self): + """Return full text of message.""" + return self.getheadertext() + "\n" + self.getbodytext(0) + + # + # The following are the interface provided together by + # rfc822.Message, mimetools.Message, and mhlib.Message. + # + + def getallmatchingheaders(self, name): + self._open() + x = self._msg.getallmatchingheaders(name) + self._close() + return x + def getfirstmatchingheader(self, name): + self._open() + x = self._msg.getfirstmatchingheader(name) + self._close() + return x + def getrawheader(self, name): + self._open() + x = self._msg.getrawheader(name) + self._close() + return x + def getheader(self, name): + self._open() + x = self._msg.getheader(name) + self._close() + return x + def getaddr(self, name): + self._open() + x = self._msg.getaddr(name) + self._close() + return x + def getaddrlist(self, name): + self._open() + x = self._msg.getaddrlist(name) + self._close() + return x + def getdate(self, name): + if string.lower(name) == "date": + if not self._cached_date: + self._open() + self._cache_headers() + self._close() + return self._cached_date + else: + self._open() + date = self._msg.getdate(name) + self._close() + return date + def __len__(self): + self._open() + x = self._msg.__len__() + self._close() + return x + def _cache_headers(self): + self._assert_open() + for h in interesting_headers: + try: + x = self._msg.__getitem__(h) + except KeyError: + x = "" + x = _intern_string(x) + self._cached_headers[h] = x + self._cached_date = self._msg.getdate("date") + if not self._cached_date: + self._cached_date = (1970,1,1,0,0,0,3,1,0) + def __getitem__(self, name): + # Note: we assume it is benign to return "" for missing header. + name = string.lower(name) + if self._cached_headers.has_key(name): + return self._cached_headers[name] + self._open() + try: + x = self._msg.__getitem__(name) + except KeyError: + x = "" + x = _intern_string(x) + self._cached_headers[name] = x + self._close() + return x + def has_key(self, name): + self._open() + x = self._msg.has_key(name) + self._close() + return x + def keys(self): + self._open() + x = self._msg.keys() + self._close() + return x + def values(self): + self._open() + x = self._msg.values() + self._close() + return x + def items(self): + self._open() + x = self._msg.items() + self._close() + return x + + def getplist(self): + self._open() + x = self._msg.getplist() + self._close() + return x + def getparam(self, name): + self._open() + x = self._msg.getparam(name) + self._close() + return x + def getparamnames(self): + self._open() + x = self._msg.getparamnames() + self._close() + return x + def getencoding(self): + self._open() + x = self._msg.getencoding() + self._close() + return x + def gettype(self): + self._open() + x = self._msg.gettype() + self._close() + return x + def getmaintype(self): + self._open() + x = self._msg.getmaintype() + self._close() + return x + def getsubtype(self): + self._open() + x = self._msg.getsubtype() + self._close() + return x + + def getheadertext(self, pred = None): + self._open() + x = self._msg.getheadertext(pred) + self._close() + return x + def getbodytext(self, decode = 1): + self._open() + x = self._msg.getbodytext(decode) + self._close() + return x + def getbodyparts(self): + self._open() + x = self._msg.getbodyparts() + self._close() + return x + def getbody(self): + self._open() + x = self._msg.getbody() + self._close() + return x diff --git a/slime-0.11/slime_draft.py b/slime-0.11/slime_draft.py new file mode 100644 index 0000000..dfc1053 --- /dev/null +++ b/slime-0.11/slime_draft.py @@ -0,0 +1,52 @@ +"""Handle the draft folder.""" + +import os, slime_mh +import mhlib2 +mhlib = mhlib2 + +SLIME_DIR = "~/.slime" +DRAFT_FOLDER = "~/.slime/internal_folders" +DRAFT_PROFILE = "~/.slime/.internal_mh_profile" +slime_dir = None +draft_path = None +draft_profile = None + +draft_mh = None +draft_folder = None + +def make_draft_folder(): + global draft_mh, draft_folder, draft_path, draft_profile, slime_dir + + if not draft_path: + slime_dir = os.path.expanduser(SLIME_DIR) + draft_path = os.path.expanduser(DRAFT_FOLDER) + draft_profile = os.path.expanduser(DRAFT_PROFILE) + if not os.path.isdir(draft_path): + os.mkdir(slime_dir) + os.mkdir(draft_path) + os.mkdir(os.path.join(draft_path, "drafts")) + f = open(os.path.join(draft_path, ".folders"), "w") + f.write("drafts\n") + f.close() + if not os.path.isfile(draft_profile): + f = open(draft_profile, "w") + f.close() + + draft_mh = mhlib.MH(draft_path, draft_profile) + draft_folder = SlimeFolder_Draft('drafts', draft_mh) + return draft_folder + +class SlimeFolder_Draft(slime_mh.SlimeFolder_MH): + def close(self): + pass + + +def main(): + f = draft_top_folder() + f.open() + f.rescan_subfolders() + print f.list_all_subfolders() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_folderinfo.py b/slime-0.11/slime_folderinfo.py new file mode 100644 index 0000000..041b2f1 --- /dev/null +++ b/slime-0.11/slime_folderinfo.py @@ -0,0 +1,77 @@ +import helpers, string, os + +_folderinfo = {} + +def load_folderinfo(filename): + global _folderinfo + try: + f = open(filename, "r") + except IOError: + _folderinfo = {} + return + while 1: + line = f.readline() + if not line: + break + foldername = string.split(line, "\n")[0] + dict = {} + while 1: + line = f.readline() + if not line or line == "\n": + break + parts = string.split(line, " ", 1) + field = parts[0] + value = string.split(parts[1], "\n")[0] + dict[field] = value + _folderinfo[foldername] = dict + f.close() + +def save_folderinfo(filename): + text = [] + for foldername in _folderinfo.keys(): + text.append("%s\n" % foldername) + dict = _folderinfo[foldername] + for field in dict.keys(): + text.append("%s %s\n" % (field, dict[field])) + text.append("\n") + text = string.join(text, "") + helpers.write_text_to_named_file(filename + ".new", text) + try: + os.remove(filename) + except os.error: + pass + os.rename(filename + ".new", filename) + +def get_folderinfo(foldername, field): + try: + return _folderinfo[foldername][field] + except KeyError: + return None + +def set_folderinfo(foldername, field, value): + try: + dict = _folderinfo[foldername] + except KeyError: + dict = {} + _folderinfo[foldername] = dict + dict[field] = value + +def get_folders_with_info(field, value): + list = [] + for foldername in _folderinfo.keys(): + try: + if _folderinfo[foldername][field] == value: + list.append(foldername) + except KeyError: + pass + return list + +def main(): + load_folderinfo("folderinfo.test") + print get_folderinfo("/home/liw/Mail/inbox", "inbox") + set_folderinfo("/home/liw/Mail/inbox", "inbox", "no") + print get_folderinfo("/home/liw/Mail/inbox", "inbox") + save_folderinfo("folderinfo.test") + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_mh.py b/slime-0.11/slime_mh.py new file mode 100644 index 0000000..a4571d6 --- /dev/null +++ b/slime-0.11/slime_mh.py @@ -0,0 +1,245 @@ +"""Implement interface to MH folders.""" + +from slime_abstract import * +import os + +import mhlib2 +mhlib = mhlib2 # kludge until Python accepts my modified mhlib.py +import StringIO2 +StringIO = StringIO2 + +default_mh = None + +def mh_top_folder(): + """Return SlimeFolder with top level MH folders.""" + global default_mh + if not default_mh: + default_mh = mhlib.MH() + mhroot = SlimeFolder_MHroot(default_mh) + mhroot.open() + mhroot.rescan_subfolders() + if mhroot.list_all_subfolders(): + mhroot.close() + return mhroot + mhroot.close() + return None + +class SlimeFolder_MHroot(SlimeFolder): + """Root of all MH folders.""" + + def __init__(self, mh): + SlimeFolder.__init__(self) + self._mh = mh + self._name = mh.getpath() + self._subfolders = [] + + def open(self): + self._is_open = 1 + self.rescan_subfolders() + + def close(self): + self._is_open = 0 + + def commit_changes(self): + self._assert_open() + + def rescan_messages(self): + pass + + def rescan_subfolders(self): + self._assert_open() + list = [] + for name in self._mh.listfolders(): + if not os.path.isabs(name): + name = os.path.join(self._mh.getpath(), name) + new = None + for subfolder in self._subfolders: + if name == repr(subfolder): + new = subfolder + break + if new is None: + new = SlimeFolder_MH(name, self._mh) + list.append(new) + self._subfolders = list + +class SlimeFolder_MH(SlimeFolder): + """Abstract interface to an MH mail folder.""" + + def __init__(self, name, mh): + SlimeFolder.__init__(self) + self.mh = mh + self._subfolders = [] + self._name = os.path.join(mh.getpath(), name) + self.mh_folder = self.mh.openfolder(self._name) + self._last_message_scan = 0 + + def open(self): + self._is_open = 1 + self._old_numbers = [] + + def close(self): + """Close the folder.""" + for msg in self._messages: + msg.uncache_headers() + self._is_open = 0 + + def rescan_messages(self): + self._assert_open() + + dirname = self.mh_folder.getfullname() + dirmtime = os.stat(dirname)[8] + if dirmtime == self._last_message_scan: + return + self._last_message_scan = dirmtime + + seq = self.mh_folder.getsequences() + if seq.has_key("unseen"): + unseen = seq["unseen"] + else: + unseen = [] + + numbers = self.mh_folder.listmessages() + list = [] + for n in numbers: + if n in self._old_numbers: + for msg in self._messages: + if msg._id == n: + list.append(msg) + break + else: + msg = SlimeMessage_MH(self, n) + if n in unseen: + msg.set_status("N") + list.append(msg) + self._messages = list + self._old_numbers = numbers + self._threads = [] + + def rescan_subfolders(self): + oldlist = [] + for old in self._subfolders: + oldlist.append(repr(old)) + list = [] + for name in self.mh_folder.listsubfolders(): + fullname = os.path.join(self.mh.getpath(), name) + try: + i = oldlist.index(fullname) + new = self._subfolders[i] + except ValueError: + new = SlimeFolder_MH(name, self.mh) + list.append(new) + self._subfolders = list + + def add_message(self, file): + self._assert_open() + f = self.mh_folder + n = f.getlast() + 1 + f.createmessage(n, file) + msg = SlimeMessage_MH(self, n) + self._messages.append(msg) + self._old_numbers.append(n) + return msg + + def commit_changes(self): + if self._dirty: + self._assert_open() + unseen = [] + list = [] + for msg in self._messages: + if msg.has_status("D"): + list.append(msg._id) + elif msg.has_status("N"): + unseen.append(msg._id) + self.mh_folder.removemessages(list) + + seq = self.mh_folder.getsequences() + if seq.has_key("unseen"): + unseen.sort() + seq["unseen"] = unseen + self.mh_folder.putsequences(seq) + + self._dirty = 0 + + self.rescan_messages() + +class SlimeMessage_MH(SlimeMessage): + """A SlimeMessage interface to mhlib.Message.""" + + def __init__(self, folder, id): + SlimeMessage.__init__(self, folder) + self._msg = None + self._id = id + + def _open(self): + self._folder._assert_open() + self._open_count = self._open_count + 1 + if not self._msg: + self._msg = mhlib.Message(self._folder.mh_folder, + self._id) + # make sure stuff is cached + if not self._cached_date: + self._cache_headers() + + def _close(self): + self._folder._assert_open() + self._open_count = self._open_count - 1 + if self._open_count == 0: + self._msg = None + + def change_text(self, file): + self._folder._assert_open() + if self._open_count != 0: + raise Error, "change_text needs closed message" + filename = self._folder.mh_folder.getmessagefilename(self._id) + newname = filename + '.new' + f = open(newname, "w") + while 1: + buf = file.read(16*1024) + if not buf: + break + f.write(buf) + f.close() + os.rename(newname, filename) + self.uncache_headers() + +def main(): + root = mh_top_folder() + if not root: + print "no MH root" + else: +# root.open() +# root.rescan_subfolders() +# print root.list_all_subfolders() +# root.close() + foo(root) + +def foo(folder): + folder.open() + folder.rescan_subfolders() + folder.close() + folder.open() + folder.rescan_subfolders() + print folder + for sub in folder.list_all_subfolders(): + foo(sub) + folder.close() + +def profile_main(): + import time + f = mh_top_folder() + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + if sub.get_nice_name() == "cola-incoming": + sub.open() + print "computing threads" + start = time.time() + sub.rescan_messages() + sub.list_all_threads() + end = time.time() + print "threads computed", end - start + sub.close() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_pgpmime.py b/slime-0.11/slime_pgpmime.py new file mode 100644 index 0000000..e02d06d --- /dev/null +++ b/slime-0.11/slime_pgpmime.py @@ -0,0 +1,168 @@ +"""PGP/MIME interface for Slime. + +Parts of this module is based on the algorithms in pgpExec.tcl in +Exmh 2.0gamma. + +""" + +from slime_abstract import * +from slime_mh import * +from slime_unix import * +from ui_config import config +import slime_send, helpers + +import tempfile, regex, mimetools, os, FCNTL + + +# Exception names for results of PGP stuff +SecretMissing = "slime_pgp.SecretMissing" +PublicMissing = "slime_pgp.PublicMissing" +GoodUntrustedSignature = "slime_pgp.GoodUntrustedSignature" +BadUntrustedSignature = "slime_pgp.BadUntrustedSignature" +BadTrustedSignature = "slime_pgp.BadTrustedSignature" +OtherResult = "slime_pgp.OtherResult" + +# Regular expressions for interpreting the PGP results +secret_missing_pat = \ + regex.compile("This.*do not have the secret key.*file", regex.casefold) +public_missing_pat = \ + regex.compile("Can't.*can't check signature integrity", regex.casefold) +good_signature_pat = \ + regex.compile("Good signature", regex.casefold) +confidence_pat = \ + regex.compile("WARNING:.*confidence", regex.casefold) +doesnt_match_pat = \ + regex.compile("WARNING:.*doesn't match", regex.casefold) + +def interpret_pgp(exit_code, stdout, stderr): + """Interpret the output of PGP, raise exception for errors.""" + tuple = (exit_code, stdout, stderr) + + if secret_missing_pat.search(stdout) >= 0: + raise SecretMissing, tuple + elif public_missing_pat.search(stdout) >= 0: + raise PublicMissing, tuple + elif good_signature_pat.search(stdout) >= 0: + if confidence_pat.search(stdout) >= 0: + raise GoodUntrustedSignature, tuple + else: + return # Yay! It's OK! Way to go, man! + elif doesnt_match_pat.search(stdout) >= 0: + if confidence_pat.search(stdout) >= 0: + raise BadUntrustedSignature, tuple + else: + raise BadTrustedSignature, tuple + else: + raise OtherResult, tuple + +def write_to_named_file(filename, msg): + helpers.write_text_to_named_file(filename, + msg.getheadertext() + "\n" + msg.getbodytext()[:-1]) + # XXX note that the [:-1] part is a kludge -- exmh + # creates an extra newline or something + +def check_signature(msg): + """Check signature of a PGP/MIME message, raise exception for errors.""" + if msg.gettype() != "multipart/signed" or \ + not "protocol" in msg.getparamnames() or \ + msg.getparam("protocol") != "application/pgp-signature" or \ + not "micalg" in msg.getparamnames() or \ + msg.getparam("micalg") != "pgp-md5": + return None + + parts = msg.getbodyparts() + msgtxt = tempfile.mktemp() + msgsig = tempfile.mktemp() + + write_to_named_file(msgtxt, parts[0]) + write_to_named_file(msgsig, parts[1]) + + exit_code, stdout, stderr = \ + helpers.run_pgp(config["pgp-path"], "", "", [], [msgsig, msgtxt]) + os.remove(msgtxt) + os.remove(msgsig) + interpret_pgp(exit_code, stdout, stderr) + +def filter_content_headers(headername): + headername = string.lower(headername) + return not headername in ["content-type", "content-transfer-encoding"] + +def make_signature(msg, username, password): + """Return string with text of signed message.""" + + body_type = msg["content-type"] + body_xfer = msg["content-transfer-encoding"] + if not body_type or body_type == "text/plain": + charset = slime_send.deduce_charset(msg.getbodytext()) + body_type = "text/plain; charset=%s" % charset + if charset == "us-ascii": + body_xfer = "7bit" + else: + body_xfer = "8bit" + + msg_headers = msg.getheadertext(filter_content_headers) + msg_body = msg.getbodytext(decode=0) + + boundary = mimetools.choose_boundary() + msg_type = 'multipart/signed; boundary=%s;\n' + \ + ' micalg=pgp-md5; protocol="application/pgp-signature"' + msg_type = msg_type % boundary + + part1_headers = "Content-Type: %s\nContent-Transfer-Encoding: %s\n" % \ + (body_type, body_xfer) + part1 = part1_headers + "\n" + msg_body + + exit_code, stdout, stderr = \ + helpers.run_pgp(config["pgp-path"], username, password, + ["-sbatf"], [], stdin=part1) + if exit_code != 0: + raise OtherResult, (exit_code, stdout, stderr) + + part2_headers = "Content-Type: application/pgp\n" + part2 = part2_headers + "\n" + stdout + + return "%sContent-Type: %s\n\n--%s\n%s\n--%s\n%s\n--%s--\n" % \ + (msg_headers, msg_type, boundary, part1, + boundary, part2, boundary) + + +def test_check_signature(): + mhroot = mh_top_folder() + mhroot.open() + mhroot.rescan_subfolders() + for sub in mhroot.list_all_subfolders(): + if sub.get_nice_name() == "sent-mail": + sub.open() + sub.rescan_messages() + list = sub.list_all_messages()[:1] + if list: + msg = list[0] + result = check_signature(msg) + print "Check result:" + print result + sub.close() + mhroot.close() + +def test_make_signature(): + import sys + mhroot = mh_top_folder() + mhroot.open() + mhroot.rescan_subfolders() + for sub in mhroot.list_all_subfolders(): + if sub.get_nice_name() == "drafts": + sub.open() + sub.rescan_messages() + list = sub.list_all_messages()[:1] + if list: + msg = list[0] + print "Enter username:" + username = sys.stdin.readline()[:-1] + print "Enter password:" + password = sys.stdin.readline()[:-1] + result = make_signature(msg, username, password) + print result + sub.close() + mhroot.close() + +if __name__ == "__main__": + test_check_signature() diff --git a/slime-0.11/slime_root.py b/slime-0.11/slime_root.py new file mode 100644 index 0000000..c265801 --- /dev/null +++ b/slime-0.11/slime_root.py @@ -0,0 +1,77 @@ +"""Implement the root folder.""" + +from slime_abstract import SlimeFolder, Error + +class SlimeFolder_Root(SlimeFolder): + """The root folder that lists all folder collections.""" + + def __init__(self): + SlimeFolder.__init__(self) + self._name = "Root folder" + + def open(self): + self._is_open = 1 + + def close(self): + self._is_open = 0 + + def rescan_subfolders(f): + self._assert_open() + + def rescan_messages(f): + self._assert_open() + + def add_message(self, message): + raise Error, "Can't add messages to root folder." + + def delete_message(self, id): + raise Error, "Can't delete messages from root folder." + + def open_message(self, id): + raise Error, "Can't open messages in root folder." + + def add_subfolder(self, object): + """Add a subfolder `object'. + + Note that the interface is different from SlimeFolder: + the root folder can't know the type of the folder, so + it gets an object, instead of just the name.""" + self._assert_open() + self._subfolders.append(object) + + def delete_self(self): + raise Error, "Can't remove root folder." + + def rename_self(self, new_name): + raise Error, "Can't rename root folder." + + def commit_changes(self): + # For the root folder, everything is happens at once. + self._assert_open() + + +def main(): + """Test the root folder.""" + from slime_mh import * + from slime_unix import * + + r = SlimeFolder_Root() + + r.open() + r.add_subfolder(SlimeFolder_Unix(os.path.expanduser("~/mail"))) + + for f in r.list_all_subfolders(): + print repr(f) + f.open() + f.rescan_subfolders() + f.rescan_messages() + for m in f.list_all_threads()[:2]: + print m["subject"] + print "Subfolders:", f.list_all_subfolders() + f.close() + print + + r.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_send.py b/slime-0.11/slime_send.py new file mode 100644 index 0000000..3e6c3b8 --- /dev/null +++ b/slime-0.11/slime_send.py @@ -0,0 +1,171 @@ +import smtplib, string, regex, regsub, pwd, os, socket, rfc822, time +import helpers, StringIO +from ui_config import config + +draft_txt = """From: %s +Reply-to: %s +To: +Cc: +Subject: + +%s""" + +reply_txt = """From: %s +Reply-to: %s +To: %s +Cc: %s +Subject: %s%s +In-Reply-To: %s +References: %s + +%s: +%s + +%s""" + +_user_address = None + +def deduce_user_address(): + global _user_address + if config["from-address"]: + return config["from-address"] + if _user_address is None: + userinfo = pwd.getpwuid(os.getuid()) + host = socket.gethostname() + ip = socket.gethostbyname(host) + fqdn = socket.gethostbyaddr(ip)[0] + _user_address = "%s@%s" % (userinfo[0], fqdn) + if userinfo[4]: + x = regsub.sub(",*$", "", userinfo[4]) + _user_address = "%s <%s>" % (x, _user_address) + return _user_address + +def get_signature_text(): + try: + pathname = os.path.expanduser(config["signature-file"]) + sig = "-- \n" + helpers.read_text_from_named_file(pathname) + except IOError: + sig = "" + return sig + +def make_new_message_text(): + return draft_txt % (deduce_user_address(), config["reply-to"], + get_signature_text()) + +def remove_duplicate_addrs(recipients, used_addrs): + result = [] + for recipient in string.split(recipients, ","): + name, addr = rfc822.parseaddr(recipient) + if not addr in used_addrs: + result.append(recipient) + used_addrs.append(addr) + return string.join(result, ","), used_addrs + +def make_reply_message_text(msg): + user_addr = deduce_user_address() + reply_to = config["reply-to"] + to_addrs = msg["to"] + if to_addrs: + to_addrs = msg["from"] + ", " + to_addrs + else: + to_addrs = msg["from"] + cc_addrs = msg["cc"] + + user_addr_name, user_addr_addr = rfc822.parseaddr(user_addr) + reply_to_name, reply_to_addr = rfc822.parseaddr(reply_to) + + if user_addr_addr and reply_to_addr: + used_addrs = [user_addr_addr, reply_to_addr] + elif user_addr_addr: + used_addrs = [user_addr_addr] + elif reply_to_addr: + used_addrs = [reply_to_addr] + else: + used_addrs = [] + to_addrs, used_addrs = remove_duplicate_addrs(to_addrs, used_addrs) + cc_addrs, used_addrs = remove_duplicate_addrs(cc_addrs, used_addrs) + + subject = msg["subject"] + if string.lower(subject[:3]) == "re:": + re = "" + else: + re = "Re: " + + msgid = msg["message-id"] + refs = msg["references"] + " " + msgid + while len(refs) > config["max-references-length"]: + new_refs = regsub.sub("[^ ]* ", "", refs) + if len(new_refs) == refs: + break + refs = new_refs + + quoted_author = msg["from"] + quoted = msg.getbodytext() + quoted = regsub.gsub("^", "> ", quoted) + if quoted and quoted[-1] != "\n": + quoted = quoted + "\n" + + return reply_txt % (user_addr, reply_to, to_addrs, + cc_addrs, re, subject, msgid, refs, + quoted_author, quoted, get_signature_text()) + +empty_trans = string.maketrans("", "") +sevenbit_chars = None + +def deduce_charset(str): + """Deduce the character set used in a string.""" + global sevenbit_chars + if not sevenbit_chars: + list = [] + for code in range(128): + list.append(chr(code)) + sevenbit_chars = string.join(list, "") + if string.translate(str, empty_trans, sevenbit_chars): + return "ISO-8859-1" + return "us-ascii" + +empty_header = regex.compile("^[^:][^:]*:[ \t]*$") +date_header = regex.compile("^Date:", regex.casefold) + +def prepare_for_send(msg): + nonempty = [] + hit = 0 + for line in string.split(msg.getheadertext(), "\n"): + if line and not line[0] in string.whitespace: + if date_header.match(line) == -1: + hit = (empty_header.match(line) == -1) + else: + hit = 0 + if line and hit: + nonempty.append(line) + nonempty.append("Date: %s" % helpers.rfc822_date()) + nonempty.append("") + headers = string.join(nonempty, "\n") + + msg.change_text(StringIO.StringIO(headers + "\n" + msg.getbodytext(0))) + +def send_msg(msg): + name, sender = msg.getaddr("from") + receivers = [] + for header in ["to", "cc"]: + if msg[header]: + for name, addr in msg.getaddrlist(header): + receivers.append(addr) + + smtp = smtplib.SMTP(config["smtp-server"]) + smtp.helo() + smtp.mail_from(sender) + for receiver in receivers: + smtp.rcpt_to(receiver) + text = string.split(msg.getfulltext(), "\n") + smtp.data(text) + smtp.quit() + +def main(): + print deduce_user_address() + print rfc822_date() + print deduce_charset("this is us-ascii") + print deduce_charset("tämä on latin1:tä") + +if __name__ == "__main__": + main() diff --git a/slime-0.11/slime_unix.py b/slime-0.11/slime_unix.py new file mode 100644 index 0000000..0410aa0 --- /dev/null +++ b/slime-0.11/slime_unix.py @@ -0,0 +1,183 @@ +import mhlib, os, StringIO2 +StringIO = StringIO2 +from slime_abstract import * +from mailbox import UnixMailbox, _Subfile + +def unix_top_folder(dirname): + """Return SlimeFolder with top level Unix folders.""" + dirname = os.path.expanduser(dirname) + if os.path.isdir(dirname): + if not os.path.isfile(os.path.join(dirname, "context")): + return SlimeFolder_Unix(dirname) + return None + +class SlimeFolder_Unix(SlimeFolder): + """A SlimeFolder interface to ordinary Unix mailbox folders.""" + + def __init__(self, name): + SlimeFolder.__init__(self) + if not os.path.isabs(name): + name = os.path.join(os.getcwd(), name) + self._name = name + self._is_dir = 0 + + def open(self): + self._is_open = 1 + if os.path.isdir(self._name): + self._is_dir = 1 + else: + self._is_dir = 0 + self._fp = open(self._name, 'r+') + + def close(self): + for msg in self._messages: + msg.uncache_headers() + if not self._is_dir: + self._fp = None + self._is_open = 0 + + def rescan_messages(self): + self._assert_open() + if not self._is_dir: + new_messages = [] + mb = UnixMailbox(self._fp) + while 1: + m = mb.next() + if not m: + break + mm = SlimeMessage_Unix(self, m.fp) + for msg in self._messages: + if mm._checksum == msg._checksum: + mm = msg + break + mm.set_status(mm["status"]) + new_messages.append(mm) + self._messages = new_messages + self._threads = [] + + def rescan_subfolders(self): + self._assert_open() + if self._is_dir: + list = [] + for pathname in os.listdir(self._name): + p = os.path.join(self._name, pathname) + list.append(SlimeFolder_Unix(p)) + self._subfolders = list + + def add_message(self, file): + self._assert_open() + + dummy = mhlib.Message('NONE', 1, file) + self._fp.seek(0,2) + start = self._fp.tell() + self._fp.write(self._make_separator(dummy)) + del dummy + file.seek(0,0) + while 1: + buf = file.read(16*1024) + if not buf: + break + self._fp.write(buf) + end = self._fp.tell() + subfile = _Subfile(self._fp, start, end) + msg = SlimeMessage_Unix(self, subfile) + self._messages.append(msg) + return msg + + def commit_changes(self): + self._assert_open() + if not self._is_dir and self._dirty: + newname = self._name + ".new" + f = open(newname, "w") + for msg in self._messages: + if not msg.has_status("D"): + f.write(self._make_separator(msg)) + f.write(msg.getfulltext()) + f.close() + os.rename(newname, self._name) + + self._messages = [] + self._dirty = 0 + self.open() + self.rescan_messages() + self.rescan_subfolders() + + def _make_separator(self, msg): + date = msg.getdate("date") + if date is None: + date = (1970,1,1,0,0,0,3,1,0) + mon = ["", "Jan", "Feb", "Mar", "Apr", "May", + "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec"][date[1]] + dow = ["Mon", "Tue", "Wed", "Thu", "Fri", + "Sat", "Sun"][date[6]] + year = date[0] + if year < 1900: + year = year + 1900 + name, addr = msg.getaddr("from") + if not addr: + addr = "foo" + return "From %s %s %s %2d %2d:%02d:%02d %d\n" % \ + (addr, dow, mon, date[2], date[3], date[4], + date[5], year) + +class SlimeMessage_Unix(SlimeMessage): + """A SlimeMessage interface to messages in Unix mailbox folders.""" + + def __init__(self, folder, subfile): + SlimeMessage.__init__(self, folder) + self._start = subfile.start + self._stop = subfile.stop + self._checksum = self._start + + def _open(self): + self._folder._assert_open() + self._open_count = self._open_count + 1 + if not self._msg: + self._fp = _Subfile(self._folder._fp, + self._start, self._stop) + self._msg = mhlib.Message('NONE', 1, self._fp) + + # make sure stuff is cached + if not self._cached_date: + self._cache_headers() + + def _close(self): + self._folder._assert_open() + self._open_count = self._open_count - 1 + if self._open_count == 0: + self._msg = None + self._fp.close() + +def main(): + f = SlimeFolder_Unix(os.path.expanduser("~/mail")) + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + sub.open() + sub.rescan_messages() + for msg in sub.list_all_messages()[:2]: + print sub._name, msg["subject"] + sub.close() + print + f.close() + +def profile_main(): + import time + f = unix_top_folder("~/mail") + f.open() + f.rescan_subfolders() + for sub in f.list_all_subfolders(): + if repr(sub) == "/home/liw/mail/yow3": + sub.open() + print "computing threads" + start = time.time() + sub.rescan_messages() + sub.list_all_threads() + end = time.time() + print "threads computed", end - start + sub.close() + f.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/ui.py b/slime-0.11/ui.py new file mode 100644 index 0000000..016f5ca --- /dev/null +++ b/slime-0.11/ui.py @@ -0,0 +1,1058 @@ +from Tkinter import * +from ScrolledText import ScrolledText + +import string, time, tempfile +import Shells, Menu, ButtonBox +import slime_abstract, slime_pgpmime, slime_send, ui_config +config = ui_config.config + +from ui_helpers import * +from ui_compose import * +from slime_folderinfo import * + +INITIAL_INBOX_SCAN_DELAY = 5*1000 + +folderinfo_file = os.path.expanduser("~/.slime/folderinfo") + +root = None +draft_folder = None +fcc_folder = None + +pgp_password = None +pgp_timestamp = 0 + +class DummyFeedback: + def delete_msg(self, foo, msg, msgwin): pass + def undelete_msg(self, foo, msg, msgwin): pass + def close_msgwin(self, foo, msgwin): pass + def next_msg(self, foo, msg, msgwin): pass + def prev_msg(self, foo, msg, msgwin): pass + def mark_unread_msg(self, foo, msg, msgwin): pass + + def send_msg(self, msg): pass + +class MessageWindow: + + _all_msgwin = [] + + def __init__(self): + self._all_msgwin.append(self) + + self.top = Toplevel() + self.top.title("Slime Message Window") + self.top.iconname("Slime Message Window") + + self.feedback = DummyFeedback() + self.shorten_headers = 1 + + self.top.bind("n", self.next_msg) + self.top.bind("p", self.prev_msg) + self.top.bind("d", self.delete_msg) + self.top.bind("u", self.undelete_msg) + self.top.bind("", self.next_line) + self.top.bind("", self.prev_line) + self.top.bind("", self.next_page) + self.top.bind("", self.prev_page) + self.top.bind("", self.next_page_or_msg) + + menubar = Menu.Menubar(self.top) + Menu.Pulldown(menubar, "Menu", + ["Reply", self.reply_to_msg], + ["-"], + ["Delete", self.delete_msg], + ["Undelete", self.undelete_msg], + ["Mark unread", self.undelete_msg], + ["-"], + ["Next", self.next_msg], + ["Prev", self.prev_msg], + ["-"], + ["Show selected headers", self.show_important_headers], + ["Show full headers", self.show_full_headers], + ["-"], + ["Close", self.close_msgwin]) + + buttonbar = Frame(self.top) + buttonbar.pack(fill=X) + CommandButton(buttonbar, "Delete", self.delete_msg) + + self.textwin = ScrolledText(self.top) + self.textwin.pack(expand=YES, fill=BOTH) + self.textwin.config(background="white") + self.textwin.config(font=config["normal-font"]) + self.disable() + + statusbar = Frame(self.top) + statusbar.pack(fill=X) + b = Label(statusbar, text="Status:") + b.pack(side=LEFT) + self.statuswin = Label(statusbar, text="") + self.statuswin.config(relief=SUNKEN) + self.statuswin.pack(side=LEFT) + b = Label(statusbar, text="Size:") + b.pack(side=LEFT) + self.sizewin = Label(statusbar, text="", relief=SUNKEN) + self.sizewin.pack(side=LEFT) + + self.feedback = None + self.msg = None + + def show_full_headers(self): + self.shorten_headers = 0 + self.re_set() + + def show_important_headers(self): + self.shorten_headers = 1 + self.re_set() + + def place(self, x, y): + self.top.geometry("+%d+%d" % (x,y)) + + def enable(self): + self.textwin.config(state="normal") + + def disable(self): + self.textwin.config(state="disabled") + + def reply_to_msg(self, event=None): + self.feedback.reply_to_msg(self.msg) + + def delete_msg(self, event=None): + self.feedback.delete_msg(event, self.msg, self) + + def undelete_msg(self, event=None): + self.feedback.undelete_msg(event, self.msg, self) + + def close_msgwin(self, event=None): + self.feedback.close_msgwin(self) + self.top.destroy() + self._all_msgwin.remove(self) + + def next_msg(self, event=None): + self.feedback.next_msg(event, self.msg, self) + + def prev_msg(self, event=None): + self.feedback.prev_msg(event, self.msg, self) + + def get_top_index(self): + index = self.textwin.index("@0,0") + pair = string.split(index, ".") + return string.atoi(pair[0]), string.atoi(pair[1]) + + def height(self): + return string.atoi(self.textwin.cget("height")) + + def next_line(self, event=None): + line, col = self.get_top_index() + self.textwin.yview(line) + + def prev_line(self, event=None): + line, col = self.get_top_index() + line = line - 1 + if line < 0: + line = 0 + self.textwin.yview(line-1) + + def next_page(self, event=None): + line, col = self.get_top_index() + line = line + (self.height() - 2) + self.textwin.yview(line-1) + + def next_page_or_msg(self, event=None): + line, col = self.get_top_index() + line2 = line + (self.height() - 2) + if line2 >= self.lines() - 1: + self.next_msg() + else: + self.textwin.yview(line2-1) + + def prev_page(self, event=None): + line, col = self.get_top_index() + line = line - (self.height() - 2) + if line < 1: + line = 1 + self.textwin.yview(line-1) + + def mark_unread_msg(self, event=None): + self.feedback.mark_unread_msg(event, self.msg, self) + + def set(self, msg, feedback): + if feedback: + self.feedback = feedback + else: + self.feedback = DummyFeedback() + self.msg = msg + self.enable() + self.textwin.delete('0.0', 'end') + for tag in self.textwin.tag_names(): + self.textwin.tag_delete(tag) + self.append_part(msg) + self.append_separator() + self.disable() + x = "%.20s: %.30s" % (msg["from"], msg["subject"]) + self.top.title(x) + self.top.iconname(x) + self.update_status() + + def re_set(self): + self.set(self.msg, self.feedback) + + def update_status(self): + for msgwin in self._all_msgwin: + x = msgwin.msg.get_status() + if x == "": + x = " " + msgwin.statuswin.config(text=x) + msgwin.sizewin.config(text="%d" % self.lines()) + + def append_part(self, part): + self.append_headers(part) + type = part.getmaintype() + subtype = part.getsubtype() + if type == "multipart": + if subtype == "signed": + self.append_multipart_signed(part) + else: + self.append_multipart_any(part) + elif type == "image" and subtype == "gif": + self.append_separator() + self.append_image_gif(part) + else: + self.append_separator() + self.append_text_any(part) + + def append_multipart_signed(self, part): + if ui_config.config["check-pgp-signatures"]: + try: + slime_pgpmime.check_signature(part) + self.append_line("Signature: OK\n") + except slime_pgpmime.SecretMissing, detail: + self.append_error("Signature: Secret key is missing\n") + except slime_pgpmime.PublicMissing, detail: + self.append_error("Signature: Public key is missing\n") + except slime_pgpmime.GoodUntrustedSignature, detail: + self.append_error("Signature: Good, but untrusted signature\n") + except slime_pgpmime.BadUntrustedSignature, detail: + self.append_error("Signature: Bad signature\n") + except slime_pgpmime.BadTrustedSignature, detail: + self.append_error("Signature: Bad signature\n") + except slime_pgpmime.OtherResult, detail: + self.append_error("Signature: Unknown result\n") + else: + self.append_line("Signature: unchecked\n") + self.append_multipart_any(part) + + def append_multipart_any(self, part): + for p in part.getbodyparts(): + self.append_part(p) + + def append_error(self, line): + self.textwin.insert('end', line) + tag = self.tag_last_line('error') + self.textwin.tag_config(tag, background='red') + + def append_line(self, line): + self.textwin.insert('end', line) + + def append_image_gif(self, part): + data = part.getbodytext() + tempname = tempfile.mktemp() + helpers.write_text_to_named_file(tempname, data) + photo = PhotoImage(file=tempname) + label = Label(master=self.textwin, image=photo) + label.pack() + self.textwin.window_create('end', window=label) + os.remove(tempname) + + def append_text_any(self, part): + self.textwin.insert('end', part.getbodytext()) + + def append_separator(self): + str = "" + self.textwin.insert("end", str + "\n") + tag = self.tag_last_line("sep") + self.textwin.tag_config(tag, background='lightgrey', + relief=RAISED, borderwidth=2, font='5x7') + + def lines(self): + index = self.textwin.index("end") + pair = string.split(index, ".") + return string.atoi(pair[0]) - 2 + + def tag_last_line(self, tag_prefix): + lines = self.lines() + tag = "%s-%d" % (tag_prefix, lines) + self.textwin.tag_add(tag, "%d.0" % lines, "%d.0" % (lines + 1)) + return tag + + def append_headers(self, part): + if self.shorten_headers: + h = [] + headers = string.split(config["important-headers"]) + for header in headers: + try: + v = part[header] + if v: + h.append("%s: %s\n" % + (header, v)) + except KeyError: + pass + h = string.join(h, "") + self.textwin.insert('end', h) + else: + self.textwin.insert('end', part.getheadertext()) + +class SelectionList(ScrolledText): + def __init__(self, master): + ScrolledText.__init__(self, master) + self.pack(side=LEFT, expand=YES, fill=BOTH) + self.config(wrap="none", spacing1=1, exportselection=NO) + self.config(background="white") + self.config(font=config["normal-font"]) + self.bind("", self.select) + self.bind("", self.select_double) + self.bind("", self.select2) + self.bind("", self.select2_double) + self.bind("", self.select3) + self.bind("", self.select3_double) + self.disable() + self.current = None + + def get_top_index(self): + index = self.index("@0,0") + pair = string.split(index, ".") + return string.atoi(pair[0]), string.atoi(pair[1]) + + def enable(self): + self.config(state="normal") + + def disable(self): + self.config(state="disabled") + + def select_hook(self): + pass + def double_hook(self): + pass + + def select2_hook(self, lineno): + pass + def double2_hook(self, lineno): + pass + + def select3_hook(self, lineno): + pass + def double3_hook(self, lineno): + pass + + def update_cursor(self): + if not self.current is None: + self.tag_delete("curline") + self.tag_add("curline", + "%d.0" % self.current, + "%d.0 lineend + 1 chars" % (self.current)) + self.tag_config("curline", background="darkgrey") + + def select_by_lineno(self, lineno): + self.prev_current = self.current + self.current = lineno + self.select_hook() + self.update_cursor() + self.see("%d.0" % (lineno + 4)) + self.see("%d.0" % (lineno)) + + def _get_lineno(self, event): + index = self.index("@%d,%d" % (event.x, event.y)) + pair = string.split(index, ".") + return string.atoi(pair[0]) + + def select(self, event): + self.select_by_lineno(self._get_lineno(event)) + def select_double(self, event): + self.double_hook() + + def select2(self, event): + self.select2_hook(self._get_lineno(event)) + def select2_double(self, event): + self.double2_hook(self._get_lineno(event)) + + def select3(self, event): + self.select3_hook(self._get_lineno(event)) + def select3_double(self, event): + self.double3_hook(self._get_lineno(event)) + +class FolderTocWindow(SelectionList): + def __init__(self, master): + SelectionList.__init__(self, master) + self.config(width=60, height=15) + self.folder = None + self.msg = None + self.msgwin = None + self.msg_dict = {} + self.msg_count = 0 + + self.focus_set() + self.bind("n", self.next_msg) + self.bind("p", self.prev_msg) + self.bind("d", self.delete_msg) + self.bind("u", self.undelete_msg) + + def set(self, folder, mcount_win, folder_win): + self.enable() + self.delete('0.0', 'end') + n = 0 + self.folder = folder + self.msg = None + self.msg_dict = {} + self.msg_count = len(folder.list_all_threads()) + for indent_level, msg in folder.list_all_threads(): + if n > 0: + self.insert('end', '\n') + n = n + 1 + self.msg_dict[msg] = n + + parts = self.make_toc_parts(indent_level, msg) + line = self.make_toc_line(parts) + self.insert('end', line) + self.set_tags(parts, line, n) + self.disable() + self.mcount_win = mcount_win + self.mcount_win.config(text="%d" % self.msg_count) + self.folder_win = folder_win + + def re_set(self): + if self.folder: + old_msg = self.msg + self.folder.rescan_messages() + self.set(self.folder, self.mcount_win, self.folder_win) + if self.msg_dict.has_key(old_msg): + self.select_by_lineno(self.msg_dict[old_msg]) + elif self.msgwin: + self.msgwin.close_msgwin() + + def make_toc_line(self, parts): + return "%s %s %-20.20s %s%s" % \ + (parts[0], parts[4], parts[3], parts[1], parts[2]) + + def make_toc_parts(self, indent_level, msg): + if msg.has_status("N"): + status = "N" + elif msg.has_status("D"): + status = "D" + else: + status = " " + if indent_level <= config["max-subject-indent"]: + indent = "%*s" % (indent_level * 2, "") + else: + indent = "%*s(%d) " % \ + (config["max-subject-indent"] * 2, "", + indent_level) + name, addr = msg.getaddr("from") + if not name: + name = addr + subject = msg["subject"] + if subject: + subject = string.join(string.split(subject, "\n"), " ") + else: + subject = "" + date_tuple = msg.getdate("date") + if date_tuple is None: + date = "%10s" % "" + else: + if date_tuple[0] < 1900: + year = 1900 + date_tuple[0] + else: + year = date_tuple[0] + date = "%04d-%02d-%02d" % \ + (year, date_tuple[1], date_tuple[2]) + + return status, indent, subject, name, date + + def set_tags(self, parts, line, lineno): +# sub1 = len(parts[0]) + 1 + len(parts[4]) + 22 + len(parts[1]) +# sub2 = sub1 + len(parts[2]) + + sub1 = 0 + sub2 = len(line) + + if parts[0] == "N": + font = config["unread-font"] + else: + font = config["normal-font"] + self.add_tag(".subject", lineno, sub1, sub2, font) + + def add_tag(self, suffix, lineno, fromcol, tocol, font): + tag = "%d.%s" % (lineno, suffix) + self.tag_add(tag, + "%d.%d" % (lineno, fromcol), + "%d.%d" % (lineno, tocol)) + self.tag_configure(tag, font=font) + + def update_toc_line(self, msg): + if not self.msg_dict.has_key(msg): + return + lineno = self.msg_dict[msg] + parts = self.make_toc_parts(self.folder.get_msg_level(msg), msg) + line = self.make_toc_line(parts) + self.enable() + self.delete("%d.0" % lineno, "%d.end" % lineno) + self.insert("%d.0" % lineno, line) + self.disable() + self.set_tags(parts, line, lineno) + self.folder_win.redraw_folder(self.folder) + + def select_hook(self): + foo, self.msg = self.folder.list_all_threads()[self.current-1] + if self.msg.has_status("N"): + self.msg.remove_status("N") + self.folder_win.decrement_unread() + + if not self.msgwin: + self.msgwin = MessageWindow() + self.put_at_good_position(self.msgwin) + self.msgwin.set(self.msg, self) + self.update_toc_line(self.msg) + self.update_cursor() + + def put_at_good_position(self, msgwin): + x = self.winfo_rootx() + y = self.winfo_rooty() + w = self.winfo_width() + h = self.winfo_height() + msgwin.place(x+w/10, y+h+h/10) + + def double_hook(self): + msg = self.folder.list_all_messages()[self.current-1] + msgwin = MessageWindow() + msgwin.set(msg, self) + + def delete_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msg: + msg.delete_self() + msgwin.update_status() + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + self.next_msg(event, msg, msgwin) + + def undelete_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + msg.undelete_self() + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + + def mark_unread_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + msg.give_status("N") + if self.msg_dict.has_key(msg): + self.update_toc_line(msg) + self.update_cursor() + + def next_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msgwin == self.msgwin and self.current < self.msg_count: + self.select_by_lineno(self.current+1) + + def prev_msg(self, event=None, msg=None, msgwin=None): + if msg is None: + msg = self.msg + msgwin = self.msgwin + if msgwin == self.msgwin and self.current > 1: + self.select_by_lineno(self.current-1) + + def close_msgwin(self, msgwin): + if msgwin == self.msgwin: + self.msgwin = None + + def compose_msg(self): + if self.folder == draft_folder and self.msg: + c = ComposeWindow(self, self.msg, self) + c.show() + else: + file = StringIO.StringIO(slime_send.make_new_message_text()) + msg = draft_folder.add_message(file) + self.refresh_draft_toc() + c = ComposeWindow(self, msg, self) + c.show() + + def reply_to_msg(self, origmsg): + txt = slime_send.make_reply_message_text(origmsg) + file = StringIO.StringIO(txt) + msg = draft_folder.add_message(file) + self.refresh_draft_toc() + c = ComposeWindow(self, msg, self) + c.show() + + def refresh_draft_toc(self): + draft_folder.commit_changes() + if self.folder == draft_folder: + self.re_set() + + def get_pgp_authentication(self, msg): + global pgp_password, pgp_timestamp + + if config["pgp-username"]: + username = config["pgp-username"] + else: + d = PgpUsernameDialog(self, msg["from"]) + username = d.show() + if not username: + return None, None + + now = time.time() + diff = now - pgp_timestamp + if diff < 0 or diff > 60*config["pgp-max-password-age"]: + p = PasswordDialog(self) + password = p.show() + if not password: + return None, None + pgp_password = password + pgp_timestamp = now + else: + password = pgp_password + + return username, password + + def send_msg(self, msg): + if config["make-pgp-signatures"]: + username, password = self.get_pgp_authentication(msg) + if username is None: + return 0 + try: + new_text = slime_pgpmime.make_signature(msg, + username, password) + except slime_pgpmime.OtherResult, detail: + d = ErrorBox(self, "PGP failed", detail[2]) + d.show() + return 0 + msg.change_text(StringIO.StringIO(new_text)) + slime_send.prepare_for_send(msg) + slime_send.send_msg(msg) + if self.store_to_fcc_folder(msg) == 0: + return 0 + msg.delete_self() + self.refresh_draft_toc() + return 1 + + def store_to_fcc_folder(self, msg): + global fcc_folder, tops + if not config["fcc-folder"]: + return 1 + if not fcc_folder: + fcc_folder = \ + helpers.find_folder_by_name(config["fcc-folder"], + root) + if not fcc_folder: + d = ErrorBox(self, "No FCC folder", + ("There is no folder named\n%s\n" + + "No copy of message has been saved") \ + % (config["fcc-folder"])) + d.show() + return 0 + try: + was_open = fcc_folder.is_open() + if not was_open: + fcc_folder.open() + fcc_folder.rescan_messages() + fcc_folder.copy_message_here(msg) + if not was_open: + fcc_folder.close() + except slime_abstract.Error, detail: + d = ErrorBox(self, "FCC copy failed", + "Saving copy of sent message failed:\n" + \ + detail) + return 0 + return 1 + +class FolderWindow(SelectionList): + def __init__(self, master): + SelectionList.__init__(self, master) + self.config(width=25, height=15) + self.pack(expand=NO) + self.folder_to_lineno = {} + self.folder = None + self.msgwin = None + self.msg = None + self.main_win = None + + def decrement_unread(self): + if self.main_win: + self.main_win.decrement_unread() + + def get_folders(self, root, indent=0, recurse=1): + list = [] + for sub in root.list_all_subfolders(): + list.append(sub.get_nice_name(), sub) + list.sort() + result = [] + for tuple in list: + sub = tuple[1] + result.append(indent, sub) + if recurse: + sub.open() + sub.rescan_messages() + sub.rescan_subfolders() + result = result + self.get_folders(sub, + indent+1, recurse-1) + sub.close() + return result + + def format_folder(self, level, folder, count_width, max_folder_indent): + is_inbox = (get_folderinfo(repr(folder), "inbox") == "yes") + if is_inbox: + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_messages() + msgs = folder.list_all_messages() + else: + was_open = 1 + msgs = [] + + if is_inbox: + cnt = "%*s" % (count_width, len(msgs)) + else: + cnt = "%*s" % (count_width, "") + + if level <= max_folder_indent: + indent = "%*s" % (level*2, "") + else: + indent = "%*s(%d) " % (max_folder_indent*2, "", level) + str = "%s %s%s" % (cnt, indent, folder.get_nice_name()) + + font = config["normal-font"] + for msg in msgs: + if msg.has_status("N"): + font = config["unread-font"] + + if is_inbox and not was_open: + folder.close() + + return str, font + + def draw_folders(self): + line, col = self.get_top_index() + self.enable() + self.delete('0.0', 'end') + self.folder_to_lineno = {} + lineno = 1 + for level, f in self.folders: + self.folder_to_lineno[f] = lineno + s, font = self.format_folder(level, f, + config["count-width"], + config["max-folder-indent"]) + if lineno > 1: + self.insert('end', '\n') + self.insert('end', s) + self.change_font_on_line(lineno, font) + lineno = lineno + 1 + self.disable() + try: + self.current = self.folder_to_lineno[self.folder] + except KeyError: + self.current = None + self.update_cursor() + self.yview(line-1) + self.fcount_win.config(text="%d" % len(self.folders)) + + def change_font_on_line(self, lineno, font): + tag = "%d.font" % (lineno) + self.tag_delete(tag) + self.tag_add(tag, + "%d.0" % (lineno), + "%d.0 lineend" % (lineno)) + self.tag_config(tag, font=font) + + def redraw_folder(self, folder): + if self.folder_to_lineno.has_key(folder): + lineno = self.folder_to_lineno[folder] + for level, f in self.folders: + if f == folder: + break + s, font = self.format_folder(level, folder, + config["count-width"], + config["max-folder-indent"]) + self.enable() + self.delete("%d.0" % lineno, "%d.0 lineend" % lineno) + self.insert("%d.0" % lineno, s) + self.change_font_on_line(lineno, font) + self.disable() + if self.folder == folder: + self.update_cursor() + + def set(self, slime_root, toc_win, fcount_win, mcount_win, main_win): + self.slime_root = slime_root + self.toc_win = toc_win + self.fcount_win = fcount_win + self.mcount_win = mcount_win + self.folders = self.get_folders(slime_root, recurse=0) + self.draw_folders() + self.main_win = main_win + + def show_subfolders(self, index): + this = self.folders[index] + was_open = this[1].is_open() + if not was_open: + this[1].open() + this[1].rescan_subfolders() + subs = self.get_folders(this[1], this[0]+1, recurse=0) + if not was_open: + this[1].close() + self.folders = self.folders[:index+1] + subs + \ + self.folders[index+1:] + self.draw_folders() + + def hide_subfolders(self, index): + this = self.folders[index] + next = self.folders[index+1] + i = index+1 + while i < len(self.folders) and this[0] < self.folders[i][0]: + i = i + 1 + self.folders = self.folders[:index+1] + self.folders[i:] + self.draw_folders() + + def show_or_hide_subfolders(self, index=None): + if index is None: + index = self.current-1 + this = self.folders[index] + if index+1 < len(self.folders): + next = self.folders[index+1] + if this[0] < next[0]: + self.hide_subfolders(index) + else: + self.show_subfolders(index) + else: + self.show_subfolders(index) + + def select_hook(self): + new_folder = self.folders[self.current-1][1] + if not self.folder: + self.folder = new_folder + self.folder.open() + self.folder.rescan_subfolders() + self.toc_win.set(self.folder, self.mcount_win, self) + self.show_or_hide_subfolders() + elif self.folder == new_folder: + self.folder.rescan_subfolders() + self.toc_win.re_set() + self.show_or_hide_subfolders() + else: + if self.commit_changes(re_set = 0): + self.folder.close() + self.folder = new_folder + self.folder.open() + self.folder.rescan_subfolders() + self.folder.rescan_messages() + self.toc_win.set(self.folder, self.mcount_win, + self) + self.show_or_hide_subfolders() + else: + self.current = self.prev_current + + def commit_changes(self, re_set = 1): + if self.folder and not self.folder.is_clean(): + d = YesNoDialog(self, + "Commit folder %s?" % \ + self.folder.get_nice_name()) + doit = d.show() + d.destroy() + if doit: + self.folder.commit_changes() + self.redraw_folder(self.folder) + if re_set: + self.toc_win.re_set() + return 1 + return 0 + return 1 + + def select2_hook(self, lineno): + self.show_or_hide_subfolders(lineno-1) + + def select3_hook(self, lineno): + if not self.folder: + print "no open folder" + elif not self.toc_win.msg: + print "no selected message" + else: + target = self.folders[lineno-1][1] + was_open = target.is_open() + if not was_open: + target.open() + target.rescan_messages() + newmsg = target.move_message_here(self.toc_win.msg) + if not was_open: + target.close() + self.toc_win.delete_msg() + +class MainWindow(Frame): + default_toplevel_used = 0 + def __init__(self, slime_root): + if self.default_toplevel_used: + master = Toplevel() + else: + master = None + self.default_toplevel_used = 1 + Frame.__init__(self, master) + self.pack(expand=YES, fill=BOTH) + t = self.winfo_toplevel() + t.title("Slime") + t.iconname("Slime") + + menubar = Menu.Menubar(self) + + self.slime_root = slime_root + f = Frame(self) + f.pack(expand=YES, fill=BOTH) + self.folder_win = FolderWindow(f) + self.ftoc_win = FolderTocWindow(f) + + statusbar = Frame(self) + statusbar.pack(fill=X) + b = Label(statusbar, text="Folders:") + b.pack(side=LEFT) + self.fcount_win = Label(statusbar, text="", relief=SUNKEN) + self.fcount_win.pack(side=LEFT) + + b = Label(statusbar, text="Unread:") + b.pack(side=LEFT) + self.total_unread_win = Label(statusbar, text="", relief=SUNKEN) + self.total_unread_win.pack(side=LEFT) + + self.mcount_win = Label(statusbar, text="", relief=SUNKEN) + self.mcount_win.pack(side=RIGHT) + b = Label(statusbar, text="Messages:") + b.pack(side=RIGHT) + + self.folder_win.set(self.slime_root, self.ftoc_win, + self.fcount_win, self.mcount_win, self) + + Menu.Pulldown(menubar, "&Folder", + ["Scan inboxes", self.scan_inboxes], + ["Rescan folder", self.ftoc_win.re_set], + ["Commit folder", self.folder_win.commit_changes], + ["-"], + ["Compose message", self.ftoc_win.compose_msg], + ["-"], + ["Edit folder options", self.edit_folderinfo], + ["-"], + ["Options", ui_config.edit_config], + ["Save options", self.save_config], + ["-"], + ["Exit", self.quit_program]) + + self.unread = 0 + self.after(INITIAL_INBOX_SCAN_DELAY, self.do_inbox_scan) + + def save_config(self): + ui_config.save_config() + d = InfoBox(self, "Config saved", "Configuration has been saved") + d.show() + + def edit_folderinfo(self): + if self.folder_win.folder is None: + return + name = repr(self.folder_win.folder) + is_inbox = (get_folderinfo(name, "inbox") == "yes") + d = FolderInfoEditor(self, name, is_inbox) + is_inbox = d.show() + if is_inbox is None: + return + if is_inbox: + set_folderinfo(name, "inbox", "yes") + else: + set_folderinfo(name, "inbox", "no") + self.folder_win.redraw_folder(self.folder_win.folder) + + def do_inbox_scan(self): + self.scan_inboxes() + self.after(config["inbox-scan-frequency"]*60*1000, + self.do_inbox_scan) + + def scan_inboxes(self): + global root + names = get_folders_with_info("inbox", "yes") + helpers.clear_name_to_folder_cache() + unread = 0 + for name in names: + folder = helpers.find_folder_by_name(name, root) + was_open = folder.is_open() + if not was_open: + folder.open() + folder.rescan_messages() + folder.rescan_subfolders() + for msg in folder.list_all_messages(): + if msg.has_status("N"): + unread = unread + 1 + self.folder_win.redraw_folder(folder) + if not was_open: + folder.close() + helpers.clear_name_to_folder_cache() + self.set_unread(unread) + + def set_unread(self, unread): + self.unread = unread + self.total_unread_win.config(text="%d" % unread) + + def decrement_unread(self): + self.set_unread(self.unread - 1) + + def quit_program(self): + d = YesNoDialog(self, "Really exit?") + if d.show(): + self.folder_win.commit_changes() + self.quit() + +def main(): + from slime_mh import * + from slime_unix import * + from slime_root import * + from slime_draft import * + import os + global draft_folder, root + + ui_config.load_config() + load_folderinfo(folderinfo_file) + + root = SlimeFolder_Root() + root.open() + + draft_folder = make_draft_folder() + draft_folder.open() + draft_folder.rescan_messages() + tops = [draft_folder] + maybe_tops = [mh_top_folder(), + unix_top_folder("~/Mail"), + unix_top_folder("~/mail")] + for f in maybe_tops: + if f: + tops.append(f) + if len(tops) == 1: + tops[0].open() + tops[0].rescan_messages() + tops[0].rescan_subfolders() + if len(tops[0].list_all_messages()) == 0: + for f in tops[0].list_all_subfolders(): + root.add_subfolder(f) + tops[0].close() + tops = [] + else: + tops[0].close() + for f in tops: + root.add_subfolder(f) + + mainwin = MainWindow(root) + mainwin.mainloop() + save_folderinfo(folderinfo_file) + draft_folder.close() + root.close() + +if __name__ == "__main__": + main() diff --git a/slime-0.11/ui_compose.py b/slime-0.11/ui_compose.py new file mode 100644 index 0000000..1fd970b --- /dev/null +++ b/slime-0.11/ui_compose.py @@ -0,0 +1,96 @@ +from Tkinter import * +from ScrolledText import * +from ui_helpers import * +from ui_config import config + +import Shells, Menu, StringIO, helpers, spawn, os, tempfile + +class ComposeWindow(Shells.NonModal): + def __init__(self, master, msg, feedback): + Shells.NonModal.__init__(self, master) + + self.msg = msg + self.feedback = feedback + + self.top.title("Slime Composition Window") + self.top.iconname("Slime Composition Window") + + menubar = Menu.Menubar(self.top) + Menu.Pulldown(menubar, "Menu", + ["Use external editor", self.use_external], + ["Save", self.save_msg], + ["Postpone", self.postpone_msg], + ["Send", self.send_msg], + ["Abort", self.abort_msg]) + + buttonbar = Frame(self.top) + buttonbar.pack(fill=X) + CommandButton(buttonbar, "Send", self.send_msg) + + self.textwin = ScrolledText(self.top) + self.textwin.pack(expand=YES, fill=BOTH) + self.textwin.focus_set() + self.textwin.config(background="white") + self.textwin.config(font=config["normal-font"]) + + self.textwin.insert('0.0', self.msg.getfulltext()) + self.textwin.mark_set('insert', '0.0') + + def use_external(self): + name = config["external-editor"] + if not name: + return + self.save_msg() + tempname = tempfile.mktemp() + text = self.textwin.get('0.0', 'end') + helpers.write_text_to_named_file(tempname, text) + self.top.iconify() + exit_code, stdout, stderr = \ + spawn.run(name, stdin='', args=[tempname], + env=os.environ) + self.top.deiconify() + if exit_code != 0 or stderr != '': + d = ErrorBox(self, "External editor failed", + "The external editor failed:\n" + stderr) + d.show() + os.remove(tempname) + return + text = helpers.read_text_from_named_file(tempname) + os.remove(tempname) + self.textwin.delete('0.0', 'end') + self.textwin.insert('0.0', text) + self.textwin.mark_set('insert', '0.0') + + def save_msg(self): + txt = self.textwin.get('0.0', 'end') + txtfile = StringIO.StringIO(txt) + self.msg.change_text(txtfile) + self.feedback.refresh_draft_toc() + + def send_msg(self): + d = YesNoDialog(self.top, "Send message?") + if d.show(): + self.save_msg() + if self.feedback.send_msg(self.msg): + self.terminate() + + def postpone_msg(self): + self.save_msg() + self.terminate() + d = InfoBox(self.top, "Message postponed", + "Message has been postponed.") + d.show() + + def abort_msg(self): + d = YesNoDialog(self.top, "Abort message?") + if d.show(): + self.msg.delete_self() + self.save_msg() + self.terminate() + + def deleteWindow(self): + self.abort_msg() + + def show(self): + Shells.NonModal.show(self) + self.textwin.focus_set() diff --git a/slime-0.11/ui_config.py b/slime-0.11/ui_config.py new file mode 100644 index 0000000..f184797 --- /dev/null +++ b/slime-0.11/ui_config.py @@ -0,0 +1,103 @@ +import os +from Config import * +from ConfigEditor import * + +config_file = "~/.slime/config" + +config_vars = { + +"count-width": (CONFIG_INTEGER, 3, "Width for message count in folder list"), + +"from-address": (CONFIG_STRING, "", "User's address (automatic, if empty)"), +"reply-to": (CONFIG_STRING, "", "Default Reply-To"), +"fcc-folder": (CONFIG_STRING, "", + "Folder to store copy of sent mail (no copy, if empty)"), +"signature-file": (CONFIG_STRING, "~/.signature", "Signature file"), + +"make-pgp-signatures": (CONFIG_BOOLEAN, 0, "Make PGP signatures?"), +"check-pgp-signatures": (CONFIG_BOOLEAN, 0, "Check PGP signatures?"), +"pgp-path": (CONFIG_STRING, "/usr/bin/pgp", "PGP command (full path)"), +"pgp-username": (CONFIG_STRING, "", "PGP username"), +"pgp-max-password-age": (CONFIG_INTEGER, 60, + "Time (in minutes) to remember PGP passphrase"), + +"smtp-server": (CONFIG_STRING, "localhost", "SMTP server"), +"max-references-length": (CONFIG_INTEGER, 500, + "Max length for References header"), +"external-editor": (CONFIG_STRING, "", "External editor"), + +"normal-font": (CONFIG_STRING, "-misc-fixed-medium-r-semicondensed--13-120-75-75-c-60-iso8859-1", + "Normal font"), +"unread-font": (CONFIG_STRING, "-misc-fixed-bold-r-semicondensed--13-120-75-75-c-60-iso8859-1", + "Unread font"), +"command-font": (CONFIG_STRING, "-misc-fixed-medium-r-normal--10-*-*-*-*-*-iso8859-1", + "Command button font"), +"max-subject-indent": (CONFIG_INTEGER, 4, + "Max subject indentation"), +"max-folder-indent": (CONFIG_INTEGER, 2, + "Max folder indentation"), +"important-headers": (CONFIG_STRING, "From To Cc Date Subject", + "Headers shown normally"), + +"inbox-scan-frequency": (CONFIG_INTEGER, 1, + "Frequency of new message scan (minutes)"), + +} + +config_layout = [ + ["Mail sending", + "from-address", + "reply-to", + "smtp-server", + "max-references-length", + "fcc-folder", + "external-editor", + "signature-file", + ], + ["PGP", + "make-pgp-signatures", + "check-pgp-signatures", + "pgp-path", + "pgp-username", + "pgp-max-password-age", + ], + ["Appearance", + "normal-font", + "unread-font", + "max-subject-indent", + "max-folder-indent", + "important-headers", + "count-width", + ], + ["Misc", + "inbox-scan-frequency", + ], +] + +config = Config(config_vars) + +def edit_config(): + global config, config_layout + ConfigEditor(config, config_layout) + +def load_config(): + p = os.path.expanduser(config_file) + if os.path.isfile(p): + f = open(p, "r") + config.read(f) + f.close() + +def save_config(): + p = os.path.expanduser(config_file) + if os.path.isfile(p): + pnew = p + ".new" + fp = open(p, "r") + fpnew = open(pnew, "w") + config.rewrite(fp, fpnew) + fp.close() + fpnew.close() + os.rename(p + ".new", p) + else: + f = open(p, "w") + config.write(f) + f.close() diff --git a/slime-0.11/ui_helpers.py b/slime-0.11/ui_helpers.py new file mode 100644 index 0000000..ad52f94 --- /dev/null +++ b/slime-0.11/ui_helpers.py @@ -0,0 +1,155 @@ +from Tkinter import * +import Shells, ButtonBox +from ui_config import config + +class CommandButton(Button): + def __init__(self, master, text, command): + Button.__init__(self, master=master, text=text, + font=config["command-font"], padx=1, pady=0, + command=command) + self.pack(side=LEFT) + +class ModalDialog(Shells.Modal): + def terminate(self): + Shells.Modal.terminate(self) + self.top.update() + + def show(self): + apply(Shells.NonModal.show, (self,)) + self.initFocus() + self.top.waitvar(self.waitVar) + return self.result + + def initFocus(self): + pass + +class PgpUsernameDialog(ModalDialog): + def __init__(self, infrontof, default_value): + ModalDialog.__init__(self, infrontof) + self.top.title("Enter PGP username") + self.top.iconname("Enter PGP username") + Label(self.top, text="Please enter the PGP username").pack() + self.entry = Entry(self.top, width=40) + self.entry.pack() + self.entry.bind("", self.ok) + self.entry.bind("", self.cancel) + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + self.default_value = default_value + + def ok(self, event=None): + self.terminate() + self.result = self.entry.get() + + def cancel(self, event=None): + self.terminate() + self.result = None + + def initControls(self): + self.entry.delete('0', 'end') + self.entry.insert('0', self.default_value) + + def initFocus(self): + self.entry.focus_set() + +class PasswordDialog(ModalDialog): + def __init__(self, infrontof): + ModalDialog.__init__(self, infrontof) + self.top.title("Enter PGP Passphrase") + self.top.iconname("Enter PGP Passphrase") + Label(self.top, text="Please enter the PGP passphrase").pack() + self.entry = Entry(self.top, show="*", width=40) + self.entry.pack() + self.entry.bind("", self.ok) + self.entry.bind("", self.cancel) + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + + def ok(self, event=None): + self.terminate() + self.result = self.entry.get() + + def cancel(self, event=None): + self.terminate() + self.result = None + + def initControls(self): + self.entry.delete('0', 'end') + + def initFocus(self): + self.entry.focus_set() + +class YesNoDialog(ModalDialog): + def __init__(self, infrontof, question, title=None): + ModalDialog.__init__(self, infrontof) + if not title: + title = question + self.top.title(title) + self.top.iconname(title) + Label(self.top, text=question).pack() + b = ButtonBox.T(self.top, + ("OK", self.ok), ("Cancel", self.cancel)) + + def ok(self): + self.result = 1 + self.terminate() + + def cancel(self): + self.result = 0 + self.terminate() + +class NonModalDialog(Shells.NonModal): + def terminate(self): + Shells.NonModal.terminate(self) + self.top.update() + +class InfoBox(NonModalDialog): + def __init__(self, infrontof, title, text): + NonModalDialog.__init__(self, infrontof) + self.top.title(title) + self.top.iconname(title) + Message(self.top, text=text, width=250).pack() + Button(self.top, text="Dismiss", command=self.terminate).pack() + +class ErrorBox(InfoBox): + def __init__(self, infrontof, title, text): + InfoBox.__init__(self, infrontof, "Error: " + title, text) + +class FolderInfoEditor(ModalDialog): + def __init__(self, infrontof, foldername, is_inbox_now): + ModalDialog.__init__(self, infrontof) + self.top.title("Folder settings") + self.top.iconname("Folder settings") + + Label(self.top, text="Settings for folder %s" % foldername).pack() + + frame = Frame(self.top) + frame.pack(fill=X) + Label(frame, text="Scan for new messages?").pack(side=LEFT) + self.var = BooleanVar() + self.var.set(is_inbox_now) + self.is_inbox = Checkbutton(frame, variable=self.var, + command=self.setOnOffText) + self.is_inbox.pack(side=LEFT) + + frame = Frame(self.top) + frame.pack(fill=X) + Button(frame, text="OK", command=self.ok).pack(side=LEFT) + Button(frame, text="Cancel", command=self.cancel).pack(side=LEFT) + + self.setOnOffText() + + def setOnOffText(self): + if self.var.get() == 0: + text = "No" + else: + text = "Yes" + self.is_inbox.config(text=text) + + def ok(self): + self.result = self.var.get() + self.terminate() + + def cancel(self): + self.result = None + self.terminate() diff --git a/slime-main.jpg b/slime-main.jpg new file mode 100644 index 0000000..f26f9d1 Binary files /dev/null and b/slime-main.jpg differ diff --git a/slime-manual.html/ch1.html b/slime-manual.html/ch1.html new file mode 100644 index 0000000..80fc084 --- /dev/null +++ b/slime-manual.html/ch1.html @@ -0,0 +1,22 @@ + +Stupid Little Mailer: User's Guide - Introduction + + + +

+Stupid Little Mailer: User's Guide - chapter 1
+Introduction + +

+This manual will be written later. +
+Stupid Little Mailer: User's Guide +- Copyright © 1997 Lars Wirzenius. + +
+Contents; abstract. +
+
Version very-pre-alpha +
+Lars Wirzenius liw@iki.fi
+ \ No newline at end of file diff --git a/slime-manual.html/index.html b/slime-manual.html/index.html new file mode 100644 index 0000000..f6ce121 --- /dev/null +++ b/slime-manual.html/index.html @@ -0,0 +1,51 @@ + +Stupid Little Mailer: User's Guide + + +

Stupid Little Mailer: User's Guide

+ +

+0.1 Abstract +

+ +Stupid Little Mailer, or Slime for short, is a mail user +agent. + +
+

+0.2 Table of contents +

+ +

0.3 Copyright

+Copyright © 1997 Lars Wirzenius. +

+Slime, including this manual, is free software; +you may redistribute it and/or modify it under the terms +of the GNU General Public License as published by the Free +Software Foundation; either version 2, or (at your option) +any later version. +

+This is distributed in the hope that it will be useful, +but without any warranty; without even the implied warranty of +merchantability or fitness for a particular purpose. See the +GNU General Public License for more details. +

+You should have received a copy of the GNU General +Public License with your Debian GNU/Linux system, in +/usr/doc/copyright/GPL, or with the SeX source +package as the file COPYING. If not, write to the Free Software +Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +


+Stupid Little Mailer: User's Guide +- Copyright © 1997 Lars Wirzenius. + +
+Contents; abstract. +
+
Version very-pre-alpha +
+Lars Wirzenius liw@iki.fi
+ \ No newline at end of file diff --git a/slime-msg.jpg b/slime-msg.jpg new file mode 100644 index 0000000..dc1ff19 Binary files /dev/null and b/slime-msg.jpg differ diff --git a/uitools/Alerts.py b/uitools/Alerts.py new file mode 100644 index 0000000..ef8c8e4 --- /dev/null +++ b/uitools/Alerts.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python +###################################################################### +# This module provides some standard Alert dialogs. +# This is a blatant imitation -- er, flattery -- of the tkdialogs +# package, with extensions. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: Alerts.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import Shells, StdDialog, ProgressBar + +###################################################################### +# This is yer basic Alert type. +###################################################################### +class T(Shells.Modal): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master, title="Alert", msg="[your message here]"): + Shells.Modal.__init__(self, master) + self.controls = StdDialog.Controls(self) + self.msg = msg + self.top.title(title) + self.justify = "center" + + frame = self.frame = self.controls.frame + + bm = self.bitmap = Tk.Label(frame) + bm.pack(side='left') + + l = self.msgLabel = Tk.Label(frame, text=msg) + l.pack(side='top', fill='x', expand='yes') + + self.btnBox = self.controls.btnBox + + # For easier override/extend in subclasses + show = Shells.Modal.show + + ################################################################## + # Standard callbacks for the various types of buttons. + ################################################################## + def okCB(self, event=None): + self.result = 1 + self.terminate() + + def cancelCB(self, event=None): + self.result = 0 + self.terminate() + + +###################################################################### +# And now for some common alerts: +###################################################################### + +###################################################################### +# Display an error message. +###################################################################### +class Error(T): + def __init__(self, master, title="Error", msg="Error"): + T.__init__(self, master, title, msg) + self.bitmap['bitmap'] = 'error' + self.btnBox.addButtons(["OK", self.okCB]) + + # For easier override/extend in subclasses + show = T.show + + +###################################################################### +# Display a warning. +###################################################################### +class Warning(T): + def __init__(self, master, title="Warning", msg="Warning", + okLabel="Yes", cancelLabel="No"): + T.__init__(self, master, title, msg) + self.bitmap['bitmap'] = 'warning' + self.btnBox.addButtons([okLabel, self.okCB], + [cancelLabel, self.cancelCB]) + + # For easier override/extend in subclasses + show = T.show + + +###################################################################### +# Display a question. +###################################################################### +class Question(T): + def __init__(self, master, title="Question", msg="?", + okLabel="Yes", cancelLabel="No"): + T.__init__(self, master, title, msg) + self.bitmap['bitmap'] = 'question' + self.btnBox.addButtons([okLabel, self.okCB], + [cancelLabel, self.cancelCB]) + + # For easier override/extend in subclasses + show = T.show + + +###################################################################### +# Display some information. +###################################################################### +class Info(T): + def __init__(self, master, title="Info", msg="I", + okLabel="OK"): + T.__init__(self, master, title, msg) + self.bitmap['bitmap'] = 'info' + self.btnBox.addButtons([okLabel, self.okCB]) + + # For easier override/extend in subclasses + show = T.show + + +###################################################################### +# Display instructions for the user. +###################################################################### +class Instruction(T): + def __init__(self, master, title="Instruction", msg="", + okLabel="Continue", cancelLabel="Cancel"): + T.__init__(self, master, title, msg) + self.bitmap.destroy() + self.btnBox.addButtons([okLabel, self.okCB], + [cancelLabel, self.cancelCB]) + + # For easier override/extend in subclasses + show = T.show + + +###################################################################### +# A progress dialog shows progress, i.e. percent-completion of a task. +###################################################################### +class Progress(T): + def __init__(self, master, title="Progress", msg="", + cancelLabel="Cancel"): + T.__init__(self, master, title, msg) + self.bitmap.destroy() + self.progressBar = ProgressBar.T(self.frame) + self.btnBox.addButtons([cancelLabel, self.cancelCB]) + self.progressBar.frame.pack(fill='x') + + # For easier overriding/extension in subclasses: + show = T.show + + ################################################################## + # Update the progress slider. + # percentComplete should be in the range 0..100, inclusive. + ################################################################## + def update(self, percentComplete): + self.progressBar.update(percentComplete) + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + f = Shells.Main() + l = Tk.Label(f, text="I'm just here for looks,\nreally.") + l.pack() + f.pack() + f.master.geometry("+100+100") + e = Error(f, msg="An unrecoverable error\nhas occurred.") + print "Error:", e.show() + w = Warning(f, msg="Since that error occurred, do you want to go on?") + print "Warning:", w.show() + print "Question:", Question(f, msg="No, I mean\ndo you really want\n" + "to go on?").show() + print "Info:", Info(f, msg="This parrot is dead.").show() + print "Instruction:", Instruction(f, + msg='Insert a blank tape and press ' + '"Continue".').show() + + class ProgressTest(Progress): + def __init__(self, master, title="Progress", msg=""): + Progress.__init__(self, master, title, msg) + self.value = 0 + + def show(self): + # Start the timer before showing, because Progress.show + # won't return until after the user has dismissed the dialog. + self.timerID = self.top.after(500, self.update) + result = Progress.show(self) + self.top.after_cancel(self.timerID) + return result + + def update(self, event=None): + self.value = self.value + 1 + Progress.update(self, self.value) + if self.value < 100: + self.timerID = self.top.after(40, self.update) + else: + self.result = 1 + self.terminate() + + + print "Progress:", ProgressTest(f, + msg="Reading Enc. Britannica").show() + + +if __name__ == "__main__": + main() diff --git a/uitools/ButtonBox.py b/uitools/ButtonBox.py new file mode 100644 index 0000000..456b3d5 --- /dev/null +++ b/uitools/ButtonBox.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +###################################################################### +# This module provides a composite widget for dialog buttons. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: ButtonBox.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import KWDict + +###################################################################### +# This class provides a horizontal array of std dialog buttons. +###################################################################### +class T: + ################################################################## + # Add buttons to self. + # argtuple should be a tuple of lists or tuples + # specifying the label and (optionally) command for each button + # to be added to the frame. + ################################################################## + def _addButtons(self, argtuple): + Button = Tk.Button + f = self.frame + maxWidth = self.maxWidth + for btnInfo in argtuple: + label, cmd = btnInfo[0], None + if len(btnInfo) > 1: + cmd = btnInfo[1] + btn = Button(f, KWDict.dict(text=label, command=cmd)) + btn.pack(side='left', padx='2m', expand='yes') + self.buttons[label] = btn + maxWidth = max(maxWidth, len(label)) + + self.maxWidth = maxWidth + for button in self.buttons.values(): + button['width'] = maxWidth + + ################################################################## + # Initialize a new instance. + # The optional arguments should each be lists or tuples + # specifying the label and (optionally) command for each button + # to be added to the frame. + ################################################################## + def __init__(self, master, *args): + self.master = master + f = self.frame = Tk.Frame(master, relief='raised', bd=1) + + self.buttons = {} + self.maxWidth = 6 + self._addButtons(args) + Tk.Pack.config(f, side='bottom', fill='x') + + ################################################################## + # Add buttons to self. + # The optional arguments should each be lists or tuples + # specifying the label and (optionally) command for each button + # to be added to the frame. + ################################################################## + def addButtons(self, *args): + self._addButtons(args) + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + return + +if __name__ == "__main__": + main() diff --git a/uitools/Cursors.py b/uitools/Cursors.py new file mode 100644 index 0000000..647b81c --- /dev/null +++ b/uitools/Cursors.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +###################################################################### +# This module defines commonly-used CDE cursors. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: Cursors.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter + +# If you're running Solaris >= 2.4, you might want to try these +# cursors: +#busyCursorTuple = ('@/usr/dt/include/bitmaps/xm_hour16', +# '/usr/dt/include/bitmaps/xm_hour16m', +# 'black', 'white') +#noEnterCursorTuple = ('@/usr/dt/include/bitmaps/xm_noenter16', +# '/usr/dt/include/bitmaps/xm_noenter16m', +# 'black', 'white') +# +# Clients can specify any of these cursors for CDE-style cursoring. +#busyCursor = "%s %s %s %s" % busyCursorTuple +#noEnterCursor = "%s %s %s %s" % noEnterCursorTuple + +busyCursor = "watch" +noEnterCursor = "X_cursor red white" + +# _instances records all instances of Mixin. (See below.) +_instances = {} + +###################################################################### +# This mixin class gives the ability to push and pop cursors on a +# widget. +###################################################################### +class Mixin: + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self): + # Register self to participate in cursoring. Whenever other + # Mixin instances pushOtherCursors(), self will get a new + # cursor. + _instances[self] = self + self.cursorStack = [] + + ################################################################## + # Destroy self. (Is this the right way to do this?) + ################################################################## + def destroy(self): + del _instances[self] # De-register self + + ################################################################## + # Set a cursor on self. + ################################################################## + def setCursor(self, cursor): + # Why not just self.config(cursor=cursor)? + # I originally wanted to be able to mix this in to class Tk. + apply(self.tk.call, (self._w, "configure", "-cursor", cursor)) + + ################################################################## + # Push a cursor on self. (Doesn't affect other CursorMixin + # instances.) + ################################################################## + def pushCursor(self, newCursor): + self.setCursor(newCursor) + self.cursorStack.append(newCursor) + + ################################################################## + # Pop a cursor off self. + ################################################################## + def popCursor(self): + stack = self.cursorStack + # Pop. + if stack: + del stack[-1] + # Re-install the old cursor, or else the default cursor. + if stack: + self.setCursor(stack[-1]) + else: + self.setCursor('') + + ################################################################## + # Push a cursor on all other registered instances. + # This is useful e.g. when displaying a modal dialog, and + # displaying busy cursors in other dialogs and toplevels. + ################################################################## + def pushOtherCursors(self, newCursor): + for w in _instances.keys(): + if w != self: + w.pushCursor(newCursor) + + ################################################################## + # Pop a cursor from all other registered instances. + ################################################################## + def popOtherCursors(self): + for w in _instances.keys(): + if w != self: + w.popCursor() + diff --git a/uitools/KWDict.py b/uitools/KWDict.py new file mode 100644 index 0000000..3fddac0 --- /dev/null +++ b/uitools/KWDict.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +###################################################################### +# This module provides a means of converting a keyword argument list +# to a dictionary. Thank you, "Internet Programming With Python". +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: KWDict.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +###################################################################### +# Build a dictionary using keyword notation. Include only items +# whose values are not None. +###################################################################### +def dict(**kw): + result = {} + for k, v in kw.items(): + if v != None: + result[k] = v + return result + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + return + +if __name__ == "__main__": + main() diff --git a/uitools/LabelEntry.py b/uitools/LabelEntry.py new file mode 100644 index 0000000..203427a --- /dev/null +++ b/uitools/LabelEntry.py @@ -0,0 +1,154 @@ +###################################################################### +# This class provides an Entry with a label on its left side. +# The text of the label can be set by means of the 'text' +# configuration key. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: LabelEntry.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter + +###################################################################### +# A MultiColEntry is a label together with one or more entries. +# Clients have direct access to the constituent widgets: +# frame -- the frame containing the other widgets. +# label -- the label appearing on the left side +# entries -- The entries arrayed across from left to right +###################################################################### +class MultiColEntry: + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, columns=2, entryClass=Tk.Entry): + f = self.frame = Tk.Frame(master) + l = self.label = Tk.Label(f, anchor='ne') + l.pack(side='left') + self.entries = [] + for i in range(columns): + e = entryClass(f) + self.entries.append(e) + e.pack(side='left', fill='x', expand='yes') + + ################################################################## + # Set the values of one or more entry fields. + # It's the caller's responsibility not to exceed the number of + # entries in self. + ################################################################## + def setValues(self, startIndex=0, *newValues): + i = startIndex + for v in newValues: + e = self.entries[i] + e.delete('0', 'end') + e.insert('end', v) + + +###################################################################### +# A labelled entry is a MultiColEntry with only one entry column. +# The instance variable "entry" provides a more-convenient way to +# refer to the sole entry widget. +###################################################################### +class LabelEntry(MultiColEntry): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, entryClass=Tk.Entry): + MultiColEntry.__init__(self, master, 1, entryClass) + self.entry = self.entries[0] + + ################################################################## + # Set the whole value of an Entry. + ################################################################## + def setValue(self, newValue): + self.entry.delete(0, 'end') + self.entry.insert('end', newValue) + + ################################################################## + # Delete LabelEntry text. + ################################################################## + def delete(self, first, last=None): + self.entry.delete(first, last) + + ################################################################## + # Insert LabelEntry text. + ################################################################## + def insert(self, index, string): + self.entry.insert(index, string) + +###################################################################### +# A ROLabelEntry is read-only, so far as the user is concerned. +###################################################################### +class ROLabelEntry(LabelEntry): + def __init__(self, master=None): + LabelEntry.__init__(self, master, Tk.Entry) + self.entry['state'] = 'disabled' + + ################################################################## + # Delete text from the entry. + ################################################################## + def delete(self, first, last=None): + self.entry['state'] = 'normal' + self.entry.delete(first, last) + self.entry['state'] = 'disabled' + + ################################################################## + # Insert text into the entry. + ################################################################## + def insert(self, index, string): + self.entry['state'] = 'normal' + self.entry.insert(index, string) + self.entry['state'] = 'disabled' + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + f = Tk.Frame() + + le = LabelEntry(f) + le.label['text'] = "New Value:" + newValue = "This is the new value." + le.entry['width'] = len(newValue) + le.insert('end', newValue) + + readOnly = ROLabelEntry(f) + readOnly.label['text'] = 'Label:' + readOnly.entry['relief'] = 'groove' + for w in [readOnly.label, readOnly.entry]: + w['width'] = 40 + + readOnly.insert('0', 'This is the value') + + mc = MultiColEntry(f, 8) + mc.label['text'] = "Multi:" + map(lambda e: e.config(width=8), mc.entries) + + for w in [le, readOnly, mc]: + w.label['width'] = 12 + w.frame.pack(fill='x') + + f.update() + f.pack() + f.wait_visibility() + f.after(2000, readOnly.insert, 'end', ' after 2 seconds') + + ################################################################## + # Insert text in a multi-column widget. + ################################################################## + def insertMulti(mc): + map(lambda e: e.insert('end', 'This is ' + `e`), mc.entries) + + mc.setValues(2, "This is entry 2.", "This is entry 3.", "This is entry 4.") + + f.after(4000, insertMulti, mc) + f.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/Menu.py b/uitools/Menu.py new file mode 100644 index 0000000..8b64615 --- /dev/null +++ b/uitools/Menu.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +###################################################################### +# Provide classes to simplify creating menubars, pulldown menus, etc. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: Menu.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import KWDict, string + +###################################################################### +# Convert a string of the form "&Something" to a tuple: +# ("Something", 0) +# indicating that the first letter of "Something" should be +# underlined. If no "&" occurs inside the string, the second tuple +# item will be None. +###################################################################### +def findUnderline(title): + ul = None + i = string.find(title, "&") + if i >= 0: + ul = i + title = title[:ul] + title[ul+1:] + return title, ul + + +###################################################################### +# This class represents a menubar, w. default relief, etc. +###################################################################### +class Menubar(Tk.Frame): + ################################################################## + # Create a new instance. + ################################################################## + def __init__(self, master=None, **kw): + opts = {'relief':'raised', 'bd':2} + for k,v in kw.items(): + opts[k] = v + apply(Tk.Frame.__init__, (self, master), opts) + Tk.Pack.config(self, side='top', fill='x') + + +###################################################################### +# This class represents a menu item in a Tk menu. +###################################################################### +class Item: + ################################################################## + # Create a new instance. + # master is the pane to which to append the new item. + # title is the text which will appear inside the menu item. It + # also determines what sort of menu item will be created. See + # below for details. + # cmd is the optional callback for this menu item. + # items specifies any items to be displayed in cascaded menus. + ################################################################## + def __init__(self, master, title, cmd=None, *items): + self.master = master + self.title = title + + if title[0] == "-": + master.add_separator() + self.index = master.index('last') + return + + submenu = None + createFn = master.add_command + prefix = title[0:2] + if prefix == "c_": + createFn = master.add_checkbutton + elif prefix == "r_": + createFn = master.add_radiobutton + elif prefix == "m_": + createFn = master.add_cascade + submenu = Pane(master) + apply(submenu.addItems, items) + else: + title = "x_" + title + + title, ul = findUnderline(title[2:]) + + # XXX no means is provided to associate a Tk variable with a + # radio button or a check button. + apply(createFn, (), KWDict.dict(label=title, command=cmd, + underline=ul, menu=submenu)) + self.index = master.index('last') + + ################################################################## + # Enable or disable an item. + ################################################################## + def enable(self, doEnable=1): + if doEnable: + newstate = 'normal' + else: + newstate = 'disabled' + + try: + # NOTE: This won't work for tear-off entries, or for separators. + self.master.tk.call(self.master._w, 'entryconfigure', self.index, + '-state', newstate) + except Tk.TclError: pass + +###################################################################### +# This class represents a pulldown menu. +###################################################################### +class Pulldown: + ################################################################## + # Create a new Pulldown menu. + # Each additional item should be a list consisting of menu + # item string and optional callback. Menu item strings are + # interpreted as indicated in parseMenuStr(). + # Returns the menu button which posts the new menu. + ################################################################## + def __init__(self, master, title, *items, **kw): + self.master = master + + packside='left' + if kw and 'side' in kw.keys(): + packside=kw['side'] + + # Create the menu button. + title, ul = findUnderline(title) + btnKeywords = KWDict.dict(text=title, underline=ul) + btn = apply(Tk.Menubutton, (master,), btnKeywords) + + # Create the menu pane and associate it with the button. + menu = btn.menu = Pane(btn) + btn['menu'] = menu + apply(menu.addItems, items) + + btn.pack(side=packside, padx='2m') + + +###################################################################### +# This class extends the Tkinter Menu class with convenience methods +# for enabling/disabling menu items and for support of popup menus. +###################################################################### +class Pane(Tk.Menu): + ################################################################## + # Init a new instance. + ################################################################## + def __init__(self, master=None, **kw): + apply(Tk.Menu.__init__, (self, master), kw) + self.items = [] + + ################################################################## + # Add items to a menu Pane. + ################################################################## + def addItems(self, *newItems): + # Create a menu item for each described item. + for item in newItems: + self.items.append(apply(Item, (self,) + tuple(item))) + + ################################################################## + # Enable or disable a menu item. + # If enable is non-zero, then the menu item is enabled; otherwise + # it is disabled. + ################################################################## + def enable(self, index, enable=1): + self.items[index].enable(enable) + + ################################################################## + # Pop up a menu pane -- + # useful when Pane is being used as a popup menu. + ################################################################## + def tk_popup(self, event=None): + x, y = 0, 0 + if event: + x, y = event.x_root, event.y_root + self.tk.call('tk_popup', self._w, x, y) + + +###################################################################### +# Mainline for testing +###################################################################### +def main(): + import TkDlgWrapper; TkDlg=TkDlgWrapper + + # To demonstrate, here's a simple file editor. + f = Tk.Frame() + m = Menubar(f) + t = Tk.Text(f) + + # Here are the file types which the open/save dialogs will recognize. + filetypes = (("Python", ".py"), ("Text", ".txt"), ("All Files", "*")) + openFile = TkDlg.OpenFile(f, defaultextension=".py", + filetypes=filetypes) + saveFile = TkDlg.SaveFile(f, defaultextension=".py", + filetypes=filetypes) + + # Here's an example of a popup menu. It should appear when + # you click mouse button 3 inside the text area. + textPopup = Pane(f, tearoff='0') + textPopup.addItems(["Cut"], ["Copy"], ["Paste"], ["Clear"]) + # Initially, disable all of the items. Enable them when a file + # is actually opened. + for item in textPopup.items: + item.enable(0) + t.bind("<3>", textPopup.tk_popup) + + def openAFile(openDlg = openFile, text=t, popup=textPopup): + filename = openDlg.show() + if filename: + text.delete('1.0', 'end') + inf = open(filename) + text.insert('end', inf.read()) + inf.close() + text.winfo_toplevel().title("Menu Example:%s" % filename) + # Enable the popup menu items. + for item in popup.items: + item.enable(1) + + def saveAFile(saveDlg = saveFile, text=t): + filename = saveDlg.show() + if filename: + outf = open(filename, "w") + outf.write(text.get('1.0', 'end')) + outf.close() + text.winfo_toplevel().title("Menu Example:%s" % filename) + + # Keyboard menu traversal appears to be broken under Tk 4.2 Try using + # keyboard traversal in the tk4.2 library's demo subdirectory... + fileMenu = Pulldown(m, "&File", + ["&Open...", openAFile], + ["Save &As...", saveAFile], + ["----"], + ["E&xit", m.quit]) + + helpMenu = Pulldown(m, "&Help", + ["&About"], + side='right') + + m.pack() + t.pack() + f.pack() + f.master.title("Menu Example") + f.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/ProgressBar.py b/uitools/ProgressBar.py new file mode 100644 index 0000000..0098ead --- /dev/null +++ b/uitools/ProgressBar.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +###################################################################### +# This module provides a progress bar, analogous to the progress +# sliders in Win 3.1 or the progress slider in Netscape Nav. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: ProgressBar.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter + +###################################################################### +# This class provides a progress slider embedded within a Tk frame. +###################################################################### +class T: + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, height=25, fillColor="blue"): + # Put the sunken relief on the frame, not the canvas. + # This way, if some subclass decides to embed a window within + # the canvas, the window won't ever draw itself into the + # relief area. + self.master = master + self.frame = Tk.Frame(master, relief='sunken', bd=2) + self.canvas = Tk.Canvas(self.frame, height=height, bd=0, + highlightthickness=0) + self.scale = self.canvas.create_rectangle(-10, -10, 0, height, + fill=fillColor) + self.canvas.pack(side='top', fill='x', expand='no') + + ################################################################## + # Specify the completion value for self. + # percentComplete must be in the range 0..100, inclusive. + ################################################################## + def update(self, percentComplete): + c = self.canvas + width, height = c.winfo_reqwidth(), c.winfo_reqheight() + c.coords(self.scale, -10, -10, + percentComplete * width / 100.0, height) + c.update_idletasks() + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + class Test: + def __init__(self): + self.progress = T() + self.fraction = 0 + self.progress.frame.pack() + self.progress.frame.after(1000, self.update) + + def update(self, event=None): + self.fraction = self.fraction + 1 + self.progress.update(self.fraction) + self.progress.frame.after(30, self.update) + + t = Test() + Tk.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/README b/uitools/README new file mode 100644 index 0000000..61471d1 --- /dev/null +++ b/uitools/README @@ -0,0 +1,186 @@ +________________________________________________________________________ +About UITools + + There's been talk recently in comp.lang.python of the need + for a set of "standard" composite widget classes to complement + Tkinter.py. Here's my attempt to get the ball rolling. + + This library consists of several modules which implement various + Python composite widget ("metawidget") classes. Most of these + classes are derived from code which I've been using for the past + year or so, but which I haven't heretofore organized. + + Most modules have a "principal export". That is, they define + one main class along with zero or more derived/supporting + classes. The principal export is usually a class named "T". + This implies that these modules should be imported using + "import " rather than "from import *". + + Classes which represent dialogs do not include "Dialog" or "Dlg" + in the class names. For example, Alerts.py exports several + alert dialog classes having names like Error, Warning, Question, + etc; Shells.py exports classes Main, Toplevel, NonModal and + Modal. + + I omitted the "Dialog" suffix on the grounds that it was + redundant. Please let me know if you think this was a mistake. + + Most modules include their own unit-test code, exported as + function main(). If a module is imported as a program's main + module, its main() function is automatically invoked. The + main() functions serve not only as rudimentary unit-tests, but + also as examples of how to use the classes defined in their + containing modules. For example, Menu.main() fires up a + simple-minded text editor, equipped with both Pulldown and Popup + menus. + + Here's hoping this stuff is useful. At the least, I hope it + catalyzes contributions of other Python-based composite widget + classes. + + + -- Mitch Chapman + mchapman@erinet.com + 1 December 1996 + +________________________________________________________________________ +Constituent Modules + + +Alerts.py + Provides various error/warning/info dialogs. This module is + inspired by the alerts.py module of the tkdialogs archive. + + The principal exported class, T, is a generic alert dialog. + Exported subclasses include: + Error -- for displaying error messages + Warning -- for displaying warnings + Question -- yes/no question presentation + Info -- displays informational messages + Instruction -- asks the user to perform a task and + press "Continue" + Progress -- shows the progress of some operation + +ButtonBox.py + Provides a frame which packs buttons horizontally. + Buttons can be accessed from the .buttons member dictionary, + using the labels by which the buttons were initially created. + + (Would a simple array of buttons be easier to use?) + +Cursors.py + Provides a means of controlling cursors on an application-wide + basis. (For an example of use, see Shells.py.) + +KWDict.py + Provides a way to construct dictionaries from keyword argument + lists. Many thanks to "Internet Programming With Python," + whence this code was stolen. + +LabelEntry.py + Provides multi-column, single-column and read-only labelled + entries. + +Menu.py + Provides a convenient means of constructing pulldown and popup + menus. With a single method call you can construct an entire + pulldown menu, complete with cascaded submenus. + + Exported classes include Menubar (basically just a Tk.Frame); + Item, representing a single item within a menu pane; Pulldown, + representing a Menubutton and its associated menu pane; and + Pane, which can serve as either the pane for a Pulldown or as a + Popup. + + This module was inspired by, but is less capable than, the + menu-creation functions in XEmacs 19.14. + + This module needs more work to properly support Checkbutton and + Radiobutton menu items. The current version makes it hard to + associate Tk variables with such menu items. + +ProgressBar.py + Provides a status bar for displaying task completion percentages. + For an example of the use of a progress bar in a dialog, see + Alerts.py. + +Scrolled.py + Provides scrollbar decorations, along with scrolled texts, + canvases and lists. Exports include T, a generic + scrolled-window composite class into which an arbitrary "view" + widget may be inserted; Text, a scrolled text window; Canvas, a + scrolled canvas window; and List, a scrolled listbox. + + This module was written using the Tk packer, rather than the + Tk 4.1+ grid widget. (The 4.1 grid widget looked a little + unreliable to me, and apparently not many people have yet + installed Tk 4.2.) + + The scrollbars extend only to the bottom-right edge of the + scrolled view, using a technique described in Welch's "Practical + Programming with Tcl/Tk". (Dang! Is that the right title?) + + In this version, both vertical and horizontal scrollbars are + displayed fulltime. I haven't yet found a way to dynamically + map and unmap the scrollbars which doesn't introduce + "flickering". (The scrollbar will map and unmap two or three + times before settling into a visible or invisible state.) Your + help is kindly requested. + + +Shells.py + Provides various top-level, non-modal and modal dialog shells, + together with application-wide cursor control. Exports include + Main, a subclass of Tk.Frame; Toplevel, derived from + Tk.Toplevel; and NonModal and Modal dialog classes. + + Main and Toplevel are provided because they support + application-wide cursor management. That is, when a + Shells.Modal dialog is mapped, all existing Main, Toplevel and + NonModal windows display a "do not enter" cursor. + + The dialog classes are similar to those provided by the + tkdialogs archive. They differ in that they can easily be + created and initialized without being displayed. So, for + example, if you have a non-modal dialog which shows logged + network traffic, you can create it as soon as your application + starts and display it only when the user requests to see it. + +StdDialog.py + Provides "standard" modal and non-modal dialogs, as well as + button boxes populated with OK, Cancel and Help buttons. + + Exported classes include Controls, a composite containing a + frame and a ButtonBox.T, stacked vertically; StdControls, a + subclass of Controls which populates its ButtonBox.T with "OK", + "Cancel" and "Help" buttons; NonModal and Modal, two dialog + classes whose managed contents consist of StdControl instances. + + This module has a really poor design. I was trying to build a + framework for "typical" dialog windows, which contain a set of + controls packed into a frame, below which is a row of + dialog buttons. Class Controls represents the "typical" dialog + contents; StdControls represents typical contents along with + typical dialog buttons; and NonModal and Modal are dialogs whose + innards are StdControls. + + Please help me straighten this out. + +TkDlgWrapper.py + Provides wrappers around the standard dialogs included with + Tk 4.2. (NOTE: This module _requires_ Tk 4.2 or later.) + + The most significant exported classes include ChooseColor, an + interactive color-selection dialog, and OpenFile and SaveFile, + file open/save dialogs which display directory contents using + folder and document icons. + + I originally released this module in October 1996 as "TkDialogs.py", + not knowing that ftp.python.org already had a tkdialogs archive. + Both this module module and the containing archive (UITools) + now have names which bear no resemblance to "tkdialogs", so + ending any potential name conflicts. I hope. + + The classes have been renamed to omit "Dlg" from the class + names, but are otherwise unchanged from the original release. + diff --git a/uitools/Scrolled.py b/uitools/Scrolled.py new file mode 100644 index 0000000..e2c1a53 --- /dev/null +++ b/uitools/Scrolled.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +###################################################################### +# Provides a generic window containing a frame and h/v scrollbars. +# Subclasses pack the scrollable/scannable thing into the frame and +# hook it up to the scrollbars by invoking setView. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: Scrolled.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import string, time + +###################################################################### +# This is the scrolled window class. +###################################################################### +class T: + ################################################################## + # Specify the view to be scrolled by self. + ################################################################## + def _setView(self, newView): + self.view = newView + self.view.pack(in_=self.borderframe, fill='both', expand='yes') + self.vsPacking['before'] = self.view + # Attach the scrollbars. Note that scrollable must support + # these methods: xview, yview, xscrollcommand, yscrollcommand. + self.vscroll['command'] = self.view.yview + self.hscroll['command'] = self.view.xview + self.view['xscrollcommand'] = self.hscroll.set + self.view['yscrollcommand'] = self.vscroll.set + # Make sure the view is visible to the user + # Gotta explicitly use Tk.Misc.tkraise, because if the view + # is a canvas it defines its own tkraise -- whose purpose + # is to change the stacking order of canvas items. + Tk.Misc.tkraise(self.view, self.borderframe) + + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, view=None): + self.master = master + frame = self.frame = Tk.Frame(master) + + # The inner frame is provided because the Tk Text widget + # (<= Tk 4.1) wasn't smart enough to keep embedded windows from + # drawing over its border. This inner frame can take the place + # of the Text border in subclasses. + self.borderframe = Tk.Frame(frame, relief='sunken', bd=3, + highlightthickness=2) + + # Create the scrollbars. Record their packing rules for later + # use in dynamically mapping/unmapping scrollbars. + self.vsPacking = {'side':'right', 'fill':'y'} + vs = self.vscroll = Tk.Scrollbar(frame, orient='vertical', width=12) + + # The horizontal scrollbar goes into its own frame at the + # bottom. This offers the opportunity to stick a "spacer" + # frame to the right of the horizontal bar, so both scrollbars + # appear to stop at the edge of the contained view. + # This is stolen from "Practical Programming in Tcl and Tk," by + # Brent B. Welch. + padsize = self.padsize = (string.atoi(vs['width']) + + 2 *(string.atoi(vs['bd']) + + string.atoi(vs['highlightthickness']))) + hsFrame = self.hsFrame = Tk.Frame(frame) + # Here's the "spacer" frame. + hsPad = self.hsPad = Tk.Frame(hsFrame, width=padsize, height=padsize) + + hs = self.hscroll = Tk.Scrollbar(hsFrame, orient='horizontal', + width=12) + hs.pack(side='bottom', fill='x') + hsPad.pack(side='right', before=self.hscroll) + # This time, the packing is for self.hsFrame + self.hsFramePacking = {'before':vs, + 'side':'bottom', 'fill':'x', 'expand':'no'} + + apply(vs.pack, (), self.vsPacking) + self.borderframe.pack(fill='both', expand='yes') + apply(hsFrame.pack, (), self.hsFramePacking) + + self.view = None + self.hsPacked = 1 + self.vsPacked = 1 + + # Bad move: initializer invoking another method on self. + # At least _setView is named so as to indicate that subclasses + # should not override it directly... + if view: + self._setView(view) + + ################################################################## + # Install a scrollable thingy as the view for self. + # Note that you can change views on the fly, though it's up to + # you to remove any old views before setting the new one. + # newView should be a widget. It must provide Tkinter.Text-style + # methods xview() and yview(), and have configurable + # xscrollcommand and yscrollcommand attributes. + ################################################################## + def setView(self, newView): + self._setView(newView) + + +###################################################################### +# This is a scrolled text class. +###################################################################### +class Text(T): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None): + self.text = Tk.Text(master, relief='flat') + T.__init__(self, master, self.text) + +###################################################################### +# This is a scrolled canvas class. +###################################################################### +class Canvas(T): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None): + self.canvas = Tk.Canvas(master) + T.__init__(self, master, self.canvas) + +###################################################################### +# A List differs from other Scrolled types in that it has only a +# vertical scrollbar. (This despite the fact that lists with long +# entries could benefit from horizontal scrollbars...) +###################################################################### +class List(T): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None): + self.listbox = Tk.Listbox(master) + T.__init__(self, master, self.listbox) + self.hsFrame.forget() + self.view['xscrollcommand'] = None + self.hscroll['command'] = None + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + if 0: + # Here's one way to build a custom scrolled window: + scroller = T(view=Tk.Text(relief='flat', wrap='none')) + scroller.frame.pack(fill='both', expand='yes') + scroller.frame.master.title("Second Scroller") + Tk.mainloop() + else: + f = Tk.Frame() + t = Text(f) + # Turn off text wrapping so you can see the scrollbars at work. + t.text.configure(width=32, height=5, wrap='none') + t.text.insert('end', "This is a text.") + t.frame.pack(fill='both', expand='yes') + + c = Canvas(f) + c.canvas.configure(scrollregion="-100 -100 1000 1000") + c.canvas.create_text(0, 0, text="This is a canvas.", anchor="nw") + c.canvas.create_arc(300, 300, 400, 400, extent=270, fill='red') + c.canvas.create_oval(100, 100, 200, 200, fill='blue') + c.frame.pack(fill='both', expand='yes') + + l = List(f) + l.listbox.insert('end', "This is a listbox.") + for i in range(1, 21): + l.listbox.insert('end', "%d Mississippi" % i) + l.frame.pack(fill='both', expand='yes') + + f.master.title("Sample Scrolled Windows") + f.pack() + Tk.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/Shells.py b/uitools/Shells.py new file mode 100644 index 0000000..623deea --- /dev/null +++ b/uitools/Shells.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python +###################################################################### +# This module provides base classes for various shell classes. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: Shells.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import Cursors + +###################################################################### +# Make widget a transient for master. +# widget should be a Toplevel. +###################################################################### +def makeTransientFor(widget, master): + shell = master.winfo_toplevel() + name = shell.group() or "." + widget.group(name) + widget.transient(widget._nametowidget(name)) + +###################################################################### +# This is a Tk Frame which knows how to register itself for cursor +# stacking. It is intended to serve as the main window of an +# application. +###################################################################### +class Main(Tk.Frame, Cursors.Mixin): + def __init__(self, master=None, **kw): + apply(Tk.Frame.__init__, (self, master), kw) + apply(Cursors.Mixin.__init__, (self,)) + + def destroy(self): + Tk.Frame.destroy(self) + Cursors.Mixin.destroy(self) + + +###################################################################### +# This is a Tk Toplevel which knows how to register itself for cursor +# stacking. +###################################################################### +class Toplevel(Tk.Toplevel, Cursors.Mixin): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, **kw): + apply(Tk.Toplevel.__init__, (self, master), kw) + apply(Cursors.Mixin.__init__, (self,)) + + ################################################################## + # Destroy an instance. + ################################################################## + def destroy(self): + Tk.Toplevel.destroy(self) + Cursors.Mixin.destroy(self) + + +###################################################################### +# This is a modal dialog class. +###################################################################### +class NonModal: + ################################################################## + # Initialize a new instance. master should be a widget in front + # of which self must appear. + ################################################################## + def __init__(self, master): + self.master = master + self.top = Toplevel(self.master) + self.top.withdraw() + self.isShowing = 0 + self.top.title(self.__class__.__name__) + self.waitVar = `self.top` + 'EndDialogVar' + + # Indicate that the dialog has not been centered in front of + # its master before. + self.isCentered = 0 + + # Modal dialogs may need to block input from other application + # windows by setting a grab on them. oldGrabber is where the + # previous owner of the app grab (if any) is saved. + self.oldGrabber = None + + # In case somebody manages to blow away self by underhanded + # means, stop waiting for waitVar. + self.top.protocol("WM_DELETE_WINDOW", self.deleteWindow) + + # A dialog has a result of arbitrary type. + # The result should be considered valid after the dialog is + # dismissed. + # Modal dialogs can use this to return arbitrary + # descriptions of what the user did. For example, a file- + # selection dialog might return either the pathname specified + # by the user or, if the dialog was cancelled, None. + self.result = None + + ################################################################## + # Default response to an attempt to delete the window: ignore it. + ################################################################## + def deleteWindow(self): + return + + ################################################################## + # Show a dialog. + ################################################################## + def show(self): + if self.isShowing: + # Dialog is already visible. Raise it to the top. + self.top.tkraise() + return + # Give us a chance to initialize contents. Other program + # state may have changed while the dialog was unmapped. + self.initControls() + + self.center() + # Remember the old grab owner, for modal subclasses. + self.oldGrabber = self.top.grab_current() + + self.top.deiconify() + + # Here's where modal subclasses get a chance to set a grab. + self.top.wait_visibility() + self.modalGrab() + + self.top.focus() + # Old results are invalid, now. + self.result = None + self.isShowing = 1 + + ################################################################## + # Initialize the contents of the dialog, e.g. put default values + # into text fields. + ################################################################## + def initControls(self): + return + + ################################################################## + # Center the dialog in front of its master, if possible. + ################################################################## + def center(self): + if self.isCentered: + # Center the dialog only the first time it is displayed. + # After that, just pop it up where the user left it. + return + + # Try to ensure any geometry requests have been processed. + self.top.update_idletasks() + + w = self.master.winfo_width() + h = self.master.winfo_height() + x = self.master.winfo_rootx() + y = self.master.winfo_rooty() + reqw = self.top.winfo_reqwidth() + reqh = self.top.winfo_reqheight() + + centerx = `x + (w - reqw)/2` + centery = `y + (h - reqh)/2` + geomStr = "+" + centerx + "+" + centery + self.top.geometry(geomStr) + self.isCentered = 1 + + ################################################################## + # This is a hook for modal dialog subclasses. It gives them a + # chance to do a grab_set on the dialog's Toplevel, to prevent + # input from being delivered to other application windows. + ################################################################## + def modalGrab(self): + return + + ################################################################## + # This is another hook for modal dialog subclasses. It allows + # for the release of any grab set via modalGrab. + ################################################################## + def modalReleaseGrab(self): + return + + ################################################################## + # Terminate the dialog, returning its result. + ################################################################## + def terminate(self): + # Set the wait variable, so modal dialogs will know they're + # done. + self.top.setvar(self.waitVar, 1) + self.top.withdraw() + self.isShowing = 0 + + # Here's where modal subclasses get a chance to release any + # grab they may have taken. + self.modalReleaseGrab() + + self.master.focus() + return self.result + + ################################################################## + # Destroy the visuals. + ################################################################## + def destroy(self): + self.top.destroy() + + +###################################################################### +# This class represents an application-modal dialog. +###################################################################### +class Modal(NonModal): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master): + apply(NonModal.__init__, (self, master)) + + # Make self a transient for its master. This basically forces + # self to remain in front of its master, and not to be + # separately iconifiable. (Depends on the window manager?) + # + # Note this isn't done for non-modal dialogs. Sorry, + # OpenWindows fans, but I can't stand having to slide + # non-modal dialogs out of the way just so I can see the + # master window... + makeTransientFor(self.top, master) + + # If somebody had an application-wide pointer grab, + # remember them here. When the dialog is dismissed, the + # grab will be returned to the previous owner. + self.oldGrabber = None + + ################################################################## + # Show a modal dialog. + ################################################################## + def show(self): + apply(NonModal.show, (self,)) + self.top.waitvar(self.waitVar) + return self.result + + ################################################################## + # Put an application-wide grab into effect, to block input from + # other application windows. + ################################################################## + def modalGrab(self): + # Display a do-not-enter cursor in front of all other toplevels + # in the application. + self.top.pushOtherCursors(Cursors.noEnterCursor) + self.top.grab_set() + + ################################################################## + # Release the grab obtained via modalGrab(). + ################################################################## + def modalReleaseGrab(self): + self.top.grab_release() + # If somebody else owned the grab before we did, give it + # back to them. + if self.oldGrabber and (self.oldGrabber != self.top): + self.oldGrabber.grab_set() + + self.top.popOtherCursors() + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + global f, nonmodal, modal + + # Display a non-modal dialog. + f = Main() + nonmodal = None + modal = None + + def showNonModal(event=None): + global f, nonmodal + + if not nonmodal: + nonmodal = NonModal(f) + label = Tk.Label(nonmodal.top, + text="This is a non-modal dialog.") + label.pack() + + def quitCB(event=None, dlg=nonmodal): + result = dlg.terminate() + print "Dialog terminated with result", `result` + btn = Tk.Button(nonmodal.top, text="OK", command=quitCB) + btn.pack() + + nonmodal.show() + + def showModal(event=None): + global f, modal + + if not modal: + modal = Modal(f) + label = Tk.Label(modal.top, text="This is a modal dialog.") + label.pack() + + def quitCB(event=None, dlg=modal): + result = dlg.terminate() + print "Dialog terminated with result", `result` + btn = Tk.Button(modal.top, text="OK", command=quitCB) + btn.pack() + + modal.show() + + for text, command in [("NonModal", showNonModal), + ("Modal", showModal), + ("Quit", f.quit)]: + b = Tk.Button(f, text=text, width=10, command=command) + b.pack() + + f.pack() + f.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/StdDialog.py b/uitools/StdDialog.py new file mode 100644 index 0000000..3e19e12 --- /dev/null +++ b/uitools/StdDialog.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +###################################################################### +# This module provides some standard dialog classes. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: StdDialog.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import Shells, ButtonBox + +###################################################################### +# This class creates basic dialog contents: a frame and +# a btnBox. +###################################################################### +class Controls: + ################################################################## + # Init a new instance. master must be a Shells.NonModal + # subclass. + ################################################################## + def __init__(self, master, okCB=None, cancelCB=None, helpCB=None): + self.master = master + + # Subclasses should put their custom controls inside the + # frame. + self.frame = Tk.Frame(master.top, relief='raised', bd=1) + self.frame.pack(side='top', fill='both', expand='yes') + + self.btnBox = ButtonBox.T(master.top) + self.btnBox.frame.pack() + + +###################################################################### +# This class creates "standard" dialog contents and some std buttons. +###################################################################### +class StdControls(Controls): + ################################################################## + # Init a new instance. master must be a Shells.NonModal + # subclass. + ################################################################## + def __init__(self, master, okCB=None, cancelCB=None, helpCB=None): + apply(Controls.__init__, (self, master)) + + self.btnBox.addButtons(["OK", okCB], + ["Cancel", cancelCB], + ["Help", helpCB]) + +###################################################################### +# This is a non-modal dialog with the standard dialog buttons. +###################################################################### +class NonModal(Shells.NonModal): + ################################################################## + # Init a new instance. + # The optional arguments are lists or tuples, each specifying the + # name and (optionally) command for a new dialog button. + ################################################################## + def __init__(self, master, *args): + Shells.NonModal.__init__(self, master) + self.controls = StdControls(self, self.okCB, self.cancelCB, + self.helpCB) + + ################################################################## + # Define callbacks for the standard buttons, for subclasses. + ################################################################## + def okCB(self, event=None): + self.terminate() + return self.result + + def cancelCB(self, event=None): + self.terminate() + return self.result + + def helpCB(self, event=None): + return + +###################################################################### +# This is a modal dialog with the standard set of dialog buttons +# arrayed across the bottom. +###################################################################### +class Modal(Shells.Modal): + ################################################################## + # Initialize a new instance. + # The optional arguments are lists or tuples, each specifying the + # name and (optionally) command for a new dialog button. + ################################################################## + def __init__(self, master, *args): + Shells.Modal.__init__(self, master) + self.controls = StdControls(self, self.okCB, self.cancelCB, + self.helpCB) + + ################################################################## + # Define callbacks for the standard buttons, for subclasses. + ################################################################## + def okCB(self, event=None): + self.terminate() + return self.result + + def cancelCB(self, event=None): + self.terminate() + return self.result + + def helpCB(self, event=None): + return + + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + f = Shells.Main() + f.pack() + + quitBtn = Tk.Button(f, text="Quit", command=f.quit) + quitBtn.pack() + + d = Modal(f) + Tk.Label(d.controls.frame, text="Press a button").pack() + + result = d.show() + print "Modal Result =", result + d.destroy() + d = NonModal(f) + Tk.Label(d.controls.frame, + text="Press a button\nwhenever you're ready").pack() + d.show() + f.mainloop() + +if __name__ == "__main__": + main() diff --git a/uitools/TkDlgWrapper.py b/uitools/TkDlgWrapper.py new file mode 100644 index 0000000..54b2236 --- /dev/null +++ b/uitools/TkDlgWrapper.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python +###################################################################### +# Try to access some of the newer Tk dialogs. +# This module is inspired by the Python 1.[34] Dialog.py module. +# +# Mitch Chapman +#--------------------------------------------------------------------- +# $Log: TkDlgWrapper.py,v $ +# Revision 1.1 1996/12/01 22:58:54 mchapman +# Initial revision +# +###################################################################### + +__version__ = "$Revision: 1.1 $" + +import Tkinter; Tk=Tkinter +import os, copy + +VersionError = "VersionError" + +if Tk.TkVersion < 4.2: + raise VersionError, "This module requires Tk 4.2 or greater." + +###################################################################### +# This is an abstract wrapper for all classes of built-in Tk dialogs. +# It's subclassed from Tk.Widget to gain access to widget +# configuration methods. +###################################################################### +class T(Tk.Widget): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, widgetName, master=None, **kw): + self.widgetName = "__%s__" % widgetName + # The unadorned widgetName is the Tk command/proc which + # displays the dialog, e.g. tk_chooseColor + self.tkCommand = widgetName + self.master = master + # Defer the actual widget initialization. I want instances + # of T to persist even when they are not visible. For + # example, this allows a file selection dialog to remember + # what file extension it last selected. But in order to allow + # persistence, the creation of the underlying Tk dialog must + # be delayed. + + # Make a copy of the configuration, so it can persist. This + # is where self remembers things such as the last file type + # selected, last color used, etc. + self.kw = copy.copy(kw) + + ################################################################## + # Show the dialog. + ################################################################## + def show(self, **kw): + # Use the configuration options to override the current config -- + # and remember them for later. + for k, v in kw.items(): + self.kw[k] = v + Tk.Widget._setup(self, self.master, self.kw) + resultStr = apply(self.tk.call, (self.tkCommand,) + + self._options(self.kw)) + try: + # Don't leak Python widget descriptors? + Tk.Widget.destroy(self) + except Tk.TclError: pass + + return resultStr + + ################################################################## + # Explicitly destroy a widget instance. Nothing to do, here, + # because the widget is gone as soon as show() returns. + ################################################################## + def destroy(self): + pass + + +###################################################################### +# Abstract wrapper for the Tk 4.2 tk_get\(Open\|Save\)File dialog. +# Tries to remember the last-used directory, so it can be used as +# the initial directory next time the dialog is displayed. +###################################################################### +class OpenSaveFile(T): + ################################################################## + # Initialize a new instance. whichDlg tells whether this is + # a tk_getOpenFile or a tk_getSaveFile, etc. + ################################################################## + def __init__(self, whichDlg, master=None, **kw): + apply(T.__init__, (self, whichDlg, master), kw) + self.filename = None + + ################################################################## + # Show the dialog and return either the selected file or "". + ################################################################## + def show(self, **kw): + self.filename = apply(T.show, (self,), kw) + # Try to remember a few configuration items for the next time + # the dialog is displayed. + if self.filename: + dirname, basename = os.path.split(self.filename) + + # Next time, start in the directory we ended in this time. + self.kw['initialdir'] = dirname + + # Try to figure out what extension to use next time. + ext = os.path.splitext(basename)[1] + self.kw['defaultextension'] = "%s" % ext + + # Try to specify the default name to use next time. + self.kw['initialfile'] = basename + + # The Tk dialogs are a little stupid: Even if you specify + # an initial file with extension ".txt", the dialog will show + # only files matching the first set of extensions in the + # -filetypes option. + # My cheap workaround is to create a new first entry for + # -filetypes which matches the extension selected this + # time. + oldtypes = () + if self.kw.has_key('filetypes'): + oldtypes = self.kw['filetypes'] + # The following works only for Unix (and maybe Windows). + # Somebody wanna fix this for handling Mac file types? + newtypes = () + dfltExt = ext or "*" + dfltDescription = "(Last Selected)" + for typespec in oldtypes: + description, types = typespec + if description == dfltDescription: + newtypes = ((dfltDescription, dfltExt),) + oldtypes[1:] + break # EXIT FOR LOOP + else: + newtypes = ((dfltDescription, dfltExt),) + oldtypes + + self.kw['filetypes'] = newtypes + + # The Tk dialogs are a little smart: If you create both an + # open and a save dialog in the same application, they seem + # to both slave to the same working directory. Change to dir + # "/blah" when opening? Then when you try to save, the + # default displayed directory will be "/blah". + # This may be a bug, given what the man pages say, but + # I like it. + return self.filename + +###################################################################### +# Wrapper for the tk_getOpenFile dialog. +###################################################################### +class OpenFile(OpenSaveFile): + def __init__(self, master=None, **kw): + apply(OpenSaveFile.__init__, (self, "tk_getOpenFile", master), kw) + + +###################################################################### +# Wrapper for the tk_getSaveFile dialog. +###################################################################### +class SaveFile(OpenSaveFile): + def __init__(self, master=None, **kw): + apply(OpenSaveFile.__init__, (self, "tk_getSaveFile", master), kw) + + +###################################################################### +# Wrapper for the tk_chooseColor dialog. +###################################################################### +class ChooseColor(T): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, **kw): + apply(T.__init__, (self, "tk_chooseColor", master), kw) + + +###################################################################### +# Wrapper for the tk_messageBox dialog. +###################################################################### +class MessageBox(T): + ################################################################## + # Initialize a new instance. + ################################################################## + def __init__(self, master=None, defaultCfg=None, **kw): + cfg = Tk._cnfmerge((defaultCfg or {}, kw)) + apply(T.__init__, (self, "tk_messageBox", master), cfg) + + +###################################################################### +# These are the specific types of MessageBox dialogs provided by Tk. +# For each of these classes, the return value of show() is a string +# containing the label of the button which was pressed. +# +# Yeah, I know, these aren't particularly useful when you can roll +# your own much more beautiful dialogs in Python. But hey! They're +# here and they work, and they're supposed to have native look-n-feel +# on every supported platform. +###################################################################### + +class AbortRetryIgnore(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, + (self, master, {'icon':'error', 'type':'abortretryignore'}), kw) + +class OK(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, (self, master, {'type':'ok'}), kw) + +class OKCancel(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, (self, master, {'type':'okcancel'}), kw) + +class RetryCancel(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, + (self, master, {'icon':'error', 'type':'retrycancel'}), kw) + +class YesNo(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, + (self, master, {'icon':'question', 'type':'yesno'}), kw) + +class YesNoCancel(MessageBox): + def __init__(self, master=None, **kw): + apply(MessageBox.__init__, + (self, master, {'icon':'question', 'type':'yesnocancel'}), kw) + + +###################################################################### +# Don't wrap tk_dialog -- that's already done in Dialog.py. +###################################################################### + +###################################################################### +# Main function for unit testing. +###################################################################### +def main(): + # Demo all of the message box dialog classes: + for c in [AbortRetryIgnore, OK, OKCancel, + RetryCancel, YesNo, YesNoCancel]: + d = c(title=c.__name__) + print d.show(message="This is an instance of %s." % c.__name__) + + # Demo the color chooser dialog: + d = ChooseColor(title="PickaKuhla", initialcolor="SlateGray") + color = d.show() + print "Selected color is", `color` + + # Demo the open file dialog. + d = OpenFile(defaultextension=".py", + filetypes=(("Python", ".py"), + ("Emacs Lisp", ".el"), + ("Text", ".txt"), + ("All Files", "*"))) + + # Run multiple times to demonstrate how the dialog remembers the + # last working directory, file extension, etc. + for i in range(2): + filename = d.show() + print "Open file", `d.filename` + + print 72 * "_" + print "Current working directory is", os.getcwd() + d = SaveFile(defaultextension=".py", + filetypes=(("Pithon [er, Python]", ".py"), + ("Text", ".txt"), + ("All Files", "*"))) + for i in range(2): + filename = d.show() + print "Save File As", `d.filename` + + +if __name__ == "__main__": + main() -- cgit v1.2.1