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) +