diff --git a/.gitignore b/.gitignore
index f24cd99..cfac725 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,7 @@ pip-log.txt
#Mr Developer
.mr.developer.cfg
+
+# Keymaps are automatically generated.
+
+*.sublime-keymap
diff --git a/Default.sublime-commands b/Default.sublime-commands
new file mode 100644
index 0000000..eaa1461
--- /dev/null
+++ b/Default.sublime-commands
@@ -0,0 +1,32 @@
+[
+ {
+ "caption": "E-Max: Fill Paragraph",
+ "command": "emax_fill_paragraph"
+ }
+ ,{
+ "caption": "E-Max: Rebuild E-Max Keymaps",
+ "command": "emax_rebuild_keymaps"
+ }
+ ,{
+ "caption": "E-Max: Forward S-Expression",
+ "command": "emax_move_sexp",
+ "args": {"forward": true}
+ }
+ ,{
+ "caption": "E-Max: Backward S-Expression",
+ "command": "emax_move_sexp",
+ "args": {"forward": false}
+ }
+ ,{
+ "caption": "E-Max: Jump to Current Diff Hunk",
+ "command": "emax_jump_to_hunk"
+ }
+ ,{
+ "caption": "E-Max: Transpose Words",
+ "command": "emax_transpose_words"
+ }
+ ,{
+ "caption": "E-Max: Transpose Chars",
+ "command": "emax_transpose_chars"
+ }
+]
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..c0555cb
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,19 @@
+Copyright (c) 2012
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/README.md b/README.md
deleted file mode 100644
index f586de8..0000000
--- a/README.md
+++ /dev/null
@@ -1,4 +0,0 @@
-E-Max
-=====
-
-Emacs keybindings for the Sublime Text editor.
\ No newline at end of file
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..c8c11ab
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,27 @@
+
+E-Max
+=====
+
+There are several plugins that emulate other editors' keybindings for Sublime
+Text 2, but only E-Max gives you the *maximum* amount of E...macs.
+
+Rather than just emulating a few cursor-movement keystrokes inconsistently
+across platforms, this plugin aims to comprehensively emulate the behavior
+you're used to, mapping the same relative physical keys the same way on each
+platform that Sublime supports, so that a true Emacs veteran will instantly feel
+at home. Kill-ring, swap point and mark, the meta key in the right position
+(under your thumb), cancel with C-g, isearch with recall: it's all (mostly)
+there. And if it's not, feel free to send a pull request!
+
+As it was written by a Python programmer, this also includes some Python-specific emacs
+goodies, like docstring wrapping and some python-specific
+bindings, such as emulation of python-shift-left/python-shift-right.
+
+E-Max was also designed with courtesy in mind. Pair programming partner doesn't
+like Emacs? No problem! Just hit a keystroke that all we keyboard
+contortionists will have no difficulty with – control-meta-shift-quote – and you
+can instantly disable all emacs-like behavior and go back to the platform-
+variable Sublime Text defaults.
+
+
+Copyright © 2012
diff --git a/base-keymap.json b/base-keymap.json
new file mode 100644
index 0000000..0fac481
--- /dev/null
+++ b/base-keymap.json
@@ -0,0 +1,603 @@
+[
+ {
+ "keys": ["meta+b"],
+ "args": {"forward": false, "by": "words"},
+ "command": "move"
+ },
+ {
+ "keys": ["meta+q"],
+ "command": "emax_fill_paragraph"
+ },
+ {
+ "keys": ["meta+t"],
+ "command": "emax_transpose_words"
+ },
+ {
+ "keys": ["ctrl+t"],
+ "command": "emax_transpose_chars"
+ },
+ {
+ "keys": ["ctrl+x", "ctrl+c"],
+ "command": "exit"
+ },
+ {
+ "keys": ["ctrl+x", "ctrl+f"],
+ "args": {"show_files": true, "overlay": "goto"},
+ "command": "show_overlay"
+ },
+ {
+ "keys": ["meta+f"],
+ "args": {"forward": true, "by": "words"},
+ "command": "move"
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "emax_beep"
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "hide_auto_complete",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "auto_complete_visible"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "emax_keyboard_quit",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "setting.emax_region_active"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "hide_panel",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "panel_visible"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "single_selection",
+ "context": [
+ {
+ "operator": "not_equal",
+ "operand": 1,
+ "key": "num_selections"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "clear_fields",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "has_next_field"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "clear_fields",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "has_prev_field"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "hide_panel",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "panel_visible"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "cancel",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "overlay_visible"
+ },
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "selection_empty",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "hide_auto_complete",
+ "context": [
+ {
+ "operator": "equal",
+ "operand": true,
+ "key": "auto_complete_visible"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+g"],
+ "command": "emax_keyboard_quit",
+ "context": [
+ {
+ "operator": "not_equal",
+ "operand": true,
+ "key": "selection_empty"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+a"],
+ "args": {"to": "bol"},
+ "command": "move_to"
+ },
+ {
+ "keys": ["ctrl+a"],
+ "args": {"to": "hardbol"},
+ "command": "move_to",
+ "context": [
+ {
+ "operator": "regex_match",
+ "operand": "^\\s$",
+ "match_all": true,
+ "key": "preceding_text"
+ }
+ ]
+ },
+ {
+ "keys": ["ctrl+e"],
+ "args": {"to": "hardeol"},
+ "command": "move_to"
+ },
+ {
+ "keys": ["ctrl+d"],
+ "command": "right_delete"
+ },
+ {
+ "keys": ["meta+d"],
+ "args": {
+ "forward": true
+ },
+ "command": "delete_word"
+ },
+ {
+ "keys": ["meta+backspace"],
+ "args": {"forward": false},
+ "command": "delete_word"
+ },
+ {
+ "keys": ["meta+c"],
+ "command": "title_case"
+ },
+ {
+ "keys": ["meta+u"],
+ "command": "upper_case"
+ },
+ {
+ "keys": ["meta+l"],
+ "command": "lower_case"
+ },
+ {
+ "keys": ["meta+ctrl+f"],
+ "args": {"forward": true},
+ "command": "emax_move_sexp"
+ },
+ {
+ "keys": ["meta+ctrl+b"],
+ "args": {"forward": false},
+ "command": "emax_move_sexp"
+ },
+ {
+ "keys": ["ctrl+shift+-"],
+ "command": "undo"
+ },
+ {
+ "keys": ["meta+ctrl+shift+-"],
+ "command": "redo"
+ },
+ {
+ "keys": ["ctrl+x", "ctrl+x"],
+ "command": "emax_exchange_point_and_mark"
+ },
+ {
+ "keys": ["ctrl+space"],
+ "command": "emax_set_mark"
+ },
+ {
+ "keys": ["meta+ctrl+shift+2"],
+ "command": "find_under_expand"
+ },
+ {
+ "keys": ["ctrl+y"],
+ "command": "emax_yank"
+ },
+ {
+ "keys": ["meta+y"],
+ "command": "emax_yank_pop"
+ },
+ {
+ "keys": ["ctrl+k"],
+ "command": "emax_kill_line"
+ },
+ {
+ "keys": ["ctrl+w"],
+ "command": "emax_kill_region"
+ },
+ {
+ "keys": ["meta+w"],
+ "command": "emax_kill_ring_save"
+ },
+ {
+ "keys": ["ctrl+x", "k"],
+ "command": "close"
+ },
+ {
+ "keys": ["ctrl+o"],
+ "command": "emax_open_line"
+ },
+ {
+ "keys": ["ctrl+l"],
+ "command": "show_at_center"
+ },
+ {
+ "keys": ["ctrl+x", "ctrl+s"],
+ "command": "save"
+ },
+ {
+ "keys": ["ctrl+x", "ctrl+w"],
+ "command": "prompt_save_as"
+ },
+ {
+ "keys": ["ctrl+x", "b"],
+ "args": {"show_files": true, "overlay": "goto"},
+ "command": "show_overlay"
+ },
+ {
+ "keys": ["meta+x"],
+ "args": {"overlay": "command_palette"},
+ "command": "show_overlay"
+ },
+ {
+ "keys": ["ctrl+s"],
+ "args": {"reverse": false, "panel": "incremental_find"},
+ "command": "show_panel"
+ },
+ {
+ "keys": ["ctrl+r"],
+ "args": {"reverse": true, "panel": "incremental_find"},
+ "command": "show_panel"
+ },
+ {
+ "keys": ["ctrl+s"],
+ "command": "emax_maybe_restore_incremental_search",
+ "args": {"reverse": false},
+ "context":
+ [
+ {"key": "panel", "operand": "incremental_find"},
+ {"key": "panel_has_focus"}
+ ]
+ },
+ {
+ "keys": ["ctrl+r"],
+ "command": "emax_maybe_restore_incremental_search",
+ "args": {"reverse": true},
+ "context":
+ [
+ {"key": "panel", "operand": "incremental_find"},
+ {"key": "panel_has_focus"}
+ ]
+ },
+ {
+ "keys": ["meta+shift+."],
+ "args": {"to": "eof"},
+ "command": "move_to"
+ },
+ {
+ "keys": ["meta+shift+,"],
+ "args": {"to": "bof"},
+ "command": "move_to"
+ },
+ {
+ "keys": ["meta+/"],
+ "command": "auto_complete"
+ },
+ {
+ "keys": ["meta+;"],
+ "command": "toggle_comment"
+ },
+ {
+ "keys": ["ctrl+v"],
+ "args": {"forward": true, "by": "pages"},
+ "command": "move"
+ },
+ {
+ "keys": ["ctrl+x", "o"],
+ "command": "emax_other_window"
+ },
+ {
+ "keys": ["meta+v"],
+ "args": {"forward": false, "by": "pages"},
+ "command": "move"
+ },
+ {
+ "keys": ["ctrl+x", "5", "0"],
+ "command": "close_window"
+ },
+ {
+ "keys": ["ctrl+x", "5", "2"],
+ "command": "new_window"
+ },
+ {
+ "keys": ["meta+."],
+ "args": {"text": "@", "overlay": "goto"},
+ "command": "show_overlay"
+ },
+ {
+ "keys": ["meta+shift+5"],
+ "args": {"panel": "replace"},
+ "command": "show_panel"
+ },
+ {
+ "keys": ["ctrl+f"],
+ "command": "move",
+ "args": {"forward": true, "by": "characters"}
+ },
+ {
+ "keys": ["ctrl+b"],
+ "command": "move",
+ "args": {"forward": false, "by": "characters"}
+ },
+ {
+ "keys": ["ctrl+n"],
+ "command": "move",
+ "args": {"forward": true, "by": "lines"}
+ },
+ {
+ "keys": ["ctrl+p"],
+ "command": "move",
+ "args": {"forward": false, "by": "lines"}
+ },
+ {
+ "keys": ["right"],
+ "command": "move",
+ "args": {"forward": true, "by": "characters"}
+ },
+ {
+ "keys": ["left"],
+ "command": "move",
+ "args": {"forward": false, "by": "characters"}
+ },
+ {
+ "keys": ["down"],
+ "command": "move",
+ "args": {"forward": true, "by": "lines"}
+ },
+ {
+ "keys": ["up"],
+ "command": "move",
+ "args": {"forward": false, "by": "lines"}
+ },
+ {
+ "keys": ["ctrl+x", "`"],
+ "command": "next_result"
+ },
+ {
+ "keys": ["meta+g", "n"],
+ "command": "next_result"
+ },
+ {
+ "keys": ["meta+g", "meta+n"],
+ "command": "next_result"
+ },
+ {
+ "keys": ["meta+g", "p"],
+ "command": "prev_result"
+ },
+ {
+ "keys": ["meta+g", "meta+p"],
+ "command": "prev_result"
+ },
+ {
+ "keys": ["meta+g", "meta+g"],
+ "command": "show_overlay",
+ "args": {"overlay": "goto", "text": ":"}
+ },
+ {
+ "keys": ["meta+g", "g"],
+ "command": "show_overlay",
+ "args": {"overlay": "goto", "text": ":"}
+ },
+ {
+ "keys": ["meta+r"],
+ "command": "build"
+ },
+ {
+ "keys": ["ctrl+x", "h"],
+ "command": "emax_mark_whole_buffer"
+ },
+ {
+ "keys": ["ctrl+c", "ctrl+c"],
+ "command": "emax_save_and_close"
+ },
+ {
+ "keys": ["ctrl+c", ">"],
+ "command": "indent"
+ },
+ {
+ "keys": ["ctrl+c", "<"],
+ "command": "unindent"
+ },
+ {
+ "keys": ["enter"],
+ "command": "emax_jump_to_hunk",
+ "context":
+ [
+ {
+ "key": "selector",
+ "operator": "equal",
+ "operand": "source.diff"
+ }
+ ]
+ },
+ {
+ "keys": ["enter"],
+ "command": "insert_snippet",
+ "args": {"contents": "\n\t$0\n"},
+ "context":
+ [
+ {
+ "key": "setting.auto_indent",
+ "operator": "equal",
+ "operand": true
+ },
+ {
+ "key": "selection_empty",
+ "operator": "equal",
+ "operand": true,
+ "match_all": true
+ },
+ {
+ "key": "preceding_text",
+ "operator": "regex_contains",
+ "operand": "\\{$",
+ "match_all": true
+ },
+ {
+ "key": "following_text",
+ "operator": "regex_contains",
+ "operand": "^\\}",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["enter"],
+ "command": "insert_snippet",
+ "args": {"contents": "\n\t$0\n"},
+ "context":
+ [
+ {
+ "key": "setting.auto_indent",
+ "operator": "equal",
+ "operand": true
+ },
+ {
+ "key": "selection_empty",
+ "operator": "equal",
+ "operand": true,
+ "match_all": true
+ },
+ {
+ "key": "preceding_text",
+ "operator": "regex_contains",
+ "operand": "\\($",
+ "match_all": true
+ },
+ {
+ "key": "following_text",
+ "operator": "regex_contains",
+ "operand": "^\\)",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["enter"],
+ "command": "insert_snippet",
+ "args": {"contents": "\n\t$0\n"},
+ "context":
+ [
+ {
+ "key": "setting.auto_indent",
+ "operator": "equal",
+ "operand": true
+ },
+ {
+ "key": "selection_empty",
+ "operator": "equal",
+ "operand": true,
+ "match_all": true
+ },
+ {
+ "key": "preceding_text",
+ "operator": "regex_contains",
+ "operand": "\\[$",
+ "match_all": true
+ },
+ {
+ "key": "following_text",
+ "operator": "regex_contains",
+ "operand": "^\\]",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["\""],
+ "command": "insert_snippet",
+ "args": {"contents": "\"\n$0\n\"\"\""},
+ "context":
+ [
+ {
+ "key": "selection_empty",
+ "operator": "equal",
+ "operand": true,
+ "match_all": true
+ },
+ {
+ "key": "preceding_text",
+ "operator": "regex_contains",
+ "operand": "\"\"$",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["'"],
+ "command": "insert_snippet",
+ "args": {"contents": "'\n$0\n'''"},
+ "context":
+ [
+ {
+ "key": "selection_empty",
+ "operator": "equal",
+ "operand": true,
+ "match_all": true
+ },
+ {
+ "key": "preceding_text",
+ "operator": "regex_contains",
+ "operand": "''$",
+ "match_all": true
+ }
+ ]
+ },
+ {
+ "keys": ["meta+n"],
+ "command": "new_file"
+ }
+]
diff --git a/emax_build_keymaps.py b/emax_build_keymaps.py
new file mode 100644
index 0000000..eda0e1f
--- /dev/null
+++ b/emax_build_keymaps.py
@@ -0,0 +1,96 @@
+#!/usr/bin/python
+# Copyright (C) 2012
+# See LICENSE.txt for details.
+"""
+Convert a template emacs JSON keymapping to one for each platform supported by
+sublime: OS X, Linux, and Windows.
+"""
+
+# Every "move" key binding needs a copy which does the "extend" variant with a
+# context that checks setting.transient_mode_on.
+
+# Every binding in the whole file needs to have a context key that checks
+# setting.emacs_on.
+
+import os
+
+__file__ = os.path.abspath(__file__)
+
+import json
+import copy
+
+def here(path):
+ return os.path.join(os.path.dirname(__file__), path)
+
+
+
+def meta(platform):
+ if platform == "OSX":
+ return "super"
+ else:
+ return "alt"
+
+
+
+def require_setting(mapping, setting):
+ mapping.setdefault("context", []).append(
+ dict(key=setting,
+ operator="equal",
+ operand=True))
+
+
+
+def add_arg(mapping, name, value):
+ mapping.setdefault("args", {})[name] = value
+
+
+
+def new_map(kmap, platform, conditional):
+ new = []
+ for binding in kmap:
+ new.append(binding)
+ binding['keys'] = [
+ k.replace(
+ "meta+", meta(platform) + "+"
+ ) for k in binding['keys']
+ ]
+ if conditional:
+ require_setting(binding, "emax_enabled")
+ if binding.get("command") in ["move", "move_to", "emax_move_sexp"]:
+ clone = copy.deepcopy(binding)
+ require_setting(clone, "setting.emax_region_active")
+ add_arg(clone, "extend", True)
+ new.append(clone)
+ return new
+
+
+
+def for_platform(platform):
+ return here("Default ({0}).sublime-keymap".format(platform))
+
+
+
+def all_maps(conditional=False):
+ template = json.load(open(here("base-keymap.json")))
+ for platform in "OSX", "Linux", "Windows":
+ cloned = copy.deepcopy(template)
+ doubleclone = new_map(cloned, platform, conditional)
+ doubleclone.append({
+ "keys": [
+ meta(platform) + "+ctrl+shift+'",
+ ],
+ "command": "toggle_emax"
+ })
+ fn = for_platform(platform)
+ tfn = fn + ".new" # atomic but not concurrent
+ with open(tfn, "wb") as f:
+ f.write(json.dumps(doubleclone, indent=2))
+ f.write("\n")
+ os.rename(tfn, fn)
+
+
+
+if __name__ == '__main__':
+ # Generating the keymaps from within the editor is more reliable, as that
+ # will honor the globalness setting.
+ all_maps()
diff --git a/emax_commands.py b/emax_commands.py
new file mode 100644
index 0000000..a7002e1
--- /dev/null
+++ b/emax_commands.py
@@ -0,0 +1,766 @@
+# Copyright (C) 2012
+# See LICENSE.txt for details.
+
+"""
+Definitions of all commands associated with E-Max.
+
+See C{README.rst} for details.
+"""
+
+from __future__ import unicode_literals
+
+import os
+
+__file__ = os.path.abspath(__file__)
+
+from cStringIO import StringIO
+
+import kill_ring
+import emax_build_keymaps
+
+from sublime_plugin import TextCommand, EventListener, WindowCommand
+from sublime import (
+ Region, OP_EQUAL, OP_NOT_EQUAL, set_timeout, set_clipboard, get_clipboard,
+ ENCODED_POSITION, HIDDEN, PERSISTENT, #status_message
+)
+
+
+REGION_VAR = 'emax_region_active'
+ENABLED_VAR = 'emax_enabled'
+
+
+
+"""
+Is emax currently enabled?
+"""
+
+EMAX_ENABLED = True
+
+
+"""
+Update the keymaps if this is the first time we've run.
+"""
+
+emax_build_keymaps.all_maps()
+
+
+def update_status(view):
+ if EMAX_ENABLED:
+ status = "[E-Max: ON (C-M-S-' to deactivate)]"
+ else:
+ status = "[E-Max: OFF (C-M-S-' to activate)]"
+ view.set_status(' emax', status)
+
+
+
+class ToggleEmax(TextCommand):
+ """
+ Not everybody likes Emacs.
+
+ This command (bound to M-C-S-' by default, which is unused in Emacs) will
+ enable and disable Emax key-bindings on the fly, allowing you to
+ pair-program with normal human beings who are not familiar with Emacs
+ key-bindings.
+ """
+
+ def run(self, edit):
+ global EMAX_ENABLED
+ EMAX_ENABLED = not EMAX_ENABLED
+ # need to keep the binding to turn it back on!
+ emax_build_keymaps.all_maps(not EMAX_ENABLED)
+ update_status(self.view)
+
+
+
+lastText = {
+ # Mapping of view ID to a string. Really only for incremental search, but
+ # may collect other small, temporary views like the input buffer if the user
+ # invokes incsearch from there, as there doesn't seem to be an API for
+ # determining which views are "real" and which views are like that.
+}
+
+
+
+def smellsLikeIncSearch(view):
+ """
+ Heuristic test for a buffer that should look more or less like the
+ incremental search buffer. Not reliable, because other views will also
+ meet these criteria.
+ """
+ return (view.window() is not None and
+ view.id() not in [x.id() for x in view.window().views()] and
+ view.size() < 200)
+
+
+
+class EmaxManager(EventListener):
+ """
+ This is mostly a workaround for the fact that Sublime does not appear to
+ have a mechanism for window- or application-scope settings, but I really
+ want to toggle an 'emax_enabled' globally.
+
+ (Also, it observes the state of the incremental-search window, so that
+ EmaxMaybeRestoreIncrementalSearch can work.)
+ """
+
+ def on_activated(self, view):
+ update_status(view)
+
+
+ def on_deactivated(self, view):
+ if smellsLikeIncSearch(view):
+ if view.size() > 0:
+ lastText[view.id()] = view.substr(Region(0, view.size()))
+
+
+ def on_query_context(self, view, key, operator, operand, match_all):
+ if key == ENABLED_VAR:
+ result = None
+ if operator == OP_EQUAL:
+ result = operand == EMAX_ENABLED
+ if operator == OP_NOT_EQUAL:
+ result = operand != EMAX_ENABLED
+ return result
+
+
+
+class EmaxBeep(TextCommand):
+ """
+ Almost no-op command that just displays a quick message in the status area;
+ bound to commands that have no effect in order to override default
+ keybindings to avoid confusing one's fingers.
+ """
+
+ def run(self, edit):
+ v = self.view
+ v.set_status(" beep", "*BEEP*")
+ def unset():
+ v.erase_status(" beep")
+ set_timeout(unset, 2000)
+
+
+
+# import pydoc
+
+class EmaxMaybeRestoreIncrementalSearch(TextCommand):
+ """
+ Maybe restore the incremental search area, if it's focused and we have a
+ memory of a previous value for it.
+ """
+
+ def run(self, edit, reverse=False):
+ if self.view.size() == 0:
+ if self.view.id() in lastText:
+ self.view.insert(edit, 0, lastText[self.view.id()])
+ else:
+ # Note: command must be run on the *window*: running it on the view
+ # runs it on the incsearch view, which apparently does nothing.
+ self.view.window().run_command(
+ "show_panel",
+ {
+ "reverse": reverse,
+ "panel": "incremental_find",
+ }
+ )
+
+
+
+class EmaxHelper(TextCommand):
+ """
+ Helper command with useful methods.
+ """
+
+ def each_point_do(self, thunk):
+ """
+ Do something for each selected region, pretending like it's an emacs-
+ style 'point'.
+ """
+ for area in self.view.sel():
+ thunk(area.end())
+
+
+ def updateScroll(self, forward=True):
+ if forward:
+ func = max
+ else:
+ func = min
+ pt = func(x.b for x in self.view.sel())
+ self.view.show(pt)
+
+
+ def region_active_p(self):
+ return self.view.settings().get(REGION_VAR)
+
+
+ def set_mark_command(self):
+ """
+ Set and activate the mark, like (set-mark-command), usually bound to
+ control-space.
+ """
+ # Use the built-in mark ring-ish thing. This doesn't support an
+ # equivalent to (yank-pop) so I need to move to something else
+ # eventually...
+ self.view.run_command('set_mark')
+ self.view.settings().set(REGION_VAR, True)
+
+
+ def deactivate_mark(self):
+ """
+ Deactivate the mark, like (deactivate-mark) or (keyboard-quit), usually
+ bound to C-g, will do. Note that the context-sensitive behavior of C-g
+ is not implemented here, but rather as a function of different C-g
+ contexts in the .sublime-keymap files.
+ """
+ self.view.settings().set(REGION_VAR, False)
+ self.view.run_command('clear_bookmarks', dict(name="mark"))
+ # hmm. self.cmd.clear_bookmarks(name="mark") instead?
+ regions = [s.b for s in self.view.sel()]
+ self.view.sel().clear()
+ for r in regions:
+ self.view.sel().add(Region(r))
+
+
+
+class EmaxOpenLineCommand(EmaxHelper):
+ """
+ Mimic 'open-line', also known as 'C-o'
+ """
+
+ def run(self, edit):
+ self.each_point_do(lambda point:
+ self.view.insert(edit, point, "\n"))
+ self.view.run_command("move",
+ {"by": "characters", "forward": False})
+
+
+
+class EmaxKillRingSave(EmaxHelper):
+ """
+ Mimic 'kill-ring-save', also known as 'M-w'.
+ """
+
+ def run(self, edit):
+ self.view.run_command('add_to_kill_ring', {"forward": False})
+ self.deactivate_mark()
+ set_clipboard(kill_ring.kill_ring.top())
+
+
+
+class EmaxKillRegionCommand(EmaxHelper):
+ """
+ Mimic 'kill-region', also known as 'C-w'.
+ """
+
+ def run(self, edit):
+ self.view.run_command('delete_to_mark')
+ set_clipboard(kill_ring.kill_ring.top())
+ self.deactivate_mark()
+ set_clipboard(kill_ring.kill_ring.top())
+
+
+
+class EmaxKillLine(EmaxHelper):
+ """
+ Mimic 'kill-line' also known as 'C-k'.
+ """
+
+ def run(self, edit):
+ self.deactivate_mark()
+ self.view.run_command(
+ "run_macro_file",
+ {"file": "Packages/Default/Delete to Hard EOL.sublime-macro"}
+ )
+ set_clipboard(kill_ring.kill_ring.top())
+
+
+
+class EmaxYank(EmaxHelper):
+ """
+ Mimic 'yank' also known as 'C-y'.
+ """
+ def run(self, edit):
+ clip = get_clipboard()
+ if kill_ring.kill_ring.top() != clip:
+ kill_ring.kill_ring.seal()
+ kill_ring.kill_ring.push(clip)
+ self.view.run_command("yank")
+
+
+
+class EmaxYankPop(EmaxHelper):
+ """
+ Mimic 'yank-pop' also known as 'M-y'.
+ """
+
+ def run(self, edit):
+ if self.view.command_history(0, True)[0] not in ('emax_yank',
+ 'emax_yank_pop'):
+ print "Previous command was not a yank."
+ return
+ if len(self.view.sel()) != 1:
+ print "Only works with one selection."
+ return
+ pt = self.view.sel()[0].a
+ if pt != self.view.sel()[0].b:
+ print "Only works with an empty selection."
+ return
+ kr = kill_ring.kill_ring
+ if kr.top() is not None:
+ # only works for a single-point selection, but better than nothing.
+ self.view.erase(edit, Region(pt, pt - len(kr.top())))
+ kr.head -= 1
+ kr.head %= kr.limit
+ if kr.top() is not None:
+ self.view.run_command("yank")
+
+
+
+class EmaxKeyboardQuit(EmaxHelper):
+ """
+ Mimic 'keyboard-quit', also known as 'C-g'.
+
+ Right now this just runs deactivate_mark, and is not context-sensitive at
+ all. See the docstring for deactivate_mark for more information about its
+ limitations.
+ """
+
+ def run(self, edit):
+ self.deactivate_mark()
+
+
+
+class EmaxSetMark(EmaxHelper):
+ """
+ Just run 'set-mark-command'; like 'C-SPC'.
+ """
+
+ def run(self, edit):
+ self.set_mark_command()
+
+
+
+class EmaxTransposeChars(EmaxHelper):
+ """
+ Transpose the characters surrounding the cursor.
+ """
+
+ def run(self, edit):
+ """
+ Completely re-implemented since sublime won't let you transpose
+ characters near a word boundary.
+ """
+ replace = []
+ for region in self.view.sel():
+ pt = region.b
+ ln = self.view.line(pt)
+ if pt == ln.b:
+ pt -= 1
+ before = Region(pt, max(0, pt - 1))
+ after = Region(pt, min(self.view.size(), pt + 1))
+ if before != after:
+ replace.append([before, after])
+ for before, after in reversed(sorted(replace)):
+ aftert = self.view.substr(after)
+ beforet = self.view.substr(before)
+ self.view.replace(edit, after, beforet)
+ self.view.replace(edit, before, aftert)
+
+
+
+class EmaxTransposeWords(EmaxHelper):
+ """
+ Transpose the words surrounding the cursor.
+ """
+
+ def run(self, edit):
+ """
+ Sublime's word-transposition is close enough; you just have to make the
+ cursor line up with its expectation.
+ """
+ newsels = []
+ for region in self.view.sel():
+ word = self.view.word(region.b)
+ if region.b == word.a:
+ newsels.append(word.a)
+ else:
+ newsels.append(word.b)
+ self.view.sel().clear()
+ for i in newsels:
+ self.view.sel().add(Region(i, i))
+ self.view.run_command("transpose")
+ self.view.run_command("move", {"forward": True, "by": "words"})
+ self.view.run_command("move", {"forward": True, "by": "words"})
+
+
+
+class EmaxRebuildKeymaps(EmaxHelper):
+ """
+ Re-build the keymaps from within the editor.
+ """
+
+ def run(self, edit):
+ emax_build_keymaps.all_maps(not EMAX_ENABLED)
+
+
+
+class EmaxMarkWholeBuffer(EmaxHelper):
+ """
+ Like select_all but also set the mark.
+ """
+
+ def run(self, edit):
+ self.view.run_command("move_to", {"to": "bof"})
+ self.set_mark_command()
+ self.view.run_command("move_to", {"to": "eof", "extend": True})
+
+
+
+class CharacterCursor(object):
+ """
+ Scan through a buffer, one character at a time.
+ """
+
+ def __init__(self, backwards, index, view):
+ super(CharacterCursor, self).__init__()
+ self.backwards = backwards
+ self.index = index
+ self.view = view
+
+
+ def __iter__(self):
+ return self
+
+
+ def peek(self):
+ """
+ Take a peek at the next character, without advancing the cursor.
+ """
+ if self.index < 0 or self.index > self.view.size():
+ return ''
+ return self.view.substr(self.index)
+
+
+ def next(self):
+ it = self.peek()
+ if self.backwards:
+ self.index -= 1
+ else:
+ self.index += 1
+ if not it:
+ raise StopIteration()
+ return it
+
+
+
+class LineCursor(object):
+ """
+ Scan through a buffer, one line at a time.
+ """
+ def __init__(self, backwards, index, view):
+ self.backwards = backwards
+ self.view = view
+ self.region = view.line(index)
+ self.stopped = False
+
+
+ def clone(self, reverse=False):
+ return self.__class__(self.backwards ^ reverse, self.region.a,
+ self.view)
+
+
+ def __iter__(self):
+ return self
+
+
+ def next(self):
+ if self.stopped:
+ raise StopIteration()
+ result = self.view.substr(self.region)
+ if self.backwards:
+ point = self.region.a - 1
+ else:
+ point = self.region.b + 1
+ oldregion = self.region
+ self.region = self.view.line(point)
+ if (oldregion == self.region or
+ self.region.b >= self.view.size() or
+ self.region.a < 0):
+ self.stopped = True
+ return result
+
+
+
+matches = {
+ "[": "]",
+ "'": "'",
+ '"': '"',
+ "{": "}",
+ "(": ")",
+}
+
+
+
+rmatches = {}
+def _andReverse():
+ for m in matches:
+ rmatches[matches[m]] = m
+_andReverse()
+import string
+whitespace = string.whitespace.decode("latin1")
+
+
+
+def scanOneSexp(scanner, matcher, rmatcher):
+ stack = []
+ ever = False
+ inword = False
+ backslash = False
+ def isquote(x):
+ return x in matcher and x in rmatcher
+ def inquotes():
+ return isquote(stack[-1])
+ for c in scanner:
+ # print 'SCANNED', repr(c)
+ # print 'STACK:', stack
+ if not stack:
+ if ever:
+ # print 'STACK EMPTY AND EVER STACK, DONE'
+ return True
+ if inword:
+ if c in whitespace or c in matcher or c in rmatcher:
+ # one word: no parens on stack: done.
+ # print 'WORD END'
+ return True
+ else:
+ # print 'WORD CONT'
+ continue
+ else:
+ if c in matcher:
+ # print "STACK PUSH"
+ ever = True
+ stack.append(c)
+ continue
+ elif c in rmatcher:
+ # close brace/bracket/paren at top scope; no sexp to jump
+ # over.
+ # print "CLOSE_AT_TOP"
+ return False
+ if c not in whitespace:
+ # print "WORD START"
+ inword = True
+ else:
+ if inquotes() and not scanner.backwards:
+ if backslash:
+ backslash = False
+ continue
+ elif c == '\\':
+ backslash = True
+ continue
+ if c in rmatcher:
+ if rmatcher[c] == stack[-1]:
+ # print "PAREN MATCH", # stack.pop()
+ if ( inquotes() and scanner.backwards
+ and scanner.peek() == '\\' ):
+ continue
+ stack.pop()
+ continue
+ elif not inquotes() and not isquote(c):
+ # mismatched parens
+ # print "MISMATCH"
+ return False
+ if c in matcher:
+ if not inquotes():
+ # print 'STACK PUSH'
+ stack.append(c)
+ else:
+ if ever and not stack:
+ return True
+ # print "SUDDEN EXIT"
+ return False
+ # print 'UNREACHABLE'
+
+
+
+class EmaxMoveSexp(EmaxHelper):
+ """
+ Move the cursor forward or backward by one S-expression.
+ """
+
+ def run(self, edit, forward=True, extend=False):
+ ns = []
+ if forward:
+ matcher = matches
+ rmatcher = rmatches
+ backward = False
+ adjust = -1
+ else:
+ matcher = rmatches
+ rmatcher = matches
+ backward = True
+ adjust = 2
+ for s in self.view.sel():
+ cursor = CharacterCursor(backward, s.b - backward, self.view)
+ if scanOneSexp(cursor, matcher, rmatcher):
+ if extend:
+ a = s.a
+ else:
+ a = cursor.index + adjust
+ ns.append(Region(a, cursor.index + adjust))
+ else:
+ ns.append(s)
+ self.view.sel().clear()
+ for r in ns:
+ self.view.sel().add(r)
+ self.updateScroll(forward)
+
+
+
+class EmaxSaveAndClose(EmaxHelper):
+ """
+ Save and close in one command.
+
+ This is to emulate the log-edit-done style commands that come along with
+ dvc or vc or psvn; save and close the buffer so that an external process
+ can get to it. Really, this should be bound only in temporary buffers
+ started by subl.
+ """
+
+ def run(self, edit):
+ self.view.run_command("save")
+ self.view.window().run_command("close")
+
+
+
+class EmaxJumpToHunk(EmaxHelper):
+ """
+ Jump to a hunk of a diff.
+ """
+
+ def run(self, edit):
+ """
+ Based on the current position of the cursor, jump to the appropriate
+ file/line combination.
+ """
+ offt = -1
+ havehunk = False
+ for line in LineCursor(True, self.view.sel()[0].b, self.view):
+ if line.startswith("@@"):
+ if havehunk:
+ continue
+ havehunk = True
+ atat, minus, plus, atat = line.split()
+ baseline = int(plus[1:].split(",")[0])
+ realline = baseline + offt
+ elif line.startswith("+++"):
+ fname = line[4:].split("\t")[0]
+ # definitely set by now, but ugh
+ self.view.window().open_file(
+ # This should probably try multiple locations, looking for
+ # files which actually exist, since a shell command like
+ # "foo | subl" always puts the output into a buffer in /tmp,
+ # and we can no longer tell which folder it applies to.
+ os.path.join(
+ self.view.window().folders()[0], fname
+ ) + ":" + str(realline),
+ ENCODED_POSITION
+ )
+ break
+ elif not havehunk and line.startswith(" ") or line.startswith("+"):
+ offt += 1
+ elif line.startswith("-"):
+ pass
+ else:
+ print "No hunk found."
+
+
+
+class EmaxExchangePointAndMark(EmaxHelper):
+ """
+ Replication of 'exchange-point-and-mark', i.e. C-x C-x.
+ """
+
+ def run(self, edit):
+ """
+ Exchange the point and the mark.
+ """
+ marks = self.view.get_regions("mark")
+ newsel = []
+ newmark = []
+ for s in self.view.sel():
+ for mark in marks:
+ if mark.a == mark.b == s.a:
+ newsel.append(Region(s.b, s.a))
+ newmark.append(Region(s.b, s.b))
+ if newsel:
+ self.view.erase_regions("mark")
+ self.view.add_regions(
+ "mark", newmark, "mark", "dot", HIDDEN | PERSISTENT
+ )
+ self.view.sel().clear()
+ for reg in newsel:
+ self.view.sel().add(reg)
+ self.updateScroll()
+
+
+
+class EmaxFillParagraph(TextCommand):
+ """
+ Similar to 'fill-paragraph', i.e. M-q.
+
+ Like fill-paragraph, this has some mode-specific logic. Currently it only
+ has some Python-aware stuff, for working on projects like U{Twisted
+ } which use Epydoc formatted docstrings.
+ """
+
+ def run(self, edit):
+ """
+ Fill a paragraph around the first point.
+ """
+ scopes = self.view.scope_name(self.view.sel()[0].a).split()
+
+ if ( 'string.quoted.double.block.python' in scopes or
+ 'string.quoted.single.block.python' in scopes ):
+ from epywrap import wrapPythonDocstring
+ orig = self.view.sel()[0]
+ scope = self.view.extract_scope(orig.a)
+ startline = self.view.substr(self.view.line(scope.a))
+ indentation = startline[:len(startline) - len(startline.lstrip())]
+ torepl = Region(scope.a + 3, scope.b - 3)
+ origPoint = orig.a - torepl.a
+ io = StringIO()
+ origText = self.view.substr(torepl)
+ newPoint = wrapPythonDocstring(
+ origText, io, indentation, point=origPoint
+ ) + torepl.a
+ val = io.getvalue()
+ if val != origText:
+ self.view.replace(edit, torepl, val)
+ # try to put the selection back at least vaguely where it was.
+ self.view.sel().clear()
+ self.view.sel().add(Region(newPoint, newPoint))
+ self.view.show(newPoint)
+ else:
+ # this should _really_ be accomplished via a mapping context, but I
+ # cannot figure out for the life of me how selector matching in
+ # .sublime-keymap files is supposed to go.
+ self.view.run_command("wrap_lines")
+
+
+
+class EmaxOtherWindowCommand(WindowCommand):
+ """
+ Similar to 'other-window', i.e. C-x C-o.
+
+ Move the focus to the next sublime group, because this is the closest
+ analogue to the next Emacs 'window'.
+ """
+ def run(self):
+ """
+ Focus the next group.
+ """
+ self.window.focus_group(
+ (self.window.active_group() + 1) % self.window.num_groups()
+ )
+
+
+
diff --git a/epywrap.py b/epywrap.py
new file mode 100644
index 0000000..de56588
--- /dev/null
+++ b/epywrap.py
@@ -0,0 +1,400 @@
+# Copyright (C) 2012
+# See LICENSE.txt for details.
+
+"""
+Epytext (and general Python docstring) wrapper
+==============================================
+
+Utility for wrapping docstrings in Python; specifically, docstrings in U{Epytext
+} format, or those that are
+close enough.
+
+The wrapping herein generally aheres to all the conventions set forth by the
+Twisted project U{http://twistedmatrix.com/}.
+
+Currently (obviously) the only supported editor is U{Sublime Text 2
+} but a sufficiently enterprising individual could
+either use this file as a script (no dependencies!) by piping the contents of
+the docstring to it, or call L{wrapPythonDocstring} and preserve point position.
+"""
+
+from __future__ import unicode_literals
+
+import re
+from uuid import uuid4
+
+__all__ = [
+ "wrapPythonDocstring"
+]
+
+
+
+def isUnderline(expr):
+ return bool(re.match("[=]+$", expr) or re.match("[-]+$", expr))
+
+
+
+class RegularParagraph(object):
+ otherIndent = ""
+
+ def __init__(self, pointTracker, fixedIndent="", hangIndent="",
+ followIndent=""):
+ self.words = []
+ self.fixedIndent = fixedIndent
+ self.hangIndent = hangIndent
+ self.followIndent = followIndent
+ self.more = None
+ self.pointTracker = pointTracker
+ self._unwrappedLines = 0
+ self._headingType = None
+ self._headingPoints = []
+
+
+ def matchesTag(self, other):
+ return False
+
+
+ def __nonzero__(self):
+ return bool(self.words)
+
+
+ def all(self):
+ while self is not None:
+ #print self.__class__.__name__
+ if self:
+ yield self
+ self = self.more
+
+
+ def setIsHeading(self, headingType):
+ self._headingType = headingType
+
+
+ def isHeading(self):
+ return bool(self._headingType)
+
+
+ def add(self, line):
+ clean = self.pointTracker.peek(line)
+ stripped = clean.strip()
+
+ if stripped:
+ self._unwrappedLines += 1
+ active = self
+ firstword = list(self.pointTracker.filterWords(line.split()))[0]
+ if stripped.startswith("@"):
+ fp = FieldParagraph(pointTracker=self.pointTracker)
+ fp.words.extend(line.split())
+ active = self.more = fp
+ elif isUnderline(stripped) and self._unwrappedLines == 2:
+ # This paragraph is actually a section heading.
+ active.setIsHeading(stripped[0])
+ self._headingPoints = self.pointTracker.extractPoints(line)
+ # FIXME: should respect leading indentation.
+ active = self.more = self.nextRegular()
+ elif (firstword == '-' or
+ (firstword.endswith(".") and firstword[:-1].isdigit())):
+ # Aesthetically I prefer a 2-space indent here, but the
+ # convention in the codebase seems to be 4 spaces.
+ hangIndent = self.pointTracker.lengthOf(firstword) + 1
+ fp = RegularParagraph(
+ pointTracker=self.pointTracker, fixedIndent=" ",
+ hangIndent=" " * hangIndent, followIndent=self.followIndent
+ )
+ fp.words.extend(line.split())
+ active = self.more = fp
+ else:
+ self.words.extend(line.split())
+ if stripped.endswith("::"):
+ active.more = PreFormattedParagraph(
+ pointTracker=self.pointTracker,
+ fixedIndent=(active.fixedIndent + active.hangIndent +
+ active.otherIndent),
+ indentBegins=len(clean) - len(clean.lstrip())
+ )
+ active = active.more
+ return active
+ else:
+ rawstrip = line.strip()
+ if rawstrip:
+ self.words.append(rawstrip)
+ if len(list(self.pointTracker.filterWords(self.words))):
+ return self.nextRegular()
+ return self
+
+
+ def wrap(self, output, indentation, width):
+ if not self.words:
+ return
+ thisLine = self.firstIndent(indentation)
+ first = True
+ prevWord = ''
+ for word in self.words:
+ if not self.pointTracker.isWord(word):
+ thisLine += word
+ continue
+ if ((prevWord.endswith(".") or prevWord.endswith("?") or
+ prevWord.endswith("!")) and not prevWord[:-1].isdigit()):
+ words = prevWord.split(".")[:-1]
+ if ( len(words) > 1 and
+ [self.pointTracker.lengthOf(x) for x in words] ==
+ [1] * len(words) ):
+ # acronym
+ spaces = 1
+ else:
+ spaces = 2
+ else:
+ spaces = 1
+ prevWord = word
+ if ( self.pointTracker.lengthOf(thisLine) +
+ self.pointTracker.lengthOf(word) + spaces <= width ):
+ if first:
+ first = not first
+ else:
+ thisLine += (" " * spaces)
+ thisLine += word
+ else:
+ output.write(self.pointTracker.scan(thisLine, output.tell()))
+ output.write("\n")
+ thisLine = self.restIndent(indentation) + word
+ output.write(self.pointTracker.scan(thisLine, output.tell()))
+ output.write("\n")
+ if self.isHeading():
+ indentText = self.firstIndent(indentation)
+ lineSize = self.pointTracker.lengthOf(thisLine) - len(indentText)
+ output.write(self.pointTracker.scan(
+ indentText + ''.join(self._headingPoints) +
+ (self._headingType * lineSize), output.tell()
+ ))
+ output.write("\n")
+
+
+ def firstIndent(self, indentation):
+ return indentation + self.fixedIndent
+
+
+ def restIndent(self, indentation):
+ return (indentation + self.fixedIndent + self.hangIndent +
+ self.otherIndent)
+
+
+ def nextRegular(self):
+ self.more = RegularParagraph(pointTracker=self.pointTracker,
+ fixedIndent=self.nextIndent(),
+ followIndent=self.nextIndent())
+ return self.more
+
+
+ def nextIndent(self):
+ return self.followIndent
+
+
+
+class FieldParagraph(RegularParagraph):
+
+ otherIndent = " "
+
+ def nextIndent(self):
+ return " "
+
+
+ def matchesTag(self, other):
+ if isinstance(other, FieldParagraph):
+ myWords = list(self.pointTracker.filterWords(self.words))
+ theirWords = list(self.pointTracker.filterWords(other.words))
+ if ( set([myWords[0], theirWords[0]]) ==
+ set(["@return:", "@rtype:"]) ):
+ # matching @return and @rtype fields.
+ return True
+ elif len(myWords) > 1 and len(theirWords) > 1:
+ # matching @param and @type fields.
+ return myWords[1] == theirWords[1]
+ return False
+ else:
+ return False
+
+
+
+class PreFormattedParagraph(object):
+
+ def __init__(self, pointTracker, fixedIndent, indentBegins):
+ self.lines = []
+ self.indentBegins = indentBegins
+ self.fixedIndent = fixedIndent
+ self.more = None
+ self.pointTracker = pointTracker
+
+
+ def matchesTag(self, other):
+ return False
+
+
+ def add(self, line):
+ actualLine = self.pointTracker.peek(line)
+
+ if actualLine.strip():
+ if len(actualLine) - len(actualLine.lstrip()) <= self.indentBegins:
+ next = self.more = RegularParagraph(
+ pointTracker=self.pointTracker,
+ fixedIndent=self.fixedIndent
+ )
+ return next.add(line)
+ self.lines.append(line.rstrip())
+ else:
+ self.lines.append(line.strip())
+ return self
+
+
+ def fixIndentation(self):
+ while self.lines and not self.lines[0].strip():
+ self.lines.pop(0)
+ while self.lines and not self.lines[-1].strip():
+ self.lines.pop()
+ if not self.lines:
+ return
+ cleanLines = map(self.pointTracker.peek, self.lines)
+ commonLeadingIndent = min([len(x) - len(x.lstrip()) for x in cleanLines
+ if x.strip()])
+ newLines = []
+ for actualLine, line in zip(cleanLines, self.lines):
+ if actualLine != line and line[:commonLeadingIndent].strip():
+ # There's a marker, and it's in the leading whitespace.
+ # Explicitly reposition the marker at the beginning of the fixed
+ # indentation.
+ line = (self.pointTracker.marker +
+ actualLine[commonLeadingIndent:])
+ else:
+ line = line.rstrip()[commonLeadingIndent:]
+ newLines.append(line)
+ self.lines = newLines
+
+
+ def wrap(self, output, indentation, width):
+ # OK, now we know about all the lines we're going to know about.
+ self.fixIndentation()
+ for line in self.lines:
+ if self.pointTracker.peek(line):
+ output.write(indentation + " " + self.fixedIndent)
+ output.write(self.pointTracker.scan(line, output.tell()))
+ output.write("\n")
+
+
+
+class PointTracker(object):
+ """
+ Object for keeping track of where the insertion points are.
+ """
+
+ def __init__(self, point):
+ self.point = point
+ self.marker = "{" + unicode(uuid4()) + "}"
+ self.outPoints = []
+
+
+ def annotate(self, text):
+ """
+ Add point references to a block of text.
+ """
+ return text[:self.point] + self.marker + text[self.point:]
+
+
+ def filterWords(self, words):
+ for word in words:
+ if self.isWord(word):
+ yield self.peek(word)
+
+
+ def isWord(self, text):
+ """
+ Is the given word actually a word, or just an artifact of the
+ point-tracking process? If it's just the point marker by itself, then
+ no, it isn't, and don't insert additional whitespace after it.
+ """
+ return not (text == self.marker)
+
+
+ def lengthOf(self, word):
+ """
+ How long would this word be if it didn't have any point-markers in it?
+ """
+ return len(self.peek(word))
+
+
+ def peek(self, word):
+ """
+ What would this word look like if it didn't have any point-markers in
+ it?
+ """
+ return word.replace(self.marker, "")
+
+
+ def extractPoints(self, text):
+ """
+ Return a C{list} of all point markers contained in the text.
+ """
+ if self.marker in text:
+ return [self.marker]
+ return []
+
+
+ def scan(self, text, offset):
+ """
+ Scan some text for point markers, remember them, and remove them.
+ """
+ idx = text.find(self.marker)
+ if idx == -1:
+ return text
+ self.outPoints.append(idx + offset)
+ return self.peek(text)
+
+
+
+def wrapPythonDocstring(docstring, output, indentation=" ",
+ width=79, point=0):
+ """
+ Wrap a given Python docstring.
+
+ @param docstring: the docstring itself (just the stuff between the quotes).
+ @type docstring: unicode
+
+ @param output: The unicode output file to write the wrapped docstring to.
+ @type output: L{file}-like (C{write} takes unicode.)
+
+ @param indentation: a string (consisting only of spaces) indicating the
+ amount of space to shift by. Don't adjust this. It's always 4 spaces.
+ PEP8 says so.
+ @type indentation: L{unicode}
+
+ @param width: The maximum number of characters allowed in a wrapped line.
+ @type width: L{int}
+
+ @param point: The location of the cursor in the text, as an offset from the
+ beginning of the docstring. If this function is being used from within
+ a graphical editor, this parameter can be used (in addition to the
+ return value of this function) to reposition the cursor at the relative
+ position which the user will expect.
+
+ @return: The new location of the cursor.
+ """
+ # TODO: multiple points; usable, for example, for start and end of a
+ # currently active selection.
+ pt = PointTracker(point)
+ start = paragraph = RegularParagraph(pt)
+ docstring = pt.annotate(docstring)
+ for line in docstring.split("\n"):
+ paragraph = paragraph.add(line)
+ prevp = None
+ for paragraph in start.all():
+ if not paragraph.matchesTag(prevp):
+ output.write("\n")
+ prevp = paragraph
+ paragraph.wrap(output, indentation, width)
+ output.write(indentation)
+ return pt.outPoints[0]
+
+
+
+if __name__ == '__main__':
+ import sys
+ wrapPythonDocstring(sys.stdin.read(), sys.stdout)
+