├── .gitignore ├── .replit ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs └── optional-dependencies.md ├── release.sh ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── suplemon.py ├── suplemon ├── __init__.py ├── __main__.py ├── cli.py ├── config.py ├── config │ ├── defaults.json │ └── keymap.json ├── cursor.py ├── editor.py ├── file.py ├── help.py ├── helpers.py ├── hex2xterm.py ├── key_mappings.py ├── lexer.py ├── line.py ├── linelight │ ├── __init__.py │ ├── color_map.py │ ├── css.py │ ├── diff.py │ ├── html.py │ ├── js.py │ ├── json.py │ ├── lua.py │ ├── md.py │ ├── php.py │ └── py.py ├── logger.py ├── main.py ├── module_loader.py ├── modules │ ├── application_state.py │ ├── autocomplete.py │ ├── autodocstring.py │ ├── battery.py │ ├── bulk_delete.py │ ├── clock.py │ ├── comment.py │ ├── config.py │ ├── crypt.py │ ├── date.py │ ├── diff.py │ ├── eval.py │ ├── hostname.py │ ├── keymap.py │ ├── linter.py │ ├── lower.py │ ├── lstrip.py │ ├── paste.py │ ├── reload.py │ ├── replace_all.py │ ├── reverse.py │ ├── rstrip.py │ ├── save.py │ ├── save_all.py │ ├── sort_lines.py │ ├── strip.py │ ├── system_clipboard.py │ ├── tabstospaces.py │ ├── toggle_whitespace.py │ └── upper.py ├── prompt.py ├── suplemon_module.py ├── themes.py ├── themes │ ├── 8colors.tmTheme │ └── monokai.tmTheme ├── ui.py └── viewer.py └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */*.pyc 3 | dist/ 4 | build/ 5 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | language = "python3" 2 | run = "python suplemon.py" 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | notifications: 3 | irc: 4 | - "irc.freenode.net#suplemon" 5 | python: 6 | - "2.7" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | - "3.7" 11 | 12 | # command to install dependencies 13 | install: 14 | - "pip install -r requirements-dev.txt" 15 | 16 | # command to run tests 17 | script: 18 | - "./test.sh" 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | 5 | ## [v0.2.1](https://github.com/richrd/suplemon/tree/0.2.1) (2019-08-29) compared to previous master branch. 6 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.2.0...0.2.1) 7 | 8 | **Fixed bugs:** 9 | 10 | - Fix a bug introduced by 0.2.0, where key bindings weren't set up if the user key config file was missing. 11 | 12 | ## [v0.2.0](https://github.com/richrd/suplemon/tree/0.2.0) (2019-06-16) compared to previous master branch. 13 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.65...0.2.0) 14 | 15 | **Fixed bugs:** 16 | 17 | - Fix not using the delta argument in the cursor move_up method. 18 | - Fix issue where mouse events in prompts could crash suplemon #247 19 | - Fix not being able to override default keys with user key bindings 20 | 21 | **Implemented enhancements:** 22 | 23 | - Allow help to be toggled with the help shortcut. Credit @caph1993 24 | - Allow opening files at specific row and column from command line. 25 | 26 | 27 | ## [v0.1.65](https://github.com/richrd/suplemon/tree/0.1.65) (2019-03-11) compared to previous master branch. 28 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.64...0.1.65) 29 | 30 | **Fixed bugs:** 31 | 32 | - Merge all the default config into user config. Credit @Consolatis 33 | - Fix diff command highlighting 34 | - Add shift+pageup and shift+pagedown bindings 35 | - Reuse existing windows on resize. Credit @Consolatis 36 | - Replace link to gitter chat with freenode webchat. Credit @Consolatis 37 | - Merge default module configs into user config. Credit @Consolatis 38 | - Use daemon mode for lint thread to fix shutdown delay. Credit @Consolatis 39 | - Simple xterm-256color imitation for curses. Credit @abl 40 | 41 | **Implemented enhancements:** 42 | 43 | - Allow line number padding using spaces instead of zeros. Credit @Consolatis 44 | - Highlight current line(s) Credit @Consolatis 45 | - Add crypt module for encrypting buffers with a password 46 | 47 | 48 | ## [v0.1.64](https://github.com/richrd/suplemon/tree/0.1.64) (2017-12-17) compared to previous master branch. 49 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.63...0.1.64) 50 | 51 | **Implemented enhancements:** 52 | 53 | - Add bulk_delete and sort_lines commands. 54 | - Lots of code style fixes and improvements. Credit @Gnewbee 55 | - Add xclip support for system clipboard. Credit @LChris314 56 | - Added command docs to readme and help. 57 | 58 | 59 | ## [v0.1.63](https://github.com/richrd/suplemon/tree/0.1.63) (2017-10-05) compared to previous master branch. 60 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.62...0.1.63) 61 | 62 | **Implemented enhancements:** 63 | 64 | - Add autocomplete to run command prompt (fixes #171) 65 | - Increase battery status polling time to 60 sec (previously 10 sec) 66 | - Change the top bar suplemon icon to a fancy unicode lemon. 67 | - Add paste mode for better pasting over SSH (disables auto indentation) 68 | 69 | **Fixed bugs:** 70 | 71 | - Keep top bar statuses of modules in alphabetical order based on module name. (fixes #57) 72 | - Prevent restoring file state if file has changed since last time (fixes #198) 73 | 74 | 75 | ## [v0.1.62](https://github.com/richrd/suplemon/tree/0.1.62) (2017-09-25) compared to previous master branch. 76 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.61...0.1.62) 77 | 78 | **Fixed bugs:** 79 | 80 | - Fixed and re-enabled fancy unicode symbols. 81 | - Fixed typos in default configuration. Credit @1fabunicorn (#192) 82 | - Fixed error message when loading valid but empty config file (Fixes #196). 83 | 84 | **Implemented enhancements:** 85 | 86 | - Add ctrl+t shortcut for trimming whitespace 87 | 88 | 89 | ## [v0.1.61](https://github.com/richrd/suplemon/tree/0.1.61) (2017-05-29) compared to previous master branch. 90 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.60...0.1.61) 91 | 92 | **Fixed bugs:** 93 | 94 | - Disable fancy unicode symbols by default. Caused problems on some terminals. 95 | 96 | 97 | ## [v0.1.60](https://github.com/richrd/suplemon/tree/0.1.60) (2017-03-23) compared to previous master branch. 98 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.59...0.1.60) 99 | 100 | **Implemented enhancements:** 101 | 102 | - Add support for the MacOS native pasteboard via pbcopy/pbpaste. Credit @abl 103 | - Added shift+tab for going backwards when autocompleting files. 104 | - Added F keys with modifiers and fixed some ctrl+shift keybindings. 105 | 106 | **Fixed bugs:** 107 | 108 | - Broader error handling in hostname module. 109 | - Don't print log message when opening file that doesn't exist. 110 | 111 | 112 | ## [v0.1.59](https://github.com/richrd/suplemon/tree/0.1.59) (2017-02-16) compared to previous master branch. 113 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.58...0.1.59) 114 | 115 | **Implemented enhancements:** 116 | 117 | - Added pygments as a dependency. 118 | 119 | 120 | **Fixed bugs:** 121 | 122 | - Added error handling to hostname module should it fail. 123 | 124 | 125 | ## [v0.1.58](https://github.com/richrd/suplemon/tree/0.1.58) (2016-12-01) compared to previous master branch. 126 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.57...0.1.58) 127 | 128 | **Fixed bugs:** 129 | 130 | - Fixed tests by using newer flake8. 131 | - Treat .ts files the same as .js so (un)commenting .ts files works. 132 | 133 | 134 | ## [v0.1.57](https://github.com/richrd/suplemon/tree/0.1.57) (2016-11-01) compared to previous master branch. 135 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.56...0.1.57) 136 | 137 | **Implemented enhancements:** 138 | - Show all special unicode whitespace characters when "show_white_space" is set to true. This helps detecting unwanted characters in files. 139 | - Now the mouse wheel works the same way in normal mode and mouse mode. 140 | 141 | **Fixed bugs:** 142 | 143 | - Fixed weird crash when pasting lots of text into prompts (like the find prompt etc) 144 | - Fixed false matches in diff highlighting. 145 | - Fixed inability to open empty files. 146 | - Fixed adding cursors via mouse click when the view is horizontally scrolled. 147 | - Fixed inputting various special characters that were ignored in some cases on Python3. 148 | 149 | 150 | ## [v0.1.56](https://github.com/richrd/suplemon/tree/0.1.56) (2016-08-01) compared to previous master branch. 151 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.55...0.1.56) 152 | 153 | **Implemented enhancements:** 154 | 155 | - New feature: Ability to use hard tabs instead of spaces via the boolean option 'hard_tabs'. 156 | - New feature: Save all files by running the ´save_all´ command. 157 | - New feature: Linter now shows PHP syntax errors (if PHP is installed). 158 | - New module: New `diff` command for comaring current edits to the file on disk. 159 | - New module: Show machine hostname in bottom status bar. 160 | - Enhanced Go To feature: If no file name begins with the search string, also match file names that contain the search string at any position. 161 | - Module status info is now shown on the left of core info in the bottom status bar. 162 | - More supported key bindings. 163 | - Other light code improvements. 164 | 165 | **Fixed bugs:** 166 | 167 | - Prevented multiple warnings about missing pygments. 168 | - Reload user keymap when it's changed in the editor. 169 | - Prioritize user key bindings over defaults. [\#163](https://github.com/richrd/suplemon/issues/163) 170 | - Reworked key handling to support more bindings (like `ctrl+enter` on some terminals). 171 | - Normalize modifier key order in keymaps so that they are matched correctly. 172 | - Properly set the internal file path when saving a file under a new name. 173 | 174 | ## [v0.1.55](https://github.com/richrd/suplemon/tree/0.1.55) (2016-08-01) compared to previous master branch. 175 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.54...0.1.55) 176 | 177 | **Implemented enhancements:** 178 | 179 | - Faster loading when linting lots of files 180 | 181 | - Use `invisibles` setting in TextMate themes [\#77](https://github.com/richrd/suplemon/issues/77) 182 | 183 | **Fixed bugs:** 184 | 185 | - Show key legend based on config instead of static defaults [\#157](https://github.com/richrd/suplemon/issues/157) 186 | 187 | 188 | ## [v0.1.54](https://github.com/richrd/suplemon/tree/0.1.54) (2016-07-30) compared to previous master branch. 189 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.53...0.1.54) 190 | 191 | **Implemented enhancements:** 192 | 193 | - Autocomplete in open/save dialogs 194 | 195 | **Fixed bugs:** 196 | 197 | - Fixed showing unwritable marker when saving file in a writable location 198 | 199 | 200 | ## [v0.1.32](https://github.com/richrd/suplemon/tree/0.1.32) (2015-08-12) compared to previous master branch. 201 | [Full Changelog](https://github.com/richrd/suplemon/compare/0.1.31...0.1.32) 202 | 203 | **Implemented enhancements:** 204 | 205 | - Use Sphinx notation for documenting parameters/return values etc [\#54](https://github.com/richrd/suplemon/issues/54) 206 | - Pygments syntax highlighting [\#52](https://github.com/richrd/suplemon/issues/52) 207 | - Make jumping between words also jump to next or previous line when applicable. [\#48](https://github.com/richrd/suplemon/issues/48) 208 | - Retain cursor x coordinate when moving vertically. [\#24](https://github.com/richrd/suplemon/issues/24) 209 | - Add line number coloring to linelighters. [\#23](https://github.com/richrd/suplemon/issues/23) 210 | - Native clipboard support [\#73](https://github.com/richrd/suplemon/issues/73) 211 | - Installing system-wide [\#75](https://github.com/richrd/suplemon/issues/75) 212 | - Added a changelog. The new version is much more mature than before, and there are a lot of changes. That's why this is the first changelog (that should have existed long before) 213 | 214 | **Closed issues:** 215 | 216 | - Auto hide keyboard shortcuts from status bar [\#70](https://github.com/richrd/suplemon/issues/70) 217 | 218 | **Fixed bugs:** 219 | 220 | - Using delete at the end of multiple lines behaves incorrectly [\#74](https://github.com/richrd/suplemon/issues/74) 221 | 222 | **Merged pull requests:** 223 | 224 | - Implemented jumping between lines. Fixes \#48. [\#72](https://github.com/richrd/suplemon/pull/72) ([richrd](https://github.com/richrd)) 225 | - Pygments-based highlighting [\#68](https://github.com/richrd/suplemon/pull/68) ([Jimx-](https://github.com/Jimx-)) 226 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Richard Lewis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include suplemon/themes/* 2 | include suplemon/config/*.json 3 | include suplemon/modules/*.py 4 | include suplemon/linelight/*.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Suplemon :lemon: 2 | ======== 3 | 4 | [![Build Status](https://travis-ci.org/richrd/suplemon.svg?branch=master)](https://travis-ci.org/richrd/suplemon) [![Join the chat at https://webchat.freenode.net/?channels=%23suplemon](https://img.shields.io/badge/chat-on%20freenode%20%23suplemon-blue.svg)](https://webchat.freenode.net/?channels=%23suplemon) 5 | [![Run on Repl.it](https://repl.it/badge/github/richrd/suplemon)](https://repl.it/github/richrd/suplemon) 6 | 7 | ___________ _________ ___ ______________________________ ___ 8 | / _____/ / / / _ \/ /\ / ______/ / ___ / | / /\ 9 | / /____/ / / / /_/ / / / / /_____/ / / / / / / |/ / / 10 | /____ / / / / _____/ / / / ______/ / / / / / / /| / / 11 | _____/ / /__/ / /\___/ /____/ /_____/ / / / /__/ / / | / / 12 | /_______/\_______/__/ / /_______/________/__/__/__/________/__/ /|__/ / 13 | \_______\ \______\__\/ \_______\________\__\__\__\________\__\/ \__\/ 14 | 15 | Remedying the pain of command line editing since 2014 16 | 17 | 18 | Suplemon is a modern, powerful and intuitive console text editor with multi cursor support. 19 | Suplemon replicates Sublime Text style functionality in the terminal with the ease of use of Nano. 20 | http://github.com/richrd/suplemon 21 | 22 | 23 | ![Suplemon in action](https://i.imgur.com/pdKvKsN.gif) 24 | 25 | ## Features 26 | * Proper multi cursor editing, as in Sublime Text 27 | * Syntax highlighting with Text Mate themes 28 | * Autocomplete (based on words in the files that are open) 29 | * Easy Undo/Redo (Ctrl + Z, Ctrl + Y) 30 | * Copy & Paste, with multi line support (and native clipboard support on X11 / Unix and Mac OS) 31 | * Multiple files in tabs 32 | * Powerful Go To feature for jumping to files and lines 33 | * Find, Find next and Find all (Ctrl + F, Ctrl + D, Ctrl + A) 34 | * Custom keyboard shortcuts (and easy-to-use defaults) 35 | * Mouse support 36 | * Restores cursor and scroll positions when reopenning files 37 | * Extensions (easy to write your own) 38 | * Lots more... 39 | 40 | 41 | ## Caveats 42 | * Currently no built in selections (regions). To copy part of a line select it with your mouse and use Ctrl + Shift + C 43 | 44 | ## Try it! 45 | 46 | You can just clone the repo, and try Suplemon, or also install it system wide. 47 | To run from source you need to install the python `wcwidth` package. 48 | 49 | pip3 install wcwidth 50 | git clone https://github.com/richrd/suplemon.git 51 | cd suplemon 52 | python3 suplemon.py 53 | 54 | ### Installation 55 | 56 | Install the latest version from PIP: 57 | 58 | sudo pip3 install suplemon 59 | 60 | To install Suplemon from the repo run the setup script: 61 | 62 | sudo python3 setup.py install 63 | 64 | ### Usage 65 | 66 | suplemon # New file in the current directory 67 | suplemon [filename]... # Open one or more files 68 | suplemon [filename:row:col]... # Open one or more files at a specific row or column (optional) 69 | 70 | 71 | ### Notes 72 | - **Must use Python 3.3 or higher for proper character encoding support.** 73 | - **Python2.7 (and maybe lower) versions work, but aren't officially supported (some special characters won't work etc).** 74 | - *The master branch is considered stable.* 75 | - *Tested on Unix.* 76 | 77 | Dev Branch Status: [![Build Status](https://travis-ci.org/richrd/suplemon.svg?branch=dev)](https://travis-ci.org/richrd/suplemon) 78 | 79 | No dependencies outside the Python Standard Library required. 80 | 81 | ### Optional dependencies 82 | 83 | * Pygments 84 | > For support for syntax highlighting over 300 languages. 85 | 86 | * Flake8 87 | > For showing linting for Python files. 88 | 89 | * xsel or xclip 90 | > For system clipboard support on X Window (Linux). 91 | 92 | * pbcopy / pbpaste 93 | > For system clipboard support on Mac OS. 94 | 95 | See [docs/optional-dependencies.md][] for installation instructions. 96 | 97 | [docs/optional-dependencies.md]: docs/optional-dependencies.md 98 | 99 | ## Description 100 | Suplemon is an intuitive command line text editor. It supports multiple cursors out of the box. 101 | It is as easy as nano, and has much of the power of Sublime Text. It also supports extensions 102 | to allow all kinds of customizations. To get more help hit ```Ctrl + H``` in the editor. 103 | Suplemon is licensed under the MIT license. 104 | 105 | ## Configuration 106 | 107 | ### Main Config 108 | The suplemon config file is stored at ```~/.config/suplemon/suplemon-config.json```. 109 | 110 | The best way to edit it is to run the ```config``` command (Run commands via ```Ctrl+E```). 111 | That way Suplemon will automatically reload the configuration when you save the file. 112 | To view the default configuration and see what options are available run ```config defaults``` via ```Ctrl+E```. 113 | 114 | 115 | ### Keymap Config 116 | 117 | Below are the default key mappings used in suplemon. They can be edited by running the ```keymap``` command. 118 | To view the default keymap file run ```keymap default``` 119 | 120 | * Ctrl + Q 121 | > Exit 122 | 123 | * Ctrl + W 124 | > Close file or tab 125 | 126 | * Ctrl + C 127 | > Copy line(s) to buffer 128 | 129 | * Ctrl + X 130 | > Cut line(s) to buffer 131 | 132 | * Ctrl + V 133 | > Insert buffer 134 | 135 | * Ctrl + K 136 | > Duplicate line 137 | 138 | * Ctrl + G 139 | > Go to line number or file (type the beginning of a filename to switch to it). 140 | > You can also use 'filena:42' to go to line 42 in filename.py etc. 141 | 142 | * Ctrl + F 143 | > Search for a string or regular expression (configurable) 144 | 145 | * Ctrl + D 146 | > Search for next occurrence or find the word the cursor is on. Adds a new cursor at each new occurrence. 147 | 148 | * Ctrl + T 149 | > Trim whitespace 150 | 151 | * Alt + Arrow Key 152 | > Add new cursor in arrow direction 153 | 154 | * Ctrl + Left / Right 155 | > Jump to previous or next word or line 156 | 157 | * ESC 158 | > Revert to a single cursor / Cancel input prompt 159 | 160 | * Alt + Page Up 161 | > Move line(s) up 162 | 163 | * Alt + Page Down 164 | > Move line(s) down 165 | 166 | * Ctrl + S 167 | > Save current file 168 | 169 | * F1 170 | > Save file with new name 171 | 172 | * F2 173 | > Reload current file 174 | 175 | * Ctrl + O 176 | > Open file 177 | 178 | * Ctrl + W 179 | > Close file 180 | 181 | * Ctrl + Page Up 182 | > Switch to next file 183 | 184 | * Ctrl + Page Down 185 | > Switch to previous file 186 | 187 | * Ctrl + E 188 | > Run a command. 189 | 190 | * Ctrl + Z and F5 191 | > Undo 192 | 193 | * Ctrl + Y and F6 194 | > Redo 195 | 196 | * F7 197 | > Toggle visible whitespace 198 | 199 | * F8 200 | > Toggle mouse mode 201 | 202 | * F9 203 | > Toggle line numbers 204 | 205 | * F11 206 | > Toggle full screen 207 | 208 | ## Mouse shortcuts 209 | 210 | * Left Click 211 | > Set cursor at mouse position. Reverts to a single cursor. 212 | 213 | * Right Click 214 | > Add a cursor at mouse position. 215 | 216 | * Scroll Wheel Up / Down 217 | > Scroll up & down. 218 | 219 | ## Commands 220 | 221 | Suplemon has various add-ons that implement extra features. 222 | The commands can be run with Ctrl + E and the prompt has autocomplete to make running them faster. 223 | The available commands and their descriptions are: 224 | 225 | * autocomplete 226 | 227 | A simple autocompletion module. 228 | 229 | This adds autocomplete support for the tab key. It uses a word 230 | list scanned from all open files for completions. By default it suggests 231 | the shortest possible match. If there are no matches, the tab action is 232 | run normally. 233 | 234 | * autodocstring 235 | 236 | Simple module for adding docstring placeholders. 237 | 238 | This module is intended to generate docstrings for Python functions. 239 | It adds placeholders for descriptions, arguments and return data. 240 | Function arguments are crudely parsed from the function definition 241 | and return statements are scanned from the function body. 242 | 243 | * bulk_delete 244 | 245 | Bulk delete lines and characters. 246 | Asks what direction to delete in by default. 247 | 248 | Add 'up' to delete lines above highest cursor. 249 | Add 'down' to delete lines below lowest cursor. 250 | Add 'left' to delete characters to the left of all cursors. 251 | Add 'right' to delete characters to the right of all cursors. 252 | 253 | * comment 254 | 255 | Toggle line commenting based on current file syntax. 256 | 257 | * config 258 | 259 | Shortcut for openning the config files. 260 | 261 | * crypt 262 | 263 | Encrypt or decrypt the current buffer. Lets you provide a passphrase and optional salt for encryption. 264 | Uses AES for encryption and scrypt for key generation. 265 | 266 | * diff 267 | 268 | View a diff of the current file compared to it's on disk version. 269 | 270 | * eval 271 | 272 | Evaluate a python expression and show the result in the status bar. 273 | 274 | If no expression is provided the current line(s) are evaluated and 275 | replaced with the evaluation result. 276 | 277 | * keymap 278 | 279 | Shortcut to openning the keymap config file. 280 | 281 | * linter 282 | 283 | Linter for suplemon. 284 | 285 | * lower 286 | 287 | Transform current lines to lower case. 288 | 289 | * lstrip 290 | 291 | Trim whitespace from beginning of current lines. 292 | 293 | * paste 294 | 295 | Toggle paste mode (helpful when pasting over SSH if auto indent is enabled) 296 | 297 | * reload 298 | 299 | Reload all add-on modules. 300 | 301 | * replace_all 302 | 303 | Replace all occurrences in all files of given text with given replacement. 304 | 305 | * reverse 306 | 307 | Reverse text on current line(s). 308 | 309 | * rstrip 310 | 311 | Trim whitespace from the end of lines. 312 | 313 | * save 314 | 315 | Save the current file. 316 | 317 | * save_all 318 | 319 | Save all currently open files. Asks for confirmation. 320 | 321 | * sort_lines 322 | 323 | Sort current lines. 324 | 325 | Sorts alphabetically by default. 326 | Add 'length' to sort by length. 327 | Add 'reverse' to reverse the sorting. 328 | 329 | * strip 330 | 331 | Trim whitespace from start and end of lines. 332 | 333 | * tabstospaces 334 | 335 | Convert tab characters to spaces in the entire file. 336 | 337 | * toggle_whitespace 338 | 339 | Toggle visually showing whitespace. 340 | 341 | * upper 342 | 343 | Transform current lines to upper case. 344 | 345 | 346 | ## Support 347 | 348 | If you experience problems, please submit a new issue. 349 | If you have a question, need help, or just want to chat head over to the IRC channel #suplemon @ Freenode. 350 | I'll be happy to chat with you, see you there! 351 | 352 | 353 | ## Development 354 | 355 | If you are interested in contributing to Suplemon, development dependencies can be installed via: 356 | 357 | # For OS cleanliness, we recommend using `virtualenv` to prevent global contamination 358 | pip install -r requirements-dev.txt 359 | 360 | After those are installed, tests can be run via: 361 | 362 | ./test.sh 363 | 364 | PRs are very welcome and appreciated. 365 | When making PRs make sure to set the target branch to `dev`. I only push to master when releasing new versions. 366 | 367 | 368 | ## Rationale 369 | For many the command line is a different environment for text editing. 370 | Most coders are familiar with GUI text editors and for many vi and emacs 371 | have a too steep learning curve. For them (like for me) nano was the weapon of 372 | choice. But nano feels clunky and it has its limitations. That's why 373 | I wrote my own editor with built in multi cursor support to fix the situation. 374 | Another reason is that developing Suplemon is simply fun to do. 375 | -------------------------------------------------------------------------------- /docs/optional-dependencies.md: -------------------------------------------------------------------------------- 1 | Optional dependencies 2 | ===================== 3 | 4 | ## Pygments 5 | Pygments adds syntax highlighting support to Suplemon. To install it, we suggest using `pip`: 6 | 7 | pip install pygments 8 | 9 | More information and installation options can be found on their website: 10 | 11 | http://pygments.org/ 12 | 13 | ## Flake8 14 | Flake8 allows us to lint Python files in Suplemon. To install it, we suggest using `flake8`: 15 | 16 | pip install flake8 17 | 18 | More information and installation options can be found on their website: 19 | 20 | http://flake8.readthedocs.org/en/latest/index.html 21 | 22 | ## xsel 23 | xsel adds system clipboard support on X Window environments (e.g. Linux). To install it, use your system package manager. 24 | 25 | For example to install it with `apt`, run the following: 26 | 27 | sudo apt-get install xsel 28 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Parse our CLI arguments 6 | version="$1" 7 | if test "$version" = ""; then 8 | echo "Expected a version to be provided to \`release.sh\` but none was provided." 1>&2 9 | echo "Usage: $0 [version] # (e.g. $0 1.0.0)" 1>&2 10 | exit 1 11 | fi 12 | 13 | # Bump the version via regexp 14 | sed -E "s/^(__version__ = \")[0-9]+\.[0-9]+\.[0-9]+(\")$/\1$version\2/" suplemon/main.py --in-place 15 | 16 | # Verify our version made it into the file 17 | if ! grep "$version" suplemon/main.py &> /dev/null; then 18 | echo "Expected \`__version__\` to update via \`sed\` but it didn't" 1>&2 19 | exit 1 20 | fi 21 | 22 | # Commit the change 23 | git add suplemon/main.py 24 | git commit -a -m "Release $version" 25 | 26 | # Tag the release 27 | git tag "$version" 28 | 29 | # Publish the release to GitHub 30 | git push 31 | git push --tags 32 | 33 | # Publish the release to PyPI 34 | python setup.py sdist --formats=gztar,zip upload 35 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.0.0,<=3.1.1 2 | flake8-quotes>=0.1.1,<1.0.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length = 120 6 | inline-quotes = " 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from setuptools import setup 4 | 5 | version = re.search( 6 | '^__version__\s*=\s*"([^"]*)"', 7 | open("suplemon/main.py").read(), 8 | re.M 9 | ).group(1) 10 | 11 | files = ["config/*.json", "themes/*", "modules/*.py", "linelight/*.py"] 12 | 13 | setup(name="Suplemon", 14 | version=version, 15 | description="Console text editor with multi cursor support.", 16 | author="Richard Lewis", 17 | author_email="richrd.lewis@gmail.com", 18 | url="https://github.com/richrd/suplemon/", 19 | packages=["suplemon"], 20 | package_data={"": files}, 21 | include_package_data=True, 22 | install_requires=[ 23 | "pygments", 24 | "wcwidth" 25 | ], 26 | entry_points={ 27 | "console_scripts": ["suplemon=suplemon.cli:main"] 28 | }, 29 | classifiers=[] 30 | ) 31 | -------------------------------------------------------------------------------- /suplemon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Convenience wrapper for running suplemon directly from source tree.""" 5 | 6 | from suplemon.cli import main 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /suplemon/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richrd/suplemon/8bb67d6758e5bc5ca200fdce7a0fb6635abb66f4/suplemon/__init__.py -------------------------------------------------------------------------------- /suplemon/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | from .cli import main 3 | main() 4 | -------------------------------------------------------------------------------- /suplemon/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 3 | """ 4 | Start a Suplemon instance in the current window 5 | """ 6 | 7 | import sys 8 | 9 | try: 10 | import argparse 11 | except: 12 | # Python < 2.7 13 | argparse = False 14 | 15 | from .main import App, __version__ 16 | 17 | 18 | def main(): 19 | """Handle CLI invocation""" 20 | # Parse our CLI arguments 21 | config_file = None 22 | if argparse: 23 | parser = argparse.ArgumentParser(description="Console text editor with multi cursor support") 24 | parser.add_argument("filenames", metavar="filename", type=str, nargs="*", help="Files to load into Suplemon") 25 | parser.add_argument("--version", action="version", version=__version__) 26 | parser.add_argument("--config", type=str, help="Configuration file path.") 27 | args = parser.parse_args() 28 | filenames = args.filenames 29 | config_file = args.config 30 | else: 31 | # Python < 2.7 fallback 32 | filenames = sys.argv[1:] 33 | 34 | # Generate and start our application 35 | app = App(filenames=filenames, config_file=config_file) 36 | if app.init(): 37 | app.run() 38 | 39 | # Output log info 40 | if app.debug: 41 | for logger_handler in app.logger.handlers: 42 | logger_handler.close() 43 | 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /suplemon/config.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Config handler. 4 | """ 5 | 6 | import os 7 | import json 8 | import logging 9 | 10 | from . import suplemon_module 11 | 12 | 13 | class Config: 14 | def __init__(self, app): 15 | self.app = app 16 | self.logger = logging.getLogger(__name__) 17 | self.default_config_filename = "defaults.json" 18 | self.default_keymap_filename = "keymap.json" 19 | self.config_filename = "suplemon-config.json" 20 | self.keymap_filename = "suplemon-keymap.json" 21 | self.home_dir = os.path.expanduser("~") 22 | self.config_dir = os.path.join(self.home_dir, ".config", "suplemon") 23 | 24 | self.defaults = {} 25 | self.keymap = {} 26 | self.config = {} 27 | self.key_bindings = {} 28 | 29 | def init(self): 30 | self.create_config_dir() 31 | return self.load_defaults() 32 | 33 | def path(self): 34 | return os.path.join(self.config_dir, self.config_filename) 35 | 36 | def keymap_path(self): 37 | return os.path.join(self.config_dir, self.keymap_filename) 38 | 39 | def set_path(self, path): 40 | parts = os.path.split(path) 41 | self.config_dir = parts[0] 42 | self.config_filename = parts[1] 43 | 44 | def load(self): 45 | path = self.path() 46 | config = False 47 | if not os.path.exists(path): 48 | self.logger.debug("Configuration file '{0}' doesn't exist.".format(path)) 49 | else: 50 | config = self.load_config_file(path) 51 | if config is not False: 52 | self.logger.debug("Loaded configuration file '{0}'".format(path)) 53 | self.config = self.merge_defaults(config) 54 | else: 55 | self.logger.info("Failed to load config file '{0}'.".format(path)) 56 | self.config = dict(self.defaults) 57 | self.load_keys() 58 | return config 59 | 60 | def load_keys(self): 61 | path = self.keymap_path() 62 | keymap = [] 63 | 64 | if not os.path.exists(path): 65 | self.logger.debug("Keymap file '{0}' doesn't exist.".format(path)) 66 | else: 67 | keymap = self.load_config_file(path) or [] 68 | if not keymap: 69 | self.logger.warning("Failed to load keymap file '{0}'.".format(path)) 70 | 71 | # Build the key bindings 72 | # User keymap overwrites the defaults in the bindings 73 | self.keymap = self.normalize_keys(self.keymap + keymap) 74 | self.key_bindings = {} 75 | for binding in self.keymap: 76 | for key in binding["keys"]: 77 | self.key_bindings[key] = binding["command"] 78 | 79 | return True 80 | 81 | def normalize_keys(self, keymap): 82 | """Normalize the order of modifier keys in keymap.""" 83 | modifiers = ["shift", "ctrl", "alt", "meta"] # The modifiers in correct order 84 | for item in keymap: 85 | new_keys = [] 86 | for key_item in item["keys"]: 87 | parts = key_item.split("+") 88 | key = parts[-1] 89 | if len(parts) < 2: 90 | new_keys.append(key) 91 | continue 92 | normalized = "" 93 | for mod in modifiers: # Add the used modifiers back in correct order 94 | if mod in parts: 95 | normalized += mod + "+" 96 | normalized += key 97 | new_keys.append(normalized) 98 | item["keys"] = new_keys 99 | return keymap 100 | 101 | def load_defaults(self): 102 | if not self.load_default_config() or not self.load_default_keys(): 103 | return False 104 | return True 105 | 106 | def load_default_config(self): 107 | path = os.path.join(self.app.path, "config", self.default_config_filename) 108 | config = self.load_config_file(path) 109 | if not config: 110 | self.logger.error("Failed to load default config file '{0}'!".format(path)) 111 | return False 112 | self.defaults = config 113 | return True 114 | 115 | def load_module_configs(self): 116 | module_config = {} 117 | modules = self.app.modules.modules 118 | for module_name in modules.keys(): 119 | module = modules[module_name] 120 | conf = module.get_default_config() 121 | module_config[module_name] = conf 122 | self.logger.debug("Loading default config for module '%s': %s" % (module_name, str(conf))) 123 | self.defaults["modules"] = module_config 124 | self.config = self.merge_defaults(self.config) 125 | 126 | def load_default_keys(self): 127 | path = os.path.join(self.app.path, "config", self.default_keymap_filename) 128 | config = self.load_config_file(path) 129 | if not config: 130 | self.logger.error("Failed to load default keymap file '{0}'!".format(path)) 131 | return False 132 | self.keymap = config 133 | return True 134 | 135 | def reload(self): 136 | """Reload the config file.""" 137 | return self.load() 138 | 139 | def store(self): 140 | """Write current config state to file.""" 141 | data = json.dumps(self.config) 142 | f = open(self.config_filename) 143 | f.write(data) 144 | f.close() 145 | 146 | def merge_defaults(self, config): 147 | """Fill any missing config options with defaults.""" 148 | return self._merge_defaults(self.defaults, config) 149 | 150 | def _merge_defaults(self, defaults, config): 151 | """Recursivley merge two dicts.""" 152 | for key in defaults.keys(): 153 | item = defaults[key] 154 | if key not in config.keys(): 155 | config[key] = item 156 | continue 157 | if not isinstance(item, dict): 158 | continue 159 | config[key] = self._merge_defaults(item, config[key]) 160 | return config 161 | 162 | def load_config_file(self, path): 163 | try: 164 | f = open(path) 165 | data = f.read() 166 | f.close() 167 | data = self.remove_config_comments(data) 168 | config = json.loads(data) 169 | return config 170 | except: 171 | return False 172 | 173 | def remove_config_comments(self, data): 174 | """Remove comments from a 'pseudo' JSON config file. 175 | 176 | Removes all lines that begin with '#' or '//' ignoring whitespace. 177 | 178 | :param data: Commented JSON data to clean. 179 | :return: Cleaned pure JSON. 180 | """ 181 | lines = data.split("\n") 182 | cleaned = [] 183 | for line in lines: 184 | line = line.strip() 185 | if line.startswith(("//", "#")): 186 | continue 187 | cleaned.append(line) 188 | return "\n".join(cleaned) 189 | 190 | def create_config_dir(self): 191 | if not os.path.exists(self.config_dir): 192 | try: 193 | os.makedirs(self.config_dir) 194 | except: 195 | self.app.logger.warning("Config folder '{0}' doesn't exist and couldn't be created.".format( 196 | self.config_dir)) 197 | 198 | def __getitem__(self, i): 199 | """Get a config variable.""" 200 | return self.config[i] 201 | 202 | def __setitem__(self, i, v): 203 | """Set a config variable.""" 204 | self.config[i] = v 205 | 206 | def __str__(self): 207 | """Convert entire config array to string.""" 208 | return str(self.config) 209 | 210 | def __len__(self): 211 | """Return length of top level config variables.""" 212 | return len(self.config) 213 | 214 | 215 | class ConfigModule(suplemon_module.Module): 216 | """Helper for shortcut for opening config files.""" 217 | def init(self): 218 | self.config_name = "defaults.json" 219 | self.config_default_path = os.path.join(self.app.path, "config", self.config_name) 220 | self.config_user_path = self.app.config.path() 221 | 222 | def run(self, app, editor, args): 223 | if args == "defaults": 224 | # Open the default config in a new file only for viewing 225 | self.open(app, self.config_default_path, read_only=True) 226 | else: 227 | self.open(app, self.config_user_path) 228 | 229 | def open(self, app, path, read_only=False): 230 | if read_only: 231 | f = open(path) 232 | data = f.read() 233 | f.close() 234 | file = app.new_file() 235 | file.set_name(self.config_name) 236 | file.set_data(data) 237 | app.switch_to_file(app.last_file_index()) 238 | else: 239 | # Open the user config file for editing 240 | f = app.file_is_open(path) 241 | if f: 242 | app.switch_to_file(app.get_file_index(f)) 243 | else: 244 | if not app.open_file(path): 245 | app.new_file(path) 246 | app.switch_to_file(app.last_file_index()) 247 | -------------------------------------------------------------------------------- /suplemon/config/defaults.json: -------------------------------------------------------------------------------- 1 | // Suplemon Default Config 2 | 3 | // This file contains the default config for Suplemon and should not be edited. 4 | // If the file doesn't exist, or if it has errors Suplemon can't run. 5 | // Suplemon supports single line comments in JSON as seen here. 6 | 7 | // There are three main groups for settings: 8 | // - app: Global settings 9 | // - editor: Editor behaviour 10 | // - display: How the UI looks 11 | 12 | { 13 | // Global settings 14 | "app": { 15 | // Print debug logging 16 | "debug": true, 17 | // Debug log level (0: Notset, 10: Debug, 20: Info, 30: Warning, 40: Error, 50: Critical) 18 | "debug_level": 20, 19 | // How long curses will wait to detect ESC key 20 | "escdelay": 50, 21 | // Whether to use special unicode symbols for decoration 22 | "use_unicode_symbols": true, 23 | // If your $TERM ends in -256color and this is true, 'xterm-256color' 24 | // will be used instead, working around an issue with curses. 25 | "imitate_256color": false 26 | }, 27 | // Editor settings 28 | "editor": { 29 | // Indent new lines to same level as previous line 30 | "auto_indent_newline": true, 31 | // Character to use for end of line 32 | "end_of_line": "\n", 33 | // Unindent with backspace 34 | "backspace_unindent": true, 35 | // Cursor style. 'reverse' or 'underline' 36 | "cursor_style": "reverse", 37 | // Encoding for reading and writing files 38 | "default_encoding": "utf-8", 39 | // Use hard tabs (insert actual tabulator character instead of spaces) 40 | "hard_tabs": 0, 41 | // Number of spaces to insert when pressing tab 42 | "tab_width": 4, 43 | // Amount of undo states to store 44 | "max_history": 50, 45 | // Characters considered to separate words 46 | "punctuation": " (){}[]<>$@!%'\"=+-/*.:,;_\n\r", 47 | // Character to use to visualize end of line 48 | "line_end_char": "", 49 | // White space characters and their visual matches 50 | "white_space_map": { 51 | // Null byte as null symbol 52 | "\u0000": "\u2400", 53 | // Space as interpunct 54 | " ": "\u00B7", 55 | // Tab as tab symbol 56 | "\t": "\u21B9", 57 | // Nonbreaking space as open box 58 | "\u00A0": "\u237D", 59 | // Soft hyphen as letter shelf 60 | "\u00AD": "\u2423", 61 | 62 | // Other special unicode spaces shown as a space symbol (s/p) 63 | // See here for details: http://www.cs.tut.fi/~jkorpela/chars/spaces.html 64 | 65 | // no-break space 66 | "\u00A0": "\u2420", 67 | // mongolian vowel separator 68 | "\u180E": "\u2420", 69 | // en quad 70 | "\u2000": "\u2420", 71 | // em quad 72 | "\u2001": "\u2420", 73 | // en space 74 | "\u2002": "\u2420", 75 | // em space 76 | "\u2003": "\u2420", 77 | // three-per-em space 78 | "\u2004": "\u2420", 79 | // four-per-em space 80 | "\u2005": "\u2420", 81 | // six-per-em space 82 | "\u2006": "\u2420", 83 | // figure space 84 | "\u2007": "\u2420", 85 | // punctuation space 86 | "\u2008": "\u2420", 87 | // thin space 88 | "\u2009": "\u2420", 89 | // hair space 90 | "\u200A": "\u2420", 91 | // zero width space 92 | "\u200B": "\u2420", 93 | // narrow no-break space 94 | "\u202F": "\u2420", 95 | // medium mathematical space 96 | "\u205F": "\u2420", 97 | // ideographic space 98 | "\u3000": "\u2420", 99 | // zero width no-break space 100 | "\uFEFF": "\u2420" 101 | }, 102 | // Whether to visually show white space chars 103 | "show_white_space": false, 104 | // Show tab indicators in whitespace 105 | "show_tab_indicators": true, 106 | // Tab indicator charatrer 107 | "tab_indicator_character": "\u203A", 108 | // Highlight current line(s) 109 | "highlight_current_line": true, 110 | // Line numbering 111 | "show_line_nums": true, 112 | // Pad line numbers with spaces instead of zeros 113 | "line_nums_pad_space": true, 114 | // Naive line highlighting 115 | "show_line_colors": true, 116 | // Proper syntax highlighting 117 | "show_highlighting": true, 118 | // Syntax highlighting theme 119 | "theme": "monokai", 120 | // Listen for mouse events 121 | "use_mouse": false, 122 | // Whether to use copy/paste across multiple files 123 | "use_global_buffer": true, 124 | // Find with regex by default 125 | "regex_find": false 126 | }, 127 | // UI Display Settings 128 | "display": { 129 | // Show top status bar 130 | "show_top_bar": true, 131 | // Show app name and version in top bar 132 | "show_app_name": true, 133 | // Show list of open files in top bar 134 | "show_file_list": true, 135 | // Show indicator in the file list for files that are modified 136 | // NOTE: if you experience performance issues, set this to false 137 | "show_file_modified_indicator": true, 138 | // Show the keyboard legend 139 | "show_legend": true, 140 | // Show the bottom status bar 141 | "show_bottom_bar": true, 142 | // Invert status bar colors (switch text and background colors) 143 | "invert_status_bars": false 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /suplemon/config/keymap.json: -------------------------------------------------------------------------------- 1 | // Suplemon Default Key Map 2 | 3 | // This file contains the default key map for Suplemon and should not be edited. 4 | // If the file doesn't exist, or if it has errors Suplemon can't run. 5 | // Suplemon supports single line comments in JSON as seen here. 6 | 7 | [ 8 | // App 9 | {"keys": ["ctrl+h"], "command": "help"}, 10 | {"keys": ["ctrl+s"], "command": "save_file"}, 11 | {"keys": ["ctrl+e"], "command": "run_command"}, 12 | {"keys": ["ctrl+f"], "command": "find"}, 13 | {"keys": ["ctrl+g"], "command": "go_to"}, 14 | {"keys": ["ctrl+o"], "command": "open"}, 15 | {"keys": ["ctrl+w"], "command": "close_file"}, 16 | {"keys": ["ctrl+n"], "command": "new_file"}, 17 | {"keys": ["ctrl+q"], "command": "ask_exit"}, 18 | {"keys": ["ctrl+p"], "command": "comment"}, 19 | {"keys": ["ctrl+pageup"], "command": "next_file"}, 20 | {"keys": ["ctrl+pagedown"], "command": "prev_file"}, 21 | {"keys": ["f1"], "command": "save_file_as"}, 22 | {"keys": ["f2"], "command": "reload_file"}, 23 | {"keys": ["f7"], "command": "toggle_whitespace"}, 24 | {"keys": ["f8"], "command": "toggle_mouse"}, 25 | {"keys": ["f12"], "command": "toggle_fullscreen"}, 26 | // Editor 27 | {"keys": ["up"], "command": "arrow_up"}, 28 | {"keys": ["down"], "command": "arrow_down"}, 29 | {"keys": ["left"], "command": "arrow_left"}, 30 | {"keys": ["right"], "command": "arrow_right"}, 31 | {"keys": ["enter"], "command": "enter"}, 32 | {"keys": ["backspace"], "command": "backspace"}, 33 | {"keys": ["delete"], "command": "delete"}, 34 | {"keys": ["tab"], "command": "tab"}, 35 | {"keys": ["shift+tab"], "command": "untab"}, 36 | {"keys": ["home"], "command": "home"}, 37 | {"keys": ["end"], "command": "end"}, 38 | {"keys": ["escape"], "command": "escape"}, 39 | {"keys": ["pageup"], "command": "page_up"}, 40 | {"keys": ["pagedown"], "command": "page_down"}, 41 | {"keys": ["f5", "ctrl+z"], "command": "undo"}, 42 | {"keys": ["f6", "ctrl+y"], "command": "redo"}, 43 | {"keys": ["f9"], "command": "toggle_line_nums"}, 44 | {"keys": ["f10"], "command": "toggle_line_ends"}, 45 | {"keys": ["f11"], "command": "toggle_highlight"}, 46 | {"keys": ["alt+up"], "command": "new_cursor_up"}, 47 | {"keys": ["alt+down"], "command": "new_cursor_down"}, 48 | {"keys": ["alt+left"], "command": "new_cursor_left"}, 49 | {"keys": ["alt+right"], "command": "new_cursor_right"}, 50 | {"keys": ["alt+pageup"], "command": "push_up"}, 51 | {"keys": ["alt+pagedown"], "command": "push_down"}, 52 | {"keys": ["ctrl+c"], "command": "copy"}, 53 | {"keys": ["ctrl+x"], "command": "cut"}, 54 | {"keys": ["ctrl+k"], "command": "duplicate_line"}, 55 | {"keys": ["ctrl+v", "insert"], "command": "insert"}, 56 | {"keys": ["ctrl+d"], "command": "find_next"}, 57 | {"keys": ["ctrl+a"], "command": "find_all"}, 58 | {"keys": ["ctrl+left"], "command": "jump_left"}, 59 | {"keys": ["ctrl+right"], "command": "jump_right"}, 60 | {"keys": ["ctrl+up"], "command": "jump_up"}, 61 | {"keys": ["ctrl+down"], "command": "jump_down"}, 62 | {"keys": ["ctrl+t"], "command": "strip"} 63 | ] -------------------------------------------------------------------------------- /suplemon/cursor.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Cursor object for storing cursor data. 4 | """ 5 | 6 | 7 | class Cursor: 8 | def __init__(self, x=0, y=0): 9 | """Initialize Cursor. 10 | 11 | :param x: Cursor x coordinate or x,y tuple. Defaults to 0. 12 | :param y: Cursor y coordinate. Defaults to 0. 13 | """ 14 | # Handle coords as a tuple 15 | if isinstance(x, tuple) or isinstance(x, list): 16 | x, y = x 17 | self.x = x 18 | self.y = y 19 | # Handle coords from existing Cursor 20 | elif isinstance(x, Cursor): # Handle arguments as a cursor 21 | self.x = x.x 22 | self.y = x.y 23 | # Handle coords as plain ints 24 | else: 25 | self.x = x 26 | self.y = y 27 | # Store the desired x position and 28 | # use it if the line is long enough 29 | self.persistent_x = self.x 30 | 31 | def get_x(self): 32 | """Return the x coordinate of the cursor. 33 | 34 | :return: Cursor x coordinate. 35 | :rtype: int 36 | """ 37 | return self.x 38 | 39 | def get_y(self): 40 | """Return the y coordinate of the cursor. 41 | 42 | :return: Cursor y coordinate. 43 | :rtype: int 44 | """ 45 | return self.y 46 | 47 | def set_x(self, x): 48 | """Set the x coordinate of the cursor.""" 49 | self.x = x 50 | self.persistent_x = x 51 | 52 | def set_y(self, y): 53 | """Set the y coordinate of the cursor.""" 54 | self.y = y 55 | 56 | def move_left(self, delta=1): 57 | """Move the cursor left by delta steps. 58 | 59 | :param int delta: How much to move. Defaults to 1. 60 | """ 61 | self.x -= delta 62 | if self.x < 0: 63 | self.x = 0 64 | self.persistent_x = self.x 65 | return 66 | 67 | def move_right(self, delta=1): 68 | """Move the cursor right by delta steps. 69 | 70 | :param int delta: How much to move. Defaults to 1. 71 | """ 72 | self.x += delta 73 | # Check in case of negative values 74 | if self.x < 0: 75 | self.x = 0 76 | self.persistent_x = self.x 77 | return 78 | 79 | def move_up(self, delta=1): 80 | """Move the cursor up by delta steps. 81 | 82 | :param int delta: How much to move. Defaults to 1. 83 | """ 84 | self.y -= delta 85 | if self.y < 0: 86 | self.y = 0 87 | return 88 | 89 | def move_down(self, delta=1): 90 | """Move the cursor down by delta steps. 91 | 92 | :param int delta: How much to move. Defaults to 1. 93 | """ 94 | self.y += delta 95 | return 96 | 97 | def __getitem__(self, i): 98 | # TODO: Deprecate in favor of proper access methods. 99 | """Get coordinates with list indices. 100 | 101 | :param i: 0 or 1 for x or y 102 | :return: x or y coordinate of cursor. 103 | :rtype: int 104 | """ 105 | if i == 0: 106 | return self.x 107 | elif i == 1: 108 | return self.y 109 | 110 | def __eq__(self, item): 111 | """Check cursor for equality.""" 112 | if isinstance(item, Cursor): 113 | if item.x == self.x and item.y == self.y: 114 | return True 115 | return False 116 | 117 | def __ne__(self, item): 118 | """Check cursor for unequality.""" 119 | if isinstance(item, Cursor): 120 | if item.x != self.x or item.y != self.x: 121 | return False 122 | 123 | def __str__(self): 124 | return "Cursor({x},{y})".format(x=self.x, y=self.y) 125 | 126 | def __repr__(self): 127 | return self.__str__() 128 | 129 | def tuple(self): 130 | """Return the cursor as a tuple. 131 | 132 | :return: Tuple with x and y coordinates of cursor. 133 | :rtype: tuple 134 | """ 135 | return (self.x, self.y) 136 | -------------------------------------------------------------------------------- /suplemon/editor.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Editor class for extending viewer with text editing features. 4 | """ 5 | 6 | 7 | from . import helpers 8 | 9 | from .line import Line 10 | from .cursor import Cursor 11 | from .viewer import Viewer 12 | 13 | 14 | class State: 15 | """Store editor state for undo/redo.""" 16 | def __init__(self, editor=None): 17 | self.cursors = [Cursor()] 18 | self.lines = [Line()] 19 | self.y_scroll = 0 20 | self.x_scroll = 0 21 | self.last_find = "" 22 | if editor is not None: 23 | self.store(editor) 24 | 25 | def store(self, editor): 26 | """Store the state of editor instance.""" 27 | self.cursors = [cursor.tuple() for cursor in editor.cursors] 28 | self.lines = [line.data for line in editor.lines] 29 | self.y_scroll = editor.y_scroll 30 | self.x_scroll = editor.x_scroll 31 | self.last_find = editor.last_find 32 | 33 | def restore(self, editor): 34 | """Restore stored state into the editor instance.""" 35 | editor.cursors = [Cursor(cursor) for cursor in self.cursors] 36 | editor.lines = [Line(line) for line in self.lines] 37 | editor.y_scroll = self.y_scroll 38 | editor.x_scroll = self.x_scroll 39 | editor.last_find = self.last_find 40 | 41 | 42 | class Editor(Viewer): 43 | """Extends Viewer with editing capabilities.""" 44 | def __init__(self, app, window): 45 | """Initialize the editor. 46 | 47 | Args: 48 | app: The Suplemon main instance. 49 | window: A window object to use for the ui. 50 | """ 51 | Viewer.__init__(self, app, window) 52 | 53 | # History of editor states for undo/redo 54 | self.history = [State()] 55 | # Current state index of the editor 56 | self.current_state = 0 57 | # Last editor action that was used (for undo/redo) 58 | self.last_action = None 59 | 60 | def init(self): 61 | Viewer.init(self) 62 | operations = { 63 | "backspace": self.backspace, # Backspace 64 | "delete": self.delete, # Delete 65 | "insert": self.insert, # Insert 66 | "enter": self.enter, # Enter 67 | "tab": self.tab, # Tab 68 | "untab": self.untab, # Shift + Tab 69 | "escape": self.escape, # Escape 70 | "single_selection": self.single_selection, # Escape 71 | "clear_last_find": self.clear_last_find, # Escape 72 | "new_cursor_up": self.new_cursor_up, # Alt + Up 73 | "new_cursor_down": self.new_cursor_down, # Alt + Down 74 | "new_cursor_left": self.new_cursor_left, # Alt + Left 75 | "new_cursor_right": self.new_cursor_right, # Alt + Right 76 | "page_up": self.page_up, # Page Up 77 | "page_down": self.page_down, # Page Down 78 | "push_up": self.push_up, # Alt + Page Up 79 | "push_down": self.push_down, # Alt + Page Down 80 | "undo": self.undo, # F5 81 | "redo": self.redo, # F6 82 | "toggle_line_nums": self.toggle_line_nums, # F9 83 | "toggle_line_ends": self.toggle_line_ends, # F10 84 | "toggle_highlight": self.toggle_highlight, # F11 85 | "copy": self.copy, # Ctrl + C 86 | "cut": self.cut, # Ctrl + X 87 | "duplicate_line": self.duplicate_line, # Ctrl + W 88 | } 89 | for key in operations.keys(): 90 | self.operations[key] = operations[key] 91 | 92 | def set_buffer(self, buffer): 93 | """Sets local or global buffer depending on config.""" 94 | if self.app.config["editor"]["use_global_buffer"]: 95 | self.app.global_buffer = buffer 96 | else: 97 | self.buffer = buffer 98 | 99 | def set_data(self, data): 100 | """Set the editor text contents.""" 101 | Viewer.set_data(self, data) 102 | buffer = self.get_buffer() # TODO: check this 103 | if len(buffer) > 1: 104 | self.store_state() 105 | else: 106 | state = State() 107 | state.store(self) 108 | self.history[0] = state 109 | 110 | def store_action_state(self, action, state=None): 111 | """Store the editor state if a new action is taken.""" 112 | if self.last_action != action: 113 | self.last_action = action 114 | self.store_state(state) 115 | else: 116 | # FIXME: This if is here just for safety. 117 | # FIXME: current_state might be wrong ;.< 118 | if self.current_state < len(self.history)-1: 119 | self.history[self.current_state].store(self) 120 | 121 | def store_state(self, state=None, action=None): 122 | """Store the current editor state for undo/redo.""" 123 | if state is None: 124 | state = State() 125 | state.store(self) 126 | if len(self.history) > 1: 127 | if self.current_state < len(self.history)-1: 128 | self.history = self.history[:self.current_state] 129 | 130 | self.history.append(state) 131 | self.current_state = len(self.history)-1 132 | 133 | if len(self.history) > self.config["max_history"]: 134 | self.history.pop(0) 135 | 136 | def restore_state(self, index=None): 137 | """Restore an editor state.""" 138 | if len(self.history) <= 1: 139 | return False 140 | if index is None: 141 | index = self.current_state-1 142 | 143 | if index < 0 or index >= len(self.history): 144 | return False 145 | 146 | # if self.current_state < len(self.history): 147 | # self.current_state = self.current_state-1 148 | 149 | state = self.history[index] 150 | state.restore(self) 151 | self.current_state = index 152 | 153 | def handle_input(self, event): 154 | done = Viewer.handle_input(self, event) 155 | if not done: 156 | if event.is_typeable: 157 | if isinstance(event.key_code, str): 158 | self.type(event.key_code) 159 | elif event.key_name: 160 | self.type(event.key_name) 161 | return True 162 | return False 163 | 164 | def undo(self): 165 | """Undo the last command or change.""" 166 | self.last_action = "undo" 167 | self.restore_state() 168 | 169 | def redo(self): 170 | """Redo the last command or change.""" 171 | self.last_action = "redo" 172 | if self.current_state == len(self.history)-1: 173 | return False 174 | index = self.current_state+1 175 | self.restore_state(index) 176 | 177 | # 178 | # Cursor operations 179 | # 180 | 181 | def new_cursor_up(self): 182 | """Add a new cursor one line up.""" 183 | x = self.get_cursor().x 184 | cursor = self.get_first_cursor() 185 | if cursor.y == 0: 186 | return 187 | new = Cursor(x, cursor.y-1) 188 | self.cursors.append(new) 189 | self.move_cursors() 190 | self.scroll_up() 191 | 192 | def new_cursor_down(self): 193 | """Add a new cursor one line down.""" 194 | x = self.get_cursor().x 195 | cursor = self.get_last_cursor() 196 | if cursor.y == len(self.lines)-1: 197 | return 198 | new = Cursor(x, cursor.y+1) 199 | self.cursors.append(new) 200 | self.move_cursors() 201 | self.scroll_down() 202 | 203 | def new_cursor_left(self): 204 | """Add a new cursor one character left.""" 205 | new = [] 206 | for cursor in self.cursors: 207 | if cursor.x == 0: 208 | continue 209 | new.append(Cursor(cursor.x-1, cursor.y)) 210 | for c in new: 211 | self.cursors.append(c) 212 | self.move_cursors() 213 | self.scroll_up() 214 | 215 | def new_cursor_right(self): 216 | """Add a new cursor one character right.""" 217 | new = [] 218 | for cursor in self.cursors: 219 | if cursor.x+1 > len(self.lines[cursor.y]): 220 | continue 221 | new.append(Cursor(cursor.x+1, cursor.y)) 222 | for c in new: 223 | self.cursors.append(c) 224 | self.move_cursors() 225 | self.scroll_down() 226 | 227 | def escape(self): 228 | """Handle escape key. 229 | 230 | Wrapper for clear_last_find and single_selection.""" 231 | self.clear_last_find() 232 | self.single_selection() 233 | 234 | def clear_last_find(self): 235 | """Removes last_find so a new auto-find can be initiated.""" 236 | self.last_find = "" 237 | 238 | def single_selection(self): 239 | """Removes all cursors except primary cursor.""" 240 | self.cursors = [self.cursors[0]] 241 | self.move_cursors() 242 | 243 | # 244 | # Text editing operations 245 | # 246 | 247 | def replace_all(self, what, replacement): 248 | """Replaces what with replacement on each line.""" 249 | for line in self.lines: 250 | data = line.get_data() 251 | new = data.replace(what, replacement) 252 | line.set_data(new) 253 | self.move_cursors() 254 | 255 | def delete(self): 256 | """Delete the next character.""" 257 | for cursor in self.cursors: 258 | if len(self.lines)-1 < cursor.y: 259 | # If we've run out of lines 260 | break 261 | line = self.lines[cursor.y] 262 | # if we have more than 1 line 263 | # and we're at the end of the current line 264 | # and we're not on the last line 265 | if len(self.lines) > 1 and cursor.x == len(line) and cursor.y != len(self.lines) - 1: 266 | data = self.lines[cursor.y].get_data() 267 | self.lines.pop(cursor.y) 268 | self.lines[cursor.y].set_data(data+self.lines[cursor.y]) 269 | # Reposition cursors from line below into correct positions on current line 270 | line_cursors = self.get_cursors_on_line(cursor.y+1) 271 | for c in line_cursors: 272 | c.move_right(len(data)) 273 | c.move_up() 274 | self.move_y_cursors(cursor.y, -1) 275 | else: 276 | start = line[:cursor.x] 277 | end = line[cursor.x+1:] 278 | self.lines[cursor.y].set_data(start+end) 279 | self.move_x_cursors(cursor.y, cursor.x, -1) 280 | self.move_cursors() 281 | # Add a restore point if previous action != delete 282 | self.store_action_state("delete") 283 | 284 | def backspace(self): 285 | """Delete the previous character.""" 286 | curs = reversed(sorted(self.cursors, key=lambda c: (c[1], c[0]))) 287 | # Iterate through all cursors from bottom to top 288 | for cursor in curs: 289 | line_no = cursor.y 290 | # If we're at the beginning of file don't do anything 291 | if cursor.x == 0 and cursor.y == 0: 292 | continue 293 | # If were operating at the beginning of a line 294 | if cursor.x == 0 and cursor.y != 0: 295 | curr_line = self.lines.pop(line_no) 296 | prev_line = self.lines[line_no-1] 297 | length = len(prev_line) # Get the length of previous line 298 | 299 | # Add the current line to the previous one 300 | new_data = self.lines[cursor.y-1] + curr_line 301 | self.lines[cursor.y-1].set_data(new_data) 302 | 303 | # Get all cursors on current line 304 | line_cursors = self.get_cursors_on_line(line_no) 305 | 306 | for line_cursor in line_cursors: # Move the cursors 307 | line_cursor.move_up() 308 | # Add the length of previous line to each x coordinate 309 | # so that their relative positions 310 | line_cursor.move_right(length) 311 | # Move all cursors below up one line 312 | # (since a line was removed above them) 313 | self.move_y_cursors(cursor.y, -1) 314 | # Handle all other cases 315 | else: 316 | curr_line = self.lines[line_no] 317 | # Remove one character by default 318 | del_n_chars = 1 319 | # Check if we should unindent 320 | if self.config["backspace_unindent"]: 321 | # Check if we can unindent, and that it's actually whitespace 322 | # We don't do this for hard tabs since they're just a single character 323 | if not self.config["hard_tabs"]: 324 | indent = self.config["tab_width"] 325 | if cursor.x >= indent: 326 | if curr_line[cursor.x-indent:cursor.x] == indent*" ": 327 | # Remove an indents worth of whitespace 328 | del_n_chars = indent 329 | # Slice characters out of the line 330 | start = curr_line[:cursor.x-del_n_chars] 331 | end = curr_line[cursor.x:] 332 | # Store the new line 333 | self.lines[line_no].set_data(start+end) 334 | # Move the operating curser back the deleted amount 335 | cursor.move_left(del_n_chars) 336 | # Do the same to the rest 337 | self.move_x_cursors(line_no, cursor.x, -1*del_n_chars) 338 | # Ensure we keep the view scrolled 339 | self.move_cursors() 340 | self.scroll_up() 341 | # Add a restore point if previous action != backspace 342 | self.store_action_state("backspace") 343 | 344 | def enter(self): 345 | """Insert a new line at each cursor.""" 346 | # We sort the cursors, and loop through them from last to first 347 | # That way we avoid messing with 348 | # the relative positions of the higher cursors 349 | curs = sorted(self.cursors, key=lambda c: (c[1], c[0])) 350 | curs = reversed(curs) 351 | for cursor in curs: 352 | # The current line this cursor is on 353 | line = self.lines[cursor.y] 354 | 355 | # Start of the line 356 | start = line[:cursor.x] 357 | 358 | # End of the line 359 | end = line[cursor.x:] 360 | 361 | # Leave the beginning of the line 362 | self.lines[cursor.y].set_data(start) 363 | wspace = "" 364 | if self.config["auto_indent_newline"]: 365 | wspace = helpers.whitespace(self.lines[cursor.y])*" " 366 | self.lines.insert(cursor.y+1, Line(wspace+end)) 367 | self.move_y_cursors(cursor.y, 1) 368 | cursor.set_x(len(wspace)) 369 | cursor.move_down() 370 | self.move_cursors() 371 | self.scroll_down() 372 | # Add a restore point if previous action != enter 373 | self.store_action_state("enter") 374 | 375 | def insert(self): 376 | """Insert buffer data at cursor(s).""" 377 | cur = self.get_cursor() 378 | buffer = list(self.get_buffer()) 379 | 380 | # If we have more than one cursor 381 | # Or one cursor and one line 382 | if len(self.cursors) > 1 or len(buffer) == 1: 383 | # If the cursor count is more than the buffer length extend 384 | # the buffer until it's at least as long as the cursor count 385 | while len(buffer) < len(self.cursors): 386 | buffer.extend(buffer) 387 | curs = sorted(self.cursors, key=lambda c: (c[1], c[0])) 388 | for cursor in curs: 389 | line = self.lines[cursor.y] 390 | buf = buffer[0] 391 | line = line[:cursor.x] + buf + line[cursor.x:] 392 | self.lines[cursor.y].set_data(line) 393 | buffer.pop(0) 394 | self.move_x_cursors(cursor.y, cursor.x-1, len(buf)) 395 | # If we have one cursor and multiple lines 396 | else: 397 | for buf in buffer: 398 | y = cur[1] 399 | if y < 0: 400 | y = 0 401 | self.lines.insert(y, Line(buf)) 402 | self.move_y_cursors(cur[1]-1, 1) 403 | self.move_cursors() 404 | self.scroll_down() 405 | # Add a restore point if previous action != insert 406 | self.store_action_state("insert") 407 | 408 | def insert_lines_at(self, lines, at): 409 | rev_lines = reversed(lines) 410 | for line in rev_lines: 411 | self.lines.insert(at, Line(line)) 412 | self.move_y_cursors(at, len(lines)) 413 | 414 | def push_up(self): 415 | """Move current lines up by one line.""" 416 | used_y = [] 417 | curs = sorted(self.cursors, key=lambda c: (c[1], c[0])) 418 | for cursor in curs: 419 | if cursor.y in used_y: 420 | continue 421 | used_y.append(cursor.y) 422 | if cursor.y == 0: 423 | break 424 | old = self.lines[cursor.y-1] 425 | self.lines[cursor.y-1] = self.lines[cursor.y] 426 | self.lines[cursor.y] = old 427 | self.move_cursors((0, -1)) 428 | self.scroll_up() 429 | # Add a restore point if previous action != push_up 430 | self.store_action_state("push_up") 431 | 432 | def push_down(self): 433 | """Move current lines down by one line.""" 434 | used_y = [] 435 | curs = reversed(sorted(self.cursors, key=lambda c: (c[1], c[0]))) 436 | for cursor in curs: 437 | if cursor.y in used_y: 438 | continue 439 | if cursor.y >= len(self.lines)-1: 440 | break 441 | used_y.append(cursor.y) 442 | old = self.lines[cursor.y+1] 443 | self.lines[cursor.y+1] = self.lines[cursor.y] 444 | self.lines[cursor.y] = old 445 | 446 | self.move_cursors((0, 1)) 447 | self.scroll_down() 448 | # Add a restore point if previous action != push_down 449 | self.store_action_state("push_down") 450 | 451 | def tab(self): 452 | """Indent lines.""" 453 | # Add a restore point if previous action != tab 454 | self.store_action_state("tab") 455 | if not self.config["hard_tabs"]: 456 | self.type(" "*self.config["tab_width"]) 457 | else: 458 | self.type("\t") 459 | 460 | def untab(self): 461 | """Unindent lines.""" 462 | linenums = [] 463 | # String to compare tabs to 464 | tab = " "*self.config["tab_width"] 465 | if self.config["hard_tabs"]: 466 | tab = "\t" 467 | width = len(tab) 468 | for cursor in self.cursors: 469 | line = self.lines[cursor.y] 470 | if cursor.y in linenums: 471 | cursor.x = helpers.whitespace(line) 472 | continue 473 | elif line[:width] == tab: 474 | line = Line(line[width:]) 475 | self.lines[cursor.y] = line 476 | cursor.x = helpers.whitespace(line) 477 | linenums.append(cursor.y) 478 | # Add a restore point if previous action != untab 479 | self.store_action_state("untab") 480 | 481 | def copy(self): 482 | """Copy lines to buffer.""" 483 | # Store cut lines in buffer 484 | copy_buffer = [] 485 | # Get all lines with cursors on them 486 | line_nums = self.get_lines_with_cursors() 487 | 488 | for i in range(len(line_nums)): 489 | # Get the line 490 | line = self.lines[line_nums[i]] 491 | # Put it in our temporary buffer 492 | copy_buffer.append(line.get_data()) 493 | self.set_buffer(copy_buffer) 494 | self.store_action_state("copy") 495 | 496 | def cut(self): 497 | """Cut lines to buffer.""" 498 | # Store cut lines in buffer 499 | cut_buffer = [] 500 | # Get all lines with cursors on them 501 | line_nums = self.get_lines_with_cursors() 502 | # Sort from last to first (invert order) 503 | line_nums = line_nums[::-1] 504 | for i in range(len(line_nums)): # Iterate from last to first 505 | # Make sure we don't completely remove the last line 506 | if len(self.lines) == 1: 507 | cut_buffer.append(self.lines[0]) 508 | self.lines[0] = Line() 509 | break 510 | # Get the current line 511 | line_no = line_nums[i] 512 | # Get and remove the line 513 | line = self.lines.pop(line_no) 514 | # Put it in our temporary buffer 515 | cut_buffer.append(line) 516 | # Move all cursors below the current line up 517 | self.move_y_cursors(line_no, -1) 518 | self.move_cursors() # Make sure cursors are in valid places 519 | # Reverse the buffer to get correct order and store it 520 | self.set_buffer(cut_buffer[::-1]) 521 | self.store_action_state("cut") 522 | 523 | def type(self, data): 524 | """Insert data at each cursor position.""" 525 | for cursor in self.cursors: 526 | self.type_at_cursor(cursor, data) 527 | self.move_cursors() 528 | # Add a restore point if previous action != type 529 | self.store_action_state("type") 530 | 531 | def type_at_cursor(self, cursor, data): 532 | """Insert data at specified cursor.""" 533 | line = self.lines[cursor.y] 534 | start = line[:cursor.x] 535 | end = line[cursor.x:] 536 | self.lines[cursor.y].set_data(start + data + end) 537 | self.move_x_cursors(cursor.y, cursor.x, len(data)) 538 | cursor.move_right(len(data)) 539 | 540 | def go_to_pos(self, line_no, col=0): 541 | """Move primary cursor to line_no, col=0.""" 542 | if line_no < 0: 543 | line_no = len(self.lines)-1 544 | else: 545 | line_no = line_no-1 546 | 547 | self.store_state() 548 | cur = self.get_cursor() 549 | if col is not None: 550 | cur.x = col 551 | cur.y = line_no 552 | if cur.y >= len(self.lines): 553 | cur.y = len(self.lines)-1 554 | self.scroll_to_line(cur.y) 555 | self.move_cursors() 556 | 557 | def duplicate_line(self): 558 | """Copy current line and add it below as a new line.""" 559 | curs = sorted(self.cursors, key=lambda c: (c.y, c.x)) 560 | for cursor in curs: 561 | line = Line(self.lines[cursor.y]) 562 | self.lines.insert(cursor.y+1, line) 563 | self.move_y_cursors(cursor.y, 1) 564 | self.move_cursors() 565 | self.store_action_state("duplicate_line") 566 | -------------------------------------------------------------------------------- /suplemon/file.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | File object for storing an opened file and editor. 4 | """ 5 | 6 | import os 7 | import time 8 | import logging 9 | 10 | from .helpers import parse_path 11 | 12 | 13 | class File: 14 | def __init__(self, app=None): 15 | self.app = app 16 | self.logger = logging.getLogger(__name__) 17 | self.name = "" 18 | self.fpath = "" 19 | self.data = None 20 | self.read_only = False # Currently unused 21 | self.last_save = None # Time of last save 22 | self.opened = time.time() # Time of last open 23 | self.editor = None 24 | self.writable = True 25 | self.is_help = False 26 | 27 | def _path(self): 28 | """Get the full path of the file.""" 29 | return os.path.join(self.fpath, self.name) 30 | 31 | def path(self): 32 | """Get the full path of the file.""" 33 | # TODO: deprecate in favour of get_path() 34 | return self._path() 35 | 36 | def get_name(self): 37 | """Get the file name.""" 38 | return self.name 39 | 40 | def get_path(self): 41 | """Get the full path of the file.""" 42 | return self._path() 43 | 44 | def get_extension(self): 45 | parts = self.name.split(".") 46 | if len(parts) < 2: 47 | return "" 48 | return parts[-1] 49 | 50 | def get_editor(self): 51 | """Get the associated editor.""" 52 | return self.editor 53 | 54 | def set_name(self, name): 55 | """Set the file name.""" 56 | # TODO: sanitize 57 | self.name = name 58 | self.update_editor_extension() 59 | 60 | def set_path(self, path): 61 | """Set the file path. Relative paths are sanitized.""" 62 | self.fpath, self.name = parse_path(path) 63 | self.update_editor_extension() 64 | 65 | def set_data(self, data): 66 | """Set the file data and apply to editor if it exists.""" 67 | self.data = data 68 | if self.editor: 69 | self.editor.set_data(data) 70 | 71 | def set_editor(self, editor): 72 | """The editor instance set its file extension.""" 73 | self.editor = editor 74 | self.update_editor_extension() 75 | 76 | def on_load(self): 77 | """Does checks after file is loaded.""" 78 | self.writable = os.access(self._path(), os.W_OK) 79 | if not self.writable: 80 | self.logger.info("File not writable.") 81 | 82 | def update_editor_extension(self): 83 | """Set the editor file extension from the current file name.""" 84 | if not self.editor: 85 | return False 86 | ext = self.get_extension() 87 | if len(ext) >= 1: 88 | self.editor.set_file_extension(ext) 89 | 90 | def save(self): 91 | """Write the editor data to file.""" 92 | data = self.editor.get_data() 93 | try: 94 | f = open(self._path(), "w") 95 | f.write(data) 96 | f.close() 97 | except: 98 | return False 99 | self.data = data 100 | self.last_save = time.time() 101 | self.writable = os.access(self._path(), os.W_OK) 102 | return True 103 | 104 | def load(self, read=True): 105 | """Try to read the actual file and load the data into the editor instance.""" 106 | if not read: 107 | return True 108 | path = self._path() 109 | if not os.path.isfile(path): 110 | self.logger.debug("Given path isn't a file.") 111 | return False 112 | data = self._read(path) 113 | if data is False: 114 | return False 115 | self.data = data 116 | self.editor.set_data(data) 117 | self.on_load() 118 | return True 119 | 120 | def _read(self, path): 121 | data = self._read_text(path) 122 | if data is False: 123 | self.logger.warning("Normal file read failed.") 124 | data = self._read_binary(path) 125 | if data is False: 126 | self.logger.warning("Fallback file read failed.") 127 | return False 128 | return data 129 | 130 | def _read_text(self, file): 131 | # Read text file 132 | try: 133 | f = open(self._path()) 134 | data = f.read() 135 | f.close() 136 | return data 137 | except: 138 | self.logger.exception("Failed reading file \"{file}\"".format(file=file)) 139 | return False 140 | 141 | def _read_binary(self, file): 142 | # Read binary file and try to autodetect encoding 143 | try: 144 | f = open(self._path(), "rb") 145 | data = f.read() 146 | f.close() 147 | import chardet 148 | detection = chardet.detect(data) 149 | charenc = detection["encoding"] 150 | if charenc is None: 151 | self.logger.warning("Failed to detect file encoding.") 152 | return False 153 | self.logger.info("Trying to decode with encoding '{0}'".format(charenc)) 154 | return data.decode(charenc) 155 | except: 156 | self.logger.warning("Failed reading binary file!", exc_info=True) 157 | return False 158 | 159 | def reload(self): 160 | """Reload file data.""" 161 | return self.load() 162 | 163 | def is_changed(self): 164 | """Check if the editor data is different from the file.""" 165 | return self.editor.get_data() != self.data 166 | 167 | def is_changed_on_disk(self): 168 | path = self._path() 169 | if os.path.isfile(path): 170 | data = self._read(path) 171 | if data != self.data: 172 | return True 173 | return False 174 | 175 | def is_writable(self): 176 | """Check if the file is writable.""" 177 | return self.writable 178 | -------------------------------------------------------------------------------- /suplemon/help.py: -------------------------------------------------------------------------------- 1 | """ 2 | Help text for Suplemon 3 | """ 4 | 5 | help_text = """ 6 | # Suplemon Help 7 | 8 | *Contents* 9 | 1. General description 10 | 2. User interface 11 | 3. Default keyboard shortcuts 12 | 4. Commands 13 | 14 | ## 1. General description 15 | Suplemon is designed to be an easy, intuitive and powerful text editor. 16 | It emulates some features from Sublime Text and the user interface of Nano. 17 | Multi cursor editing is a core feature. Suplemon also supports extensions 18 | so you can customize it to work how you want. 19 | 20 | ## 2. User interface 21 | The user interface is designed to be as intuitive and informative as possible. 22 | There are two status bars, one at the top and one at the bottom. The top bar 23 | shows the program version, a clock, and a list of opened files. The bottom bar 24 | shows status messages and handles input for commands. Above the bottom status 25 | bar there is a list of most common keyboard shortcuts. 26 | 27 | ## 3. Default keyboard shortcuts 28 | The default keyboard shortcuts imitate those of common graphical editors. 29 | Most shortcuts are also shown at the bottom in the legend area. Here's 30 | the complete reference. 31 | 32 | 33 | * Ctrl + Q 34 | > Exit 35 | 36 | * Ctrl + W 37 | > Close file or tab 38 | 39 | * Ctrl + C 40 | > Copy line(s) to buffer 41 | 42 | * Ctrl + X 43 | > Cut line(s) to buffer 44 | 45 | * Ctrl + V 46 | > Insert buffer 47 | 48 | * Ctrl + K 49 | > Duplicate line 50 | 51 | * Ctrl + G 52 | > Go to line number or file (type the beginning of a filename to switch to it). 53 | > You can also use 'filena:42' to go to line 42 in filename.py etc. 54 | 55 | * Ctrl + F 56 | > Search for a string or regular expression (configurable) 57 | 58 | * Ctrl + D 59 | > Search for next occurance or find the word the cursor is on. Adds a new cursor at each new occurance. 60 | 61 | * Ctrl + T 62 | > Trim whitespace 63 | 64 | * Alt + Arrow Key 65 | > Add new curor in arrow direction 66 | 67 | * Ctrl + Left / Right 68 | > Jump to previous or next word or line 69 | 70 | * ESC 71 | > Revert to a single cursor / Cancel input prompt 72 | 73 | * Alt + Page Up 74 | > Move line(s) up 75 | 76 | * Alt + Page Down 77 | > Move line(s) down 78 | 79 | * Ctrl + S 80 | > Save current file 81 | 82 | * F1 83 | > Save file with new name 84 | 85 | * F2 86 | > Reload current file 87 | 88 | * Ctrl + O 89 | > Open file 90 | 91 | * Ctrl + W 92 | > Close file 93 | 94 | * Ctrl + Page Up 95 | > Switch to next file 96 | 97 | * Ctrl + Page Down 98 | > Switch to previous file 99 | 100 | * Ctrl + E 101 | > Run a command. 102 | 103 | * F5 104 | > Undo 105 | 106 | * F6 107 | > Redo 108 | 109 | * F7 110 | > Toggle visible whitespace 111 | 112 | * F8 113 | > Toggle mouse mode 114 | 115 | * F9 116 | > Toggle line numbers 117 | 118 | * F11 119 | > Toggle full screen 120 | 121 | ### Mouse shortcuts 122 | 123 | * Left Click 124 | > Set cursor at mouse position. Reverts to a single cursor. 125 | 126 | * Right Click 127 | > Add a cursor at mouse position. 128 | 129 | * Scroll Wheel Up / Down 130 | > Scroll up & down. 131 | 132 | 133 | ## 4. Commands 134 | Commands are special operations that can be performed (e.g. remove whitespace 135 | or convert line to uppercase). Each command can be run by pressing Ctrl + E 136 | and then typing the command name. Commands are extensions and are stored in 137 | the modules folder in the Suplemon installation. 138 | 139 | * autocomplete 140 | 141 | A simple autocompletion module. 142 | 143 | This adds autocomplete support for the tab key. It uses a word 144 | list scanned from all open files for completions. By default it suggests 145 | the shortest possible match. If there are no matches, the tab action is 146 | run normally. 147 | 148 | * autodocstring 149 | 150 | Simple module for adding docstring placeholders. 151 | 152 | This module is intended to generate docstrings for Python functions. 153 | It adds placeholders for descriptions, arguments and return data. 154 | Function arguments are crudely parsed from the function definition 155 | and return statements are scanned from the function body. 156 | 157 | * bulk_delete 158 | 159 | Bulk delete lines and characters. 160 | Asks what direction to delete in by default. 161 | 162 | Add 'up' to delete lines above highest cursor. 163 | Add 'down' to delete lines below lowest cursor. 164 | Add 'left' to delete characters to the left of all cursors. 165 | Add 'right' to delete characters to the right of all cursors. 166 | 167 | * comment 168 | 169 | Toggle line commenting based on current file syntax. 170 | 171 | * config 172 | 173 | Shortcut for openning the config files. 174 | 175 | * crypt 176 | 177 | Encrypt or decrypt the current buffer. Lets you provide a passphrase and optional salt for encryption. 178 | Uses AES for encryption and scrypt for key generation. 179 | 180 | * diff 181 | 182 | View a diff of the current file compared to it's on disk version. 183 | 184 | * eval 185 | 186 | Evaluate a python expression and show the result in the status bar. 187 | 188 | If no expression is provided the current line(s) are evaluated and 189 | replaced with the evaluation result. 190 | 191 | * keymap 192 | 193 | Shortcut to openning the keymap config file. 194 | 195 | * linter 196 | 197 | Linter for suplemon. 198 | 199 | * lower 200 | 201 | Transform current lines to lower case. 202 | 203 | * lstrip 204 | 205 | Trim whitespace from beginning of current lines. 206 | 207 | * paste 208 | 209 | Toggle paste mode (helpful when pasting over SSH if auto indent is enabled) 210 | 211 | * reload 212 | 213 | Reload all add-on modules. 214 | 215 | * replace_all 216 | 217 | Replace all occurrences in all files of given text with given replacement. 218 | 219 | * reverse 220 | 221 | Reverse text on current line(s). 222 | 223 | * rstrip 224 | 225 | Trim whitespace from the end of lines. 226 | 227 | * save 228 | 229 | Save the current file. 230 | 231 | * save_all 232 | 233 | Save all currently open files. Asks for confirmation. 234 | 235 | * sort_lines 236 | 237 | Sort current lines. 238 | 239 | Sorts alphabetically by default. 240 | Add 'length' to sort by length. 241 | Add 'reverse' to reverse the sorting. 242 | 243 | * strip 244 | 245 | Trim whitespace from start and end of lines. 246 | 247 | * tabstospaces 248 | 249 | Convert tab characters to spaces in the entire file. 250 | 251 | * toggle_whitespace 252 | 253 | Toggle visually showing whitespace. 254 | 255 | * upper 256 | 257 | Transform current lines to upper case. 258 | 259 | 260 | """ 261 | -------------------------------------------------------------------------------- /suplemon/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Various helper constants and functions. 4 | """ 5 | 6 | import os 7 | import re 8 | import sys 9 | import time 10 | import traceback 11 | 12 | 13 | def curr_time(): 14 | """Current time in %H:%M""" 15 | return time.strftime("%H:%M") 16 | 17 | 18 | def curr_time_sec(): 19 | """Current time in %H:%M:%S""" 20 | return time.strftime("%H:%M:%S") 21 | 22 | 23 | def multisplit(data, delimiters): 24 | pattern = "|".join(map(re.escape, delimiters)) 25 | return re.split(pattern, data) 26 | 27 | 28 | def get_error_info(): 29 | """Return info about last error.""" 30 | msg = "{0}\n{1}".format(str(traceback.format_exc()), str(sys.exc_info())) 31 | return msg 32 | 33 | 34 | def get_string_between(start, stop, s): 35 | """Search string for a substring between two delimeters. False if not found.""" 36 | i1 = s.find(start) 37 | if i1 == -1: 38 | return False 39 | s = s[i1 + len(start):] 40 | i2 = s.find(stop) 41 | if i2 == -1: 42 | return False 43 | s = s[:i2] 44 | return s 45 | 46 | 47 | def whitespace(line): 48 | """Return index of first non whitespace character on a line.""" 49 | i = 0 50 | for char in line: 51 | if char != " ": 52 | break 53 | i += 1 54 | return i 55 | 56 | 57 | def parse_path(path): 58 | """Parse a relative path and return full directory and filename as a tuple.""" 59 | if path[:2] == "~" + os.sep: 60 | p = os.path.expanduser("~") 61 | path = os.path.join(p+os.sep, path[2:]) 62 | ab = os.path.abspath(path) 63 | parts = os.path.split(ab) 64 | return parts 65 | 66 | 67 | def get_filename_cursor_pos(name): 68 | default = { 69 | "name": name, 70 | "row": 0, 71 | "col": 0, 72 | } 73 | 74 | m = re.match(r"(.*?):(\d+):?(\d+)?", name) 75 | 76 | if not m: 77 | return default 78 | 79 | groups = m.groups() 80 | if not groups[0]: 81 | return default 82 | 83 | return { 84 | "name": groups[0], 85 | "row": abs(int(groups[1])-1) if groups[1] else 0, 86 | "col": abs(int(groups[2])-1) if groups[2] else 0, 87 | } 88 | -------------------------------------------------------------------------------- /suplemon/hex2xterm.py: -------------------------------------------------------------------------------- 1 | 2 | # Default color levels for the color cube 3 | cubelevels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] 4 | # Generate a list of midpoints of the above list 5 | snaps = [(x+y)/2 for x, y in list(zip(cubelevels, [0]+cubelevels))[1:]] 6 | 7 | 8 | def hex_to_rgb(value): 9 | value = value.lstrip("#") 10 | lv = len(value) 11 | return tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3)) 12 | 13 | 14 | def hex_to_xterm(hex): 15 | """ 16 | Converts hex color values to the nearest equivalent xterm-256 color. 17 | """ 18 | rgb = hex_to_rgb(hex) 19 | r, g, b = rgb 20 | # Using list of snap points, convert RGB value to cube indexes 21 | r, g, b = map(lambda x: len(tuple(s for s in snaps if s < x)), (r, g, b)) 22 | # Simple colorcube transform 23 | return r*36 + g*6 + b + 16 24 | -------------------------------------------------------------------------------- /suplemon/key_mappings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | This file maps ugly curses keycodes to human readable versions. 4 | """ 5 | 6 | import curses 7 | 8 | 9 | key_map = { 10 | # Single keys 11 | curses.KEY_F1: "f1", # 265 12 | curses.KEY_F2: "f2", # 266 13 | curses.KEY_F3: "f3", # 267 14 | curses.KEY_F4: "f4", # 268 15 | curses.KEY_F5: "f5", # 269 16 | curses.KEY_F6: "f6", # 270 17 | curses.KEY_F7: "f7", # 271 18 | curses.KEY_F8: "f8", # 272 19 | curses.KEY_F9: "f9", # 273 20 | curses.KEY_F10: "f10", # 274 21 | curses.KEY_F11: "f11", # 275 22 | curses.KEY_F12: "f12", # 276 23 | "KEY_F(1)": "f1", 24 | "KEY_F(2)": "f2", 25 | "KEY_F(3)": "f3", 26 | "KEY_F(4)": "f4", 27 | "KEY_F(5)": "f5", 28 | "KEY_F(6)": "f6", 29 | "KEY_F(7)": "f7", 30 | "KEY_F(8)": "f8", 31 | "KEY_F(9)": "f9", 32 | "KEY_F(10)": "f10", 33 | "KEY_F(11)": "f11", 34 | "KEY_F(12)": "f12", 35 | 36 | 37 | curses.KEY_UP: "up", 38 | curses.KEY_DOWN: "down", 39 | curses.KEY_LEFT: "left", 40 | curses.KEY_RIGHT: "right", 41 | "KEY_UP": "up", 42 | "KEY_DOWN": "down", 43 | "KEY_LEFT": "left", 44 | "KEY_RIGHT": "right", 45 | 46 | curses.KEY_ENTER: "enter", 47 | "KEY_ENTER": "enter", 48 | "\n": "enter", 49 | "^J": "enter", 50 | 343: "shift+enter", 51 | 52 | curses.KEY_BACKSPACE: "backspace", 53 | "KEY_BACKSPACE": "backspace", 54 | "^?": "backspace", 55 | 56 | curses.KEY_DC: "delete", 57 | curses.KEY_HOME: "home", 58 | curses.KEY_END: "end", 59 | curses.KEY_PPAGE: "pageup", 60 | curses.KEY_NPAGE: "pagedown", 61 | "KEY_DC": "delete", 62 | "KEY_HOME": "home", 63 | "KEY_END": "end", 64 | "KEY_PPAGE": "pageup", 65 | "KEY_NPAGE": "pagedown", 66 | 67 | curses.KEY_IC: "insert", 68 | "KEY_IC": "insert", 69 | 331: "insert", 70 | "\t": "tab", 71 | "^I": "tab", 72 | "^[": "escape", 73 | 74 | 75 | # Control 76 | "^A": "ctrl+a", 77 | "^B": "ctrl+b", 78 | "^C": "ctrl+c", 79 | "^D": "ctrl+d", 80 | "^E": "ctrl+e", 81 | "^F": "ctrl+f", 82 | "^G": "ctrl+g", 83 | "^H": "ctrl+h", 84 | # "^I": "ctrl+i", # Conflicts with 'tab' 85 | # "^J": "ctrl+j", # Conflicts with 'enter' 86 | "^K": "ctrl+k", 87 | "^L": "ctrl+l", 88 | # "^M": "ctrl+m", # Conflicts with 'enter' 89 | "^N": "ctrl+n", 90 | "^O": "ctrl+o", 91 | "^P": "ctrl+p", 92 | "^Q": "ctrl+q", 93 | "^R": "ctrl+r", 94 | "^S": "ctrl+s", 95 | "^T": "ctrl+t", 96 | "^U": "ctrl+u", 97 | "^V": "ctrl+v", 98 | "^W": "ctrl+w", 99 | "^X": "ctrl+x", 100 | "^Y": "ctrl+y", 101 | "^Z": "ctrl+z", # Conflicts with suspend 102 | 103 | 544: "ctrl+left", 104 | 559: "ctrl+right", 105 | 565: "ctrl+up", 106 | 524: "ctrl+down", 107 | "kLFT5": "ctrl+left", 108 | "kRIT5": "ctrl+right", 109 | "kUP5": "ctrl+up", 110 | "kDN5": "ctrl+down", 111 | 112 | "kIC5": "ctrl+insert", 113 | "kDC5": "ctrl+delete", 114 | "kHOM5": "ctrl+home", 115 | "kEND5": "ctrl+end", 116 | 117 | 554: "ctrl+pageup", 118 | 549: "ctrl+pagedown", 119 | "kNXT5": "ctrl+pageup", 120 | "kPRV5": "ctrl+pagedown", 121 | 122 | "O5P": "ctrl+f1", 123 | "O5Q": "ctrl+f2", 124 | "O5R": "ctrl+f3", 125 | "O5S": "ctrl+f4", 126 | 127 | "KEY_F(29)": "ctrl+f5", 128 | "KEY_F(30)": "ctrl+f6", 129 | "KEY_F(31)": "ctrl+f7", 130 | "KEY_F(32)": "ctrl+f8", 131 | "KEY_F(33)": "ctrl+f9", 132 | "KEY_F(34)": "ctrl+f10", 133 | "KEY_F(35)": "ctrl+f11", 134 | "KEY_F(36)": "ctrl+f12", 135 | 136 | # Alt 137 | 563: "alt+up", 138 | 522: "alt+down", 139 | 542: "alt+left", 140 | 557: "alt+right", 141 | "kUP3": "alt+up", 142 | "kDN3": "alt+down", 143 | "kLFT3": "alt+left", 144 | "kRIT3": "alt+right", 145 | 146 | "kIC3": "alt+insert", 147 | "kDC3": "alt+delete", 148 | "kHOM3": "alt+home", 149 | "kEND3": "alt+end", 150 | 151 | 552: "alt+pageup", 152 | 547: "alt+pagedown", 153 | "kPRV3": "alt+pageup", 154 | "kNXT3": "alt+pagedown", 155 | 156 | "KEY_F(53)": "alt+f5", 157 | "KEY_F(54)": "alt+f6", 158 | "KEY_F(55)": "alt+f7", 159 | "KEY_F(56)": "alt+f8", 160 | "KEY_F(57)": "alt+f9", 161 | "KEY_F(58)": "alt+f10", 162 | "KEY_F(59)": "alt+f11", 163 | "KEY_F(60)": "alt+f12", 164 | 165 | # Shift 166 | curses.KEY_SLEFT: "shift+left", 167 | curses.KEY_SRIGHT: "shift+right", 168 | "KEY_SLEFT": "shift+left", 169 | "KEY_SRIGHT": "shift+right", 170 | "KEY_SR": "shift+up", 171 | "KEY_SF": "shift+down", 172 | 173 | 353: "shift+tab", 174 | "KEY_BTAB": "shift+tab", 175 | 176 | "KEY_SDC": "shift+delete", 177 | "KEY_SHOME": "shift+home", 178 | "KEY_SEND": "shift+end", 179 | 180 | "KEY_SPREVIOUS": "shift+pageup", 181 | "KEY_SNEXT": "shift+pagedown", 182 | 183 | "O2P": "shift+f1", 184 | "O2Q": "shift+f2", 185 | "O2R": "shift+f3", 186 | "O2S": "shift+f4", 187 | 188 | "KEY_F(17)": "shift+f5", 189 | "KEY_F(18)": "shift+f6", 190 | "KEY_F(19)": "shift+f7", 191 | "KEY_F(20)": "shift+f8", 192 | "KEY_F(21)": "shift+f9", 193 | "KEY_F(22)": "shift+f10", 194 | "KEY_F(23)": "shift+f11", 195 | "KEY_F(24)": "shift+f12", 196 | 197 | 198 | # Alt Gr 199 | "O1P": "altgr+f1", 200 | "O1Q": "altgr+f2", 201 | "O1R": "altgr+f3", 202 | "O1S": "altgr+f4", 203 | 204 | 205 | # Shift + Alt 206 | "kUP4": "shift+alt+up", 207 | "kDN4": "shift+alt+down", 208 | "kLFT4": "shift+alt+left", 209 | "kRIT4": "shift+alt+right", 210 | 211 | "kIC4": "shift+alt+inset", 212 | "kDC4": "shift+alt+delete", 213 | "kHOM4": "shift+alt+home", 214 | "kEND4": "shift+alt+end", 215 | 216 | 217 | # Control + Shift 218 | "kUP6": "ctrl+shift+up", 219 | "kDN6": "ctrl+shift+down", 220 | "kLFT6": "ctrl+shift+left", 221 | "kRIT6": "ctrl+shift+right", 222 | 223 | "kDC6": "ctrl+shift+delete", 224 | "kHOM6": "ctrl+shift+home", 225 | "kEND6": "ctrl+shift+end", 226 | 227 | 228 | # Control + Alt 229 | "kUP7": "ctrl+alt+up", 230 | "kDN7": "ctrl+alt+down", 231 | "kLFT7": "ctrl+alt+left", 232 | "kRIT7": "ctrl+alt+right", 233 | 234 | "kPRV7": "ctrl+alt+pageup", 235 | "kNXT7": "ctrl+alt+pagedown", 236 | 237 | "kIC7": "ctrl+alt+insert", 238 | "kDC7": "ctrl+alt+delete", 239 | "kHOM7": "ctrl+alt+home", 240 | "kEND7": "ctrl+alt+end", 241 | 242 | # Special events 243 | "KEY_RESIZE": "resize", 244 | } 245 | -------------------------------------------------------------------------------- /suplemon/lexer.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import pygments 4 | import pygments.token 5 | import pygments.lexers 6 | 7 | 8 | class Lexer: 9 | def __init__(self, app): 10 | self.app = app 11 | self.token_map = { 12 | pygments.token.Comment: "comment", 13 | pygments.token.Comment.Single: "comment", 14 | pygments.token.Operator: "keyword", 15 | pygments.token.Name.Function: "entity.name.function", 16 | pygments.token.Name.Class: "entity.name.class", 17 | pygments.token.Name.Tag: "entity.name.tag", 18 | pygments.token.Name.Attribute: "entity.other.attribute-name", 19 | pygments.token.Name.Variable: "variable", 20 | pygments.token.Name.Builtin.Pseudo: "constant.language", 21 | pygments.token.Literal.String: "string", 22 | pygments.token.Literal.String.Doc: "string", 23 | pygments.token.Punctuation: "punctuation", 24 | pygments.token.Literal.Number: "constant.numeric", 25 | pygments.token.Name: "entity.name", 26 | pygments.token.Keyword: "keyword", 27 | pygments.token.Generic.Deleted: "invalid", 28 | } 29 | 30 | def lex(self, code, lex): 31 | """Return tokenified code. 32 | 33 | Return a list of tuples (scope, word) where word is the word to be 34 | printed and scope the scope name representing the context. 35 | 36 | :param str code: Code to tokenify. 37 | :param lex: Lexer to use. 38 | :return: 39 | """ 40 | if lex is None: 41 | if not type(code) is str: 42 | # if not suitable lexer is found, return decoded code 43 | code = code.decode("utf-8") 44 | return (("global", code),) 45 | 46 | words = pygments.lex(code, lex) 47 | 48 | scopes = [] 49 | for word in words: 50 | token = word[0] 51 | scope = "global" 52 | 53 | if token in self.token_map.keys(): 54 | scope = self.token_map[token] 55 | 56 | scopes.append((scope, word[1])) 57 | return scopes 58 | -------------------------------------------------------------------------------- /suplemon/line.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Line object to represent a single line in the text editor. 4 | """ 5 | 6 | 7 | class Line: 8 | def __init__(self, data=""): 9 | if isinstance(data, Line): 10 | data = data.data 11 | self.data = data 12 | self.x_scroll = 0 13 | self.number_color = 8 14 | 15 | def __getitem__(self, i): 16 | return self.data[i] 17 | 18 | def __setitem__(self, i, v): 19 | self.data[i] = v 20 | 21 | def __str__(self): 22 | return self.data 23 | 24 | def __add__(self, other): 25 | return Line(self.data + other) 26 | 27 | def __radd__(self, other): 28 | return Line(other + self.data) 29 | 30 | def __len__(self): 31 | return len(self.data) 32 | 33 | def get_data(self): 34 | return self.data 35 | 36 | def set_data(self, data): 37 | if isinstance(data, Line): 38 | data = data.get_data() 39 | self.data = data 40 | 41 | def set_number_color(self, color): 42 | self.number_color = color 43 | 44 | def find(self, what, start=0): 45 | return self.data.find(what, start) 46 | 47 | def strip(self, *args): 48 | return self.data.strip(*args) 49 | 50 | def reset_number_color(self): 51 | self.number_color = 8 52 | -------------------------------------------------------------------------------- /suplemon/linelight/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richrd/suplemon/8bb67d6758e5bc5ca200fdce7a0fb6635abb66f4/suplemon/linelight/__init__.py -------------------------------------------------------------------------------- /suplemon/linelight/color_map.py: -------------------------------------------------------------------------------- 1 | color_map = { 2 | "red": 1, 3 | "green": 2, 4 | "yellow": 3, 5 | "blue": 4, 6 | "magenta": 5, 7 | "cyan": 6, 8 | "white": 7 9 | } 10 | -------------------------------------------------------------------------------- /suplemon/linelight/css.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("/*", "*/") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | if line.startswith("@import"): 12 | color = color_map["blue"] 13 | elif line.startswith("$"): 14 | color = color_map["green"] 15 | elif line.startswith("/*") or line.endswith("*/"): 16 | color = color_map["magenta"] 17 | elif line.startswith("{") or line.endswith(("}", "{")): 18 | color = color_map["cyan"] 19 | elif line.endswith(";"): 20 | color = color_map["yellow"] 21 | return color 22 | -------------------------------------------------------------------------------- /suplemon/linelight/diff.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("/*", "*/") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = str(raw_line) 11 | if line.startswith("+"): 12 | color = color_map["green"] 13 | elif line.startswith("-"): 14 | color = color_map["red"] 15 | elif line.startswith("@@"): 16 | color = color_map["blue"] 17 | return color 18 | -------------------------------------------------------------------------------- /suplemon/linelight/html.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | if line.startswith(("#", "//", "/*", "*/", "")): 14 | color = color_map["magenta"] 15 | elif line.startswith("<"): 16 | color = color_map["cyan"] 17 | elif line.endswith(">"): 18 | color = color_map["cyan"] 19 | return color 20 | -------------------------------------------------------------------------------- /suplemon/linelight/js.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("//", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | if line.startswith("function"): 12 | color = color_map["cyan"] 13 | elif line.startswith("return"): 14 | color = color_map["red"] 15 | elif line.startswith("this."): 16 | color = color_map["cyan"] 17 | elif line.startswith(("//", "/*", "*/", "*")): 18 | color = color_map["magenta"] 19 | elif line.startswith(("if", "else", "for ", "while ", "continue", "break")): 20 | color = color_map["yellow"] 21 | return color 22 | -------------------------------------------------------------------------------- /suplemon/linelight/json.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self, line): 6 | return ("", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | if line.startswith(("{", "}")): 12 | color = color_map["yellow"] 13 | elif line.startswith("\""): 14 | color = color_map["green"] 15 | return color 16 | -------------------------------------------------------------------------------- /suplemon/linelight/lua.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("-- ", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | return color 11 | -------------------------------------------------------------------------------- /suplemon/linelight/md.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self, line): 6 | return ("", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | if line.startswith(("*", "-")): # List 12 | color = color_map["cyan"] 13 | elif line.startswith("#"): # Header 14 | color = color_map["green"] 15 | elif line.startswith(">"): # Item description 16 | color = color_map["yellow"] 17 | elif raw_line.startswith(" "): # Code 18 | color = color_map["magenta"] 19 | return color 20 | -------------------------------------------------------------------------------- /suplemon/linelight/php.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("//", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | keywords = ("if", "else", "finally", "try", "catch", "foreach", 12 | "while", "continue", "pass", "break") 13 | if line.startswith(("include", "require")): 14 | color = color_map["blue"] 15 | elif line.startswith(("class", "public", "private", "function")): 16 | color = color_map["green"] 17 | elif line.startswith("def"): 18 | color = color_map["cyan"] 19 | elif line.startswith("return"): 20 | color = color_map["red"] 21 | elif line.startswith("$"): 22 | color = color_map["cyan"] 23 | elif line.startswith(("#", "//", "/*", "*/")): 24 | color = color_map["magenta"] 25 | elif line.startswith(keywords): 26 | color = color_map["yellow"] 27 | return color 28 | -------------------------------------------------------------------------------- /suplemon/linelight/py.py: -------------------------------------------------------------------------------- 1 | from suplemon.linelight.color_map import color_map 2 | 3 | 4 | class Syntax: 5 | def get_comment(self): 6 | return ("# ", "") 7 | 8 | def get_color(self, raw_line): 9 | color = color_map["white"] 10 | line = raw_line.strip() 11 | keywords = ("if", "elif", "else", "finally", "try", "except", 12 | "for ", "while ", "continue", "pass", "break") 13 | if line.startswith(("import", "from")): 14 | color = color_map["blue"] 15 | elif line.startswith("class"): 16 | color = color_map["green"] 17 | elif line.startswith("def"): 18 | color = color_map["cyan"] 19 | elif line.startswith(("return", "yield")): 20 | color = color_map["red"] 21 | elif line.startswith("self."): 22 | color = color_map["cyan"] 23 | elif line.startswith(("#", "//", "\"", "'", ":")): 24 | color = color_map["magenta"] 25 | elif line.startswith(keywords): 26 | color = color_map["yellow"] 27 | return color 28 | -------------------------------------------------------------------------------- /suplemon/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic logging to delay printing until curses is unloaded. 3 | """ 4 | from __future__ import print_function 5 | import os 6 | import logging 7 | from logging.handlers import BufferingHandler, RotatingFileHandler 8 | import sys 9 | 10 | 11 | # Define an mix of BufferingHandler and MemoryHandler which store records internally and flush on `close` 12 | # https://docs.python.org/3.3/library/logging.handlers.html#logging.handlers.BufferingHandler 13 | # https://docs.python.org/3.3/library/logging.handlers.html#logging.handlers.MemoryHandler 14 | class BufferingTargetHandler(BufferingHandler): 15 | # Set up capacity and target for MemoryHandler 16 | def __init__(self, capacity, fd_target): 17 | """ 18 | :param int capacity: Amount of records to store in memory 19 | https://github.com/python/cpython/blob/3.3/Lib/logging/handlers.py#L1161-L1176 20 | :param object fd_target: File descriptor to write output to (e.g. `sys.stdout`) 21 | """ 22 | # Call our BufferingHandler init 23 | if issubclass(BufferingTargetHandler, object): 24 | super(BufferingTargetHandler, self).__init__(capacity) 25 | else: 26 | BufferingHandler.__init__(self, capacity) 27 | 28 | # Save target for later 29 | self._fd_target = fd_target 30 | 31 | def close(self): 32 | """Upon `close`, flush our internal info to the target""" 33 | # Flush our buffers to the target 34 | # https://github.com/python/cpython/blob/3.3/Lib/logging/handlers.py#L1185 35 | # https://github.com/python/cpython/blob/3.3/Lib/logging/handlers.py#L1241-L1256 36 | self.acquire() 37 | try: 38 | for record in self.buffer: 39 | if record.levelno < self.level: 40 | continue 41 | msg = self.format(record) 42 | print(msg, file=self._fd_target) 43 | finally: 44 | self.release() 45 | 46 | # Then, run our normal close actions 47 | if issubclass(BufferingTargetHandler, object): 48 | super(BufferingTargetHandler, self).close() 49 | else: 50 | BufferingHandler.close(self) 51 | 52 | 53 | # Initialize logging 54 | logging.basicConfig(level=logging.NOTSET) 55 | logger = logging.getLogger() 56 | logger.handlers = [] 57 | 58 | # Generate and configure handlers 59 | log_filepath = os.path.join(os.path.expanduser("~"), ".config", "suplemon", "output.log") 60 | logger_handlers = [ 61 | # Buffer 64k records in memory at a time 62 | BufferingTargetHandler(64 * 1024, fd_target=sys.stderr), 63 | ] 64 | 65 | # Error recovery when log_filepath isn't writable 66 | try: 67 | if not os.path.exists(os.path.dirname(log_filepath)): 68 | os.makedirs(os.path.dirname(log_filepath)) 69 | # Output up to 4MB of records to `~/.config/suplemon/output.log` for live debugging 70 | # https://docs.python.org/3.3/library/logging.handlers.html#logging.handlers.RotatingFileHandler 71 | # DEV: We use append mode to prevent erasing out logs 72 | # DEV: We use 1 backup count since it won't truncate otherwise =/ 73 | rfh = RotatingFileHandler(log_filepath, mode="a+", maxBytes=(4 * 1024 * 1024), backupCount=1) 74 | logger_handlers.append(rfh) 75 | except: 76 | # Can't recover and can't log this error 77 | pass 78 | 79 | fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 80 | logger_formatter = logging.Formatter(fmt) 81 | for logger_handler in logger_handlers: 82 | logger_handler.setFormatter(logger_formatter) 83 | 84 | # Save handlers for our logger 85 | for logger_handler in logger_handlers: 86 | logger.addHandler(logger_handler) 87 | -------------------------------------------------------------------------------- /suplemon/module_loader.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Addon module loader. 4 | """ 5 | import os 6 | import imp 7 | import logging 8 | 9 | 10 | class ModuleLoader: 11 | def __init__(self, app=None): 12 | self.app = app 13 | self.logger = logging.getLogger(__name__) 14 | # The app root directory 15 | self.curr_path = os.path.dirname(os.path.realpath(__file__)) 16 | # The modules subdirectory 17 | self.module_path = os.path.join(self.curr_path, "modules" + os.sep) 18 | # Module instances 19 | self.modules = {} 20 | 21 | def load(self): 22 | """Find and load available modules.""" 23 | self.logger.debug("Loading modules...") 24 | names = self.get_module_names() 25 | for name in names: 26 | module = self.load_single(name) 27 | if module: 28 | # Load and store the module instance 29 | inst = self.load_instance(module) 30 | if inst: 31 | self.modules[module[0]] = inst 32 | 33 | def get_module_names(self): 34 | """Get names of loadable modules.""" 35 | names = [] 36 | dirlist = os.listdir(self.module_path) 37 | for item in dirlist: 38 | # Skip 'hidden' dot files and files beginning with and underscore 39 | if item.startswith((".", "_")): 40 | continue 41 | parts = item.split(".") 42 | if len(parts) < 2: 43 | # Can't find file extension 44 | continue 45 | name = parts[0] 46 | ext = parts[-1] 47 | # only load .py modules 48 | if ext != "py": 49 | continue 50 | names.append(name) 51 | return names 52 | 53 | def load_instance(self, module): 54 | """Initialize a module.""" 55 | try: 56 | inst = module[1]["class"](self.app, module[0], module[1]) # Store the module instance 57 | return inst 58 | except: 59 | self.logger.error("Initializing module failed: {0}".format(module[0]), exc_info=True) 60 | return False 61 | 62 | def load_single(self, name): 63 | """Load single module file.""" 64 | path = os.path.join(self.module_path, name+".py") 65 | try: 66 | mod = imp.load_source(name, path) 67 | except: 68 | self.logger.error("Failed loading module: {0}".format(name), exc_info=True) 69 | return False 70 | if "module" not in dir(mod): 71 | return False 72 | if "status" not in mod.module.keys(): 73 | mod.module["status"] = False 74 | return name, mod.module 75 | 76 | def extract_docs(self): 77 | """Get names and docs of runnable modules and print as markdown.""" 78 | names = sorted(self.get_module_names()) 79 | for name in names: 80 | name, module = self.load_single(name) 81 | # Skip modules that can't be run expicitly 82 | if module["class"].run.__module__ == "suplemon.suplemon_module": 83 | continue 84 | # Skip undocumented modules 85 | if not module["class"].__doc__: 86 | continue 87 | docstring = module["class"].__doc__ 88 | docstring = "\n " + docstring.strip() 89 | 90 | doc = " * {0}\n{1}\n".format(name, docstring) 91 | print(doc) 92 | 93 | 94 | if __name__ == "__main__": 95 | ml = ModuleLoader() 96 | ml.extract_docs() 97 | -------------------------------------------------------------------------------- /suplemon/modules/application_state.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | 4 | import hashlib 5 | 6 | from suplemon.suplemon_module import Module 7 | 8 | 9 | class ApplicationState(Module): 10 | """ 11 | Stores the state of open files when exiting the editor and restores when files are reopened. 12 | 13 | Cursor positions and scroll position are stored and restored. 14 | """ 15 | 16 | def init(self): 17 | self.init_logging(__name__) 18 | self.bind_event_after("app_loaded", self.on_load) 19 | self.bind_event_before("app_exit", self.on_exit) 20 | 21 | def on_load(self, event): 22 | """Runs when suplemon is fully loaded.""" 23 | self.restore_states() 24 | 25 | def on_exit(self, event): 26 | """Runs before suplemon is exits.""" 27 | self.store_states() 28 | 29 | def get_file_states(self): 30 | """Get the state of currently opened files. Returns a dict with the file path as key and file state as value.""" 31 | states = {} 32 | for file in self.app.get_files(): 33 | states[file.get_path()] = self.get_file_state(file) 34 | return states 35 | 36 | def get_file_state(self, file): 37 | """Get the state of a single file.""" 38 | editor = file.get_editor() 39 | state = { 40 | "cursors": [cursor.tuple() for cursor in editor.cursors], 41 | "scroll_pos": editor.scroll_pos, 42 | "hash": self.get_hash(editor), 43 | } 44 | return state 45 | 46 | def get_hash(self, editor): 47 | # We don't need cryptographic security so we just use md5 48 | h = hashlib.md5() 49 | for line in editor.lines: 50 | h.update(line.get_data().encode("utf-8")) 51 | return h.hexdigest() 52 | 53 | def set_file_state(self, file, state): 54 | """Set the state of a file.""" 55 | cursor = file.editor.get_cursor() 56 | # Don't set the cursor pos if it's not the default 0,0 57 | if cursor.x or cursor.y: 58 | return 59 | file.editor.set_cursors(state["cursors"]) 60 | file.editor.scroll_pos = state["scroll_pos"] 61 | 62 | def store_states(self): 63 | """Store the states of opened files.""" 64 | states = self.get_file_states() 65 | for path in states.keys(): 66 | self.storage[path] = states[path] 67 | self.storage.store() 68 | 69 | def restore_states(self): 70 | """Restore the states of files that are currently open.""" 71 | for file in self.app.get_files(): 72 | path = file.get_path() 73 | if path in self.storage.get_data().keys(): 74 | state = self.storage[path] 75 | if "hash" not in state: 76 | self.set_file_state(file, state) 77 | elif state["hash"] == self.get_hash(file.get_editor()): 78 | self.set_file_state(file, state) 79 | 80 | 81 | module = { 82 | "class": ApplicationState, 83 | "name": "application_state", 84 | } 85 | -------------------------------------------------------------------------------- /suplemon/modules/autocomplete.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import re 4 | 5 | from suplemon import helpers 6 | from suplemon.suplemon_module import Module 7 | 8 | 9 | class AutoComplete(Module): 10 | """ 11 | A simple autocompletion module. 12 | 13 | This adds autocomplete support for the tab key. It uses a word 14 | list scanned from all open files for completions. By default it suggests 15 | the shortest possible match. If there are no matches, the tab action is 16 | run normally. 17 | """ 18 | 19 | def init(self): 20 | self.word_list = [] 21 | self.bind_event("tab", self.auto_complete) 22 | self.bind_event_after("app_loaded", self.build_word_list) 23 | self.bind_event_after("save_file", self.build_word_list) 24 | self.bind_event_after("save_file_as", self.build_word_list) 25 | 26 | def get_separators(self): 27 | """Return list of word separators obtained from app config. 28 | 29 | :return: String with all separators. 30 | :rtype: str 31 | """ 32 | separators = self.app.config["editor"]["punctuation"] 33 | # Support words with underscores 34 | separators = separators.replace("_", "") 35 | return separators 36 | 37 | def build_word_list(self, *args): 38 | """Build the word list based on contents of open files.""" 39 | word_list = [] 40 | for file in self.app.files: 41 | data = file.get_editor().get_data() 42 | words = helpers.multisplit(data, self.get_separators()) 43 | for word in words: 44 | # Discard undesired whitespace 45 | word = word.strip() 46 | # Must be longer than 1 and not yet in word_list 47 | if len(word) > 1 and word not in word_list: 48 | word_list.append(word) 49 | self.word_list = word_list 50 | return False 51 | 52 | def get_match(self, word): 53 | """Find a completable match for word. 54 | 55 | :param word: The partial word to complete 56 | :return: The completion to add to the partial word 57 | :rtype: str 58 | """ 59 | if not word: 60 | return False 61 | # Build list of suitable matches 62 | candidates = [] 63 | for candidate in self.word_list: 64 | if candidate.startswith(word) and len(candidate) > len(word): 65 | candidates.append(candidate) 66 | # Find the shortest match 67 | # TODO: implement cycling through matches 68 | shortest = "" 69 | for candidate in candidates: 70 | if not shortest: 71 | shortest = candidate 72 | continue 73 | if len(candidate) < len(shortest): 74 | shortest = candidate 75 | if shortest: 76 | return shortest[len(word):] 77 | return False 78 | 79 | def run(self, app, editor, args): 80 | """Run the autocompletion.""" 81 | self.auto_complete() 82 | 83 | def auto_complete(self, event): 84 | """Attempt to autocomplete at each cursor position. 85 | 86 | This callback runs before the tab action and tries to autocomplete 87 | the current word. If a match is found the tab action is inhibited. 88 | 89 | :param event: The event object. 90 | :return: True if a match is found. 91 | """ 92 | editor = self.app.get_editor() 93 | pattern = "|".join(map(re.escape, self.get_separators())) 94 | matched = False 95 | for cursor in editor.cursors: 96 | line = editor.lines[cursor.y][:cursor.x] 97 | words = re.split(pattern, line) 98 | last_word = words[-1] 99 | match = self.get_match(last_word) 100 | if match: 101 | matched = True 102 | editor.type_at_cursor(cursor, match) 103 | return matched 104 | 105 | 106 | module = { 107 | "class": AutoComplete, 108 | "name": "autocomplete", 109 | } 110 | -------------------------------------------------------------------------------- /suplemon/modules/autodocstring.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon import helpers 4 | from suplemon.suplemon_module import Module 5 | 6 | 7 | class AutoDocstring(Module): 8 | """ 9 | Simple module for adding docstring placeholders. 10 | 11 | This module is intended to generate docstrings for Python functions. 12 | It adds placeholders for descriptions, arguments and return data. 13 | Function arguments are crudely parsed from the function definition 14 | and return statements are scanned from the function body. 15 | """ 16 | 17 | def init(self): 18 | self.init_logging(__name__) 19 | self.default_values = { 20 | "short_desc": "Short description.", 21 | "long_desc": "Long description.", 22 | "args": "", 23 | "returns": "", 24 | } 25 | self.docstring_template = "{short_desc}\n" + \ 26 | "\n" + \ 27 | "{long_desc}\n" + \ 28 | "\n" + \ 29 | "{args}" + \ 30 | "{returns}" 31 | 32 | def run(self, app, editor, args): 33 | """Run the autosphinx command.""" 34 | cursor = editor.get_cursor() 35 | line = editor.get_line(cursor.y) 36 | line_data = line.get_data() 37 | if not line_data.strip().startswith("def "): 38 | app.set_status("Current line isn't a function definition.") 39 | return False 40 | 41 | func_args = self.get_function_args(line_data) 42 | func_returns = self.get_function_returns(editor, cursor.y) 43 | docstring = self.get_docstring(func_args=func_args, func_returns=func_returns) 44 | 45 | indent = self.get_docstring_indent(line_data) 46 | indent = indent * self.app.config["editor"]["tab_width"] * " " 47 | self.insert_docstring(editor, cursor.y+1, indent, docstring) 48 | 49 | def insert_docstring(self, editor, line_number, indent, docstring): 50 | """Insert a docstring at a specific line. 51 | 52 | Inserts the given docstring into editor at a specific 53 | line and indentation. 54 | 55 | :param editor: The editor to insert into 56 | :param int line_number: The first line number 57 | :param str indent: How much the docstring 58 | :param str docstring: The actual docstring 59 | """ 60 | docstring = '"""{0}"""'.format(docstring) 61 | raw_lines = docstring.split("\n") 62 | lines = [] 63 | for line in raw_lines: 64 | if not line.strip(): 65 | lines.append("") 66 | else: 67 | lines.append(indent+line) 68 | editor.insert_lines_at(lines, line_number) 69 | 70 | def get_docstring_indent(self, line_data): 71 | """Get indent amount for docstring. 72 | 73 | Gets the indentation of the function definiton line, and 74 | adds +1 to account for the function body. 75 | 76 | :param line_data: The line of the function definition. 77 | """ 78 | tab = self.app.config["editor"]["tab_width"] 79 | wspace = helpers.whitespace(line_data) 80 | indent = int(wspace/tab)+1 81 | return indent 82 | 83 | def get_docstring(self, **values): 84 | for key in self.default_values.keys(): 85 | if key not in values.keys(): 86 | values[key] = self.default_values[key] 87 | 88 | args = "" 89 | for arg in values["func_args"]: 90 | args += ":param {0}:\n".format(arg) 91 | values["args"] = args 92 | 93 | if values["func_returns"]: 94 | values["returns"] = ":return:\n" 95 | 96 | doc = self.docstring_template 97 | doc = doc.format(**values) 98 | return doc 99 | 100 | def get_function_name(self, line_data): 101 | """Get the name of function defined in line_data. 102 | 103 | :param str line_data: The line containing the function definition. 104 | """ 105 | return helpers.get_string_between("def ", "(", line_data) 106 | 107 | def get_function_args(self, line_data): 108 | """Get list of arguments for function 109 | 110 | Parses a function definition in line_data and returns argument list. 111 | 112 | :param str line_data: The line containing the function definition. 113 | """ 114 | func_name = self.get_function_name(line_data) 115 | raw_args = helpers.get_string_between(func_name+"(", "):", line_data) 116 | parts = raw_args.split(",") 117 | args = [part.strip() for part in parts] 118 | if "self" in args: 119 | args.pop(args.index("self")) 120 | return args 121 | 122 | def get_function_returns(self, editor, line_number): 123 | """Returns True if function at line_number returns something. 124 | 125 | :param editor: Editor instance to get lines from. 126 | :param line_number: Line number of the function definition. 127 | :return: Boolean indicating wether the function something. 128 | """ 129 | for i in range(line_number+1, len(editor.lines)): 130 | line = editor.get_line(i) 131 | data = line.get_data().strip() 132 | if data.startswith("def "): 133 | break 134 | if data.startswith("return "): 135 | return True 136 | return False 137 | 138 | 139 | module = { 140 | "class": AutoDocstring, 141 | "name": "autodocstring", 142 | } 143 | -------------------------------------------------------------------------------- /suplemon/modules/battery.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import os 4 | import time 5 | import subprocess 6 | 7 | from suplemon import helpers 8 | from suplemon.suplemon_module import Module 9 | 10 | 11 | class Battery(Module): 12 | """Shows remaining battery capacity in the top status bar if available.""" 13 | 14 | def init(self): 15 | self.last_value = -1 16 | self.checked = time.time() 17 | self.interval = 60 # Seconds to wait until polling again 18 | 19 | def value(self): 20 | """Get the battery charge percent and cache it.""" 21 | if self.last_value == -1: 22 | state = self.battery_status() 23 | elif time.time()-self.checked > self.interval: 24 | state = self.battery_status() 25 | else: 26 | return self.last_value 27 | self.last_value = state 28 | return state 29 | 30 | def value_str(self): 31 | """Return formatted value string to show in the UI.""" 32 | val = self.value() 33 | if val: 34 | if self.app.config["app"]["use_unicode_symbols"]: 35 | return "\u26A1{0}%".format(str(val)) 36 | else: 37 | return "BAT {0}%".format(str(val)) 38 | return "" 39 | 40 | def get_status(self): 41 | """Called by app when showing status bar contents.""" 42 | return self.value_str() 43 | 44 | def battery_status(self): 45 | """Attempts to get the battery charge percent.""" 46 | value = None 47 | methods = [ 48 | self.battery_status_read, 49 | self.battery_status_acpi, 50 | self.battery_status_upower 51 | ] 52 | for m in methods: 53 | value = m() 54 | if value is not None: 55 | break 56 | return value 57 | 58 | def battery_status_read(self): 59 | """Get the battery status via proc/acpi.""" 60 | try: 61 | path_info = self.readf("/proc/acpi/battery/BAT0/info") 62 | path_state = self.readf("/proc/acpi/battery/BAT0/state") 63 | except: 64 | return None 65 | try: 66 | max_cap = float(helpers.get_string_between("last full capacity:", "mWh", path_info)) 67 | cur_cap = float(helpers.get_string_between("remaining capacity:", "mWh", path_state)) 68 | return int(cur_cap / max_cap * 100) 69 | except: 70 | return None 71 | 72 | def battery_status_acpi(self): 73 | """Get the battery status via acpi.""" 74 | try: 75 | fnull = open(os.devnull, "w") 76 | raw_str = subprocess.check_output(["acpi"], stderr=fnull) 77 | fnull.close() 78 | except: 79 | return None 80 | raw_str = raw_str.decode("utf-8") 81 | part = helpers.get_string_between(",", "%", raw_str) 82 | if part: 83 | try: 84 | return int(part) 85 | except: 86 | return None 87 | return None 88 | 89 | def battery_status_upower(self): 90 | """Get the battery status via upower.""" 91 | path = "/org/freedesktop/UPower/devices/battery_BAT0" 92 | try: 93 | raw_str = subprocess.check_output(["upower", "-i", path]) 94 | except: 95 | return None 96 | raw_str = raw_str.decode("utf-8") 97 | raw_str = raw_str.splitlines()[0] 98 | part = helpers.get_string_between("percentage:", "%", raw_str) 99 | if part: 100 | try: 101 | return int(part) 102 | except: 103 | return None 104 | return None 105 | 106 | def readf(self, path): 107 | """Read and return file contents at path.""" 108 | f = open(path) 109 | data = f.read() 110 | f.close() 111 | return data 112 | 113 | 114 | module = { 115 | "class": Battery, 116 | "name": "battery", 117 | "status": "top", 118 | } 119 | -------------------------------------------------------------------------------- /suplemon/modules/bulk_delete.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class BulkDelete(Module): 7 | """ 8 | Bulk delete lines and characters. 9 | Asks what direction to delete in by default. 10 | 11 | Add 'up' to delete lines above highest cursor. 12 | Add 'down' to delete lines below lowest cursor. 13 | Add 'left' to delete characters to the left of all cursors. 14 | Add 'right' to delete characters to the right of all cursors. 15 | """ 16 | 17 | def init(self): 18 | self.directions = ["up", "down", "left", "right"] 19 | 20 | def handler(self, prompt, event): 21 | # Get arrow keys from prompt 22 | if event.key_name in self.directions: 23 | prompt.set_data(event.key_name) 24 | prompt.on_ready() 25 | return True # Disable normal key handling 26 | 27 | def run(self, app, editor, args): 28 | direction = args.lower() 29 | if not direction: 30 | direction = app.ui.query_filtered("Press arrow key in direction to delete:", handler=self.handler) 31 | 32 | if direction not in self.directions: 33 | app.set_status("Invalid direction.") 34 | return False 35 | 36 | # Delete entire lines 37 | if direction == "up": 38 | pos = editor.get_first_cursor() 39 | length = len(editor.lines) 40 | editor.lines = editor.lines[pos.y:] 41 | delta = length - len(editor.lines) 42 | # If lines were removed, move the cursors up the same amount 43 | if delta: 44 | editor.move_cursors((0, -delta)) 45 | 46 | elif direction == "down": 47 | pos = editor.get_last_cursor() 48 | editor.lines = editor.lines[:pos.y+1] 49 | 50 | # Delete from start or end of lines 51 | else: 52 | # Select min/max function based on direction 53 | func = min if direction == "left" else max 54 | # Get all lines with cursors 55 | line_indices = editor.get_lines_with_cursors() 56 | for line_no in line_indices: 57 | # Get all cursors for the line 58 | line_cursors = editor.get_cursors_on_line(line_no) 59 | # Get the leftmost of rightmost x coordinate 60 | x = func(line_cursors, key=lambda c: c.x).x 61 | 62 | # Delete correct part of the line 63 | line = editor.lines[line_no] 64 | if direction == "left": 65 | line.data = line.data[x:] 66 | # Also move cursors appropriately when deleting left side 67 | [c.move_left(x) for c in line_cursors] 68 | else: 69 | line.data = line.data[:x] 70 | 71 | 72 | module = { 73 | "class": BulkDelete, 74 | "name": "bulk_delete", 75 | } 76 | -------------------------------------------------------------------------------- /suplemon/modules/clock.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import time 4 | 5 | from suplemon.suplemon_module import Module 6 | 7 | 8 | class Clock(Module): 9 | """Shows a clock in the top status bar.""" 10 | 11 | def get_status(self): 12 | s = time.strftime("%H:%M") 13 | if self.app.config["app"]["use_unicode_symbols"]: 14 | return "\u231A" + s 15 | return s 16 | 17 | 18 | module = { 19 | "class": Clock, 20 | "name": "clock", 21 | "status": "top", 22 | } 23 | -------------------------------------------------------------------------------- /suplemon/modules/comment.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon import helpers 4 | from suplemon.suplemon_module import Module 5 | 6 | 7 | class Comment(Module): 8 | """Toggle line commenting based on current file syntax.""" 9 | 10 | def run(self, app, editor, args): 11 | """Comment the current line(s).""" 12 | try: 13 | # Try to get comment start and end syntax 14 | comment = editor.syntax.get_comment() 15 | except: 16 | return False 17 | line_nums = editor.get_lines_with_cursors() 18 | # Iterate through lines 19 | for lnum in line_nums: 20 | line = editor.lines[lnum] 21 | if not len(line): 22 | continue # Skip empty lines 23 | # Look for comment syntax in stripped line (TODO:Make this smarter) 24 | target = str(line).strip() 25 | w = helpers.whitespace(line) # Amount of whitespace at line start 26 | # If the line starts with comment syntax 27 | if target.startswith(comment[0]): 28 | # Reconstruct the whitespace and add the line 29 | new_line = (" "*w) + line[w+len(comment[0]):] 30 | # If comment end syntax exists 31 | if comment[1]: 32 | # Try to remove it from the end of the line 33 | if new_line.endswith(comment[1]): 34 | new_line = new_line[:-1*len(comment[1])] 35 | # Store the modified line 36 | # editor.lines[lnum] = Line(new_line) 37 | editor.lines[lnum].set_data(new_line) 38 | # If the line isn't commented 39 | else: 40 | # Slice out the prepended whitespace 41 | new_line = line[w:] 42 | # Add the whitespace and starting comment 43 | new_line = (" "*w) + comment[0] + new_line 44 | if comment[1]: 45 | # Add comment end syntax if needed 46 | new_line += comment[1] 47 | # Store modified line 48 | # editor.lines[lnum] = Line(new_line) 49 | editor.lines[lnum].set_data(new_line) 50 | # Keep cursors under control, same as usual... 51 | editor.move_cursors() 52 | editor.store_action_state("comment") 53 | 54 | 55 | module = { 56 | "class": Comment, 57 | "name": "comment", 58 | } 59 | -------------------------------------------------------------------------------- /suplemon/modules/config.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import os 4 | 5 | from suplemon import config 6 | 7 | 8 | class SuplemonConfig(config.ConfigModule): 9 | """Shortcut for openning the config files.""" 10 | 11 | def init(self): 12 | self.config_name = "defaults.json" 13 | self.config_default_path = os.path.join(self.app.path, "config", self.config_name) 14 | self.config_user_path = self.app.config.path() 15 | 16 | 17 | module = { 18 | "class": SuplemonConfig, 19 | "name": "config", 20 | } 21 | -------------------------------------------------------------------------------- /suplemon/modules/crypt.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | import base64 3 | import hashlib 4 | import binascii 5 | from Crypto import Random 6 | from Crypto.Cipher import AES 7 | 8 | from suplemon.suplemon_module import Module 9 | from suplemon.prompt import PromptPassword 10 | 11 | 12 | def password_to_key(password, salt): 13 | # scrypt deflaults 14 | cost = 16384 15 | block_size = 8 16 | parallelization = 1 17 | dk = hashlib.scrypt( 18 | bytes(password, "utf-8"), 19 | salt=bytes(salt, "utf-8"), 20 | n=cost, 21 | r=block_size, 22 | p=parallelization, 23 | dklen=16 24 | ) 25 | return binascii.hexlify(dk) 26 | 27 | 28 | def pad_data(s): 29 | return s + (AES.block_size - len(s) % AES.block_size) * chr(AES.block_size - len(s) % AES.block_size) 30 | 31 | 32 | def unpad_data(s): 33 | return s[:-ord(s[len(s)-1:])] 34 | 35 | 36 | def encrypt(data, password, salt): 37 | if not password: 38 | raise ValueError("password must be a non empty string") 39 | key = password_to_key(password, salt) 40 | data = pad_data(data) 41 | 42 | iv = Random.new().read(AES.block_size) 43 | cipher = AES.new(key, AES.MODE_CBC, iv) 44 | encoded = base64.b64encode(iv + cipher.encrypt(data)).decode('utf-8') 45 | return encoded 46 | 47 | 48 | def decrypt(data, password, salt): 49 | key = password_to_key(password, salt) 50 | data = base64.b64decode(data) 51 | 52 | iv = data[:AES.block_size] 53 | 54 | cipher = AES.new(key, AES.MODE_CBC, iv) # never use ECB in strong systems obviously 55 | decoded = cipher.decrypt(data[AES.block_size:]).decode("utf-8") 56 | result = unpad_data(decoded) 57 | return result 58 | 59 | 60 | class Crypt(Module): 61 | """ 62 | Encrypt or decrypt the current buffer. Lets you provide a passphrase and optional salt for encryption. 63 | Uses AES for encryption and scrypt for key generation. 64 | """ 65 | 66 | def init(self): 67 | self.methods = { 68 | "e": self.encrypt, 69 | "d": self.decrypt, 70 | } 71 | 72 | self.actions = { 73 | "e": "Encryption", 74 | "d": "Decryption", 75 | } 76 | 77 | def encrypt(self, editor, options): 78 | result = encrypt(editor.get_data(), options[0], options[1]) 79 | editor.set_data(result) 80 | 81 | def decrypt(self, editor, options): 82 | result = decrypt(editor.get_data(), options[0], options[1]) 83 | editor.set_data(result) 84 | 85 | def query_options(self): 86 | pwd = self.app.ui._query("Password:", initial="", cls=PromptPassword, inst=None) 87 | 88 | if not pwd: 89 | return False 90 | salt = self.app.ui.query("Password Salt (optional):") 91 | 92 | if not salt: 93 | salt = "" 94 | return (pwd, salt) 95 | 96 | def handler(self, prompt, event): 97 | if event.key_name.lower() in self.methods.keys(): 98 | prompt.set_data(event.key_name.lower()) 99 | prompt.on_ready() 100 | return True # Disable normal key handling 101 | 102 | def run(self, app, editor, args): 103 | key = app.ui.query_filtered("Press E to encrypt or D to decrypt:", handler=self.handler) 104 | if not key: 105 | return 106 | method = self.methods[key] 107 | 108 | options = self.query_options() 109 | if not options: 110 | app.set_status("You must specify a password!") 111 | return 112 | 113 | # Run the encryption or decryption on the editor buffer 114 | try: 115 | method(editor, options) 116 | except: 117 | app.set_status(self.actions[key] + " failed.") 118 | 119 | 120 | module = { 121 | "class": Crypt, 122 | "name": "crypt", 123 | } 124 | -------------------------------------------------------------------------------- /suplemon/modules/date.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import time 4 | 5 | from suplemon.suplemon_module import Module 6 | 7 | 8 | class Date(Module): 9 | """Shows the current date without year in the top status bar.""" 10 | 11 | def get_status(self): 12 | s = time.strftime("%d.%m.") 13 | if self.app.config["app"]["use_unicode_symbols"]: 14 | return "" + s 15 | return s 16 | 17 | 18 | module = { 19 | "class": Date, 20 | "name": "date", 21 | "status": "top", 22 | } 23 | -------------------------------------------------------------------------------- /suplemon/modules/diff.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import difflib 4 | 5 | from suplemon.suplemon_module import Module 6 | 7 | 8 | class Diff(Module): 9 | """View a diff of the current file compared to it's on disk version.""" 10 | 11 | def run(self, app, editor, args): 12 | curr_file = app.get_file() 13 | curr_path = curr_file.get_path() 14 | if not curr_path: 15 | self.app.set_status("File hasn't been saved, can't show diff.") 16 | return False 17 | 18 | current_data = editor.get_data() 19 | f = open(curr_path) 20 | original_data = f.read() 21 | f.close() 22 | diff = self.get_diff(original_data, current_data) 23 | 24 | if not diff: 25 | self.app.set_status("The file in the editor and on disk are identical.") 26 | return False 27 | file = app.new_file() 28 | file.set_name(curr_file.get_name() + ".diff") 29 | file.set_data(diff) 30 | app.switch_to_file(app.last_file_index()) 31 | 32 | def get_diff(self, a, b): 33 | a = a.splitlines(1) 34 | b = b.splitlines(1) 35 | diff = difflib.unified_diff(a, b) 36 | return "".join(diff) 37 | 38 | 39 | module = { 40 | "class": Diff, 41 | "name": "diff", 42 | } 43 | -------------------------------------------------------------------------------- /suplemon/modules/eval.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Eval(Module): 7 | """ 8 | Evaluate a python expression and show the result in the status bar. 9 | 10 | If no expression is provided the current line(s) are evaluated and 11 | replaced with the evaluation result. 12 | """ 13 | 14 | def run(self, app, editor, args): 15 | if not args: 16 | return self.evaluate_lines(editor) 17 | else: 18 | return self.evaluate_input(args) 19 | 20 | def evaluate_input(self, inp): 21 | try: 22 | value = eval(inp) 23 | except: 24 | self.app.set_status("Eval failed.") 25 | return False 26 | self.app.set_status("Result:{0}".format(value)) 27 | return True 28 | 29 | def evaluate_lines(self, editor): 30 | line_nums = editor.get_lines_with_cursors() 31 | for num in line_nums: 32 | line = editor.get_line(num) 33 | try: 34 | value = eval(line.get_data()) 35 | except: 36 | continue 37 | line.set_data(str(value)) 38 | 39 | 40 | module = { 41 | "class": Eval, 42 | "name": "eval", 43 | } 44 | -------------------------------------------------------------------------------- /suplemon/modules/hostname.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import socket 4 | 5 | from suplemon.suplemon_module import Module 6 | 7 | 8 | class Hostname(Module): 9 | """Shows the machine hostname in the bottom status bar.""" 10 | 11 | def init(self): 12 | self.hostname = "" 13 | hostinfo = None 14 | try: 15 | hostinfo = socket.gethostbyaddr(socket.gethostname()) 16 | except: 17 | self.logger.debug("Failed to get hostname.") 18 | if hostinfo: 19 | self.hostname = hostinfo[0] 20 | # Use shorter hostname if available 21 | if hostinfo[1]: 22 | self.hostname = hostinfo[1][0] 23 | 24 | def get_status(self): 25 | if self.hostname: 26 | return "host:{0}".format(self.hostname) 27 | return "" 28 | 29 | 30 | module = { 31 | "class": Hostname, 32 | "name": "hostname", 33 | "status": "bottom", 34 | } 35 | -------------------------------------------------------------------------------- /suplemon/modules/keymap.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import os 4 | 5 | from suplemon import config 6 | 7 | 8 | class KeymapConfig(config.ConfigModule): 9 | """Shortcut to openning the keymap config file.""" 10 | 11 | def init(self): 12 | self.config_name = "keymap.json" 13 | self.config_default_path = os.path.join(self.app.path, "config", self.config_name) 14 | self.config_user_path = self.app.config.keymap_path() 15 | 16 | 17 | module = { 18 | "class": KeymapConfig, 19 | "name": "keymap", 20 | } 21 | -------------------------------------------------------------------------------- /suplemon/modules/linter.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import re 4 | import os 5 | import threading 6 | import subprocess 7 | 8 | from suplemon.suplemon_module import Module 9 | 10 | 11 | class Linter(Module): 12 | """Linter for suplemon.""" 13 | 14 | def init(self): 15 | self.init_logging(__name__) 16 | 17 | # TODO: Run linting in a seperate thread to avoid 18 | # blocking the UI when the app is loading 19 | 20 | # Lint all files after app is loaded 21 | self.bind_event_after("app_loaded", self.on_loaded) 22 | # Show linting messages in status bar 23 | self.bind_event_after("mainloop", self.mainloop) 24 | # Re-lint current file when appropriate 25 | self.bind_event_after("save_file", self.lint_current_file) 26 | self.bind_event_after("save_file_as", self.lint_current_file) 27 | self.bind_event_after("reload_file", self.lint_current_file) 28 | self.bind_event_after("open_file", self.lint_current_file) 29 | 30 | def run(self, app, editor, args): 31 | """Run the linting command.""" 32 | editor = self.app.get_file().get_editor() 33 | count = self.get_msg_count(editor) 34 | status = "{0} lines with linting errors in this file.".format(str(count)) 35 | self.app.set_status(status) 36 | 37 | def on_loaded(self, event): 38 | # Lint all files in a thread since it might take a while 39 | thread = threading.Thread(target=self.lint_all_files, args=(event,)) 40 | # Use daemon mode to forcefully shutdown the thread on application exit. 41 | # Python2 doesn't know the boolean daemon keyword argument to the Thread() 42 | # constructor so we use the .daemon property instead. 43 | thread.daemon = True 44 | thread.start() 45 | 46 | def mainloop(self, event): 47 | """Run the linting command.""" 48 | file = self.app.get_file() 49 | editor = file.get_editor() 50 | cursor = editor.get_cursor() 51 | if len(editor.cursors) > 1: 52 | return False 53 | line_no = cursor.y + 1 54 | msg = self.get_msgs_on_line(editor, cursor.y) 55 | if msg: 56 | self.app.set_status("Line {0}: {1}".format(str(line_no), msg)) 57 | 58 | def lint_current_file(self, event): 59 | self.lint_file(self.app.get_file()) 60 | 61 | def lint_all_files(self, event): 62 | """Do linting check for all open files and store results.""" 63 | for file in self.app.files: 64 | self.lint_file(file) 65 | return False 66 | 67 | def lint_file(self, file): 68 | path = file.get_path() 69 | if not path: # Unsaved file 70 | return False 71 | 72 | ext = file.get_extension().lower() 73 | linter = False 74 | if ext == "py": 75 | linter = PyLint(self.logger) 76 | elif ext == "js": 77 | linter = JsLint(self.logger) 78 | elif ext == "php": 79 | linter = PhpLint(self.logger) 80 | 81 | if not linter: 82 | return False 83 | 84 | linting = linter.lint(path) 85 | if linting is False: 86 | return False 87 | 88 | editor = file.get_editor() 89 | for line_no in range(len(editor.lines)): 90 | line = editor.lines[line_no] 91 | if line_no+1 in linting.keys(): 92 | line.linting = linting[line_no+1] 93 | line.set_number_color(1) 94 | else: 95 | line.linting = False 96 | line.reset_number_color() 97 | 98 | def get_msgs_on_line(self, editor, line_no): 99 | line = editor.lines[line_no] 100 | if not hasattr(line, "linting") or not line.linting: 101 | return False 102 | return line.linting[0][1] 103 | 104 | def get_msg_count(self, editor): 105 | count = 0 106 | for line in editor.lines: 107 | if hasattr(line, "linting"): 108 | if line.linting: 109 | count += 1 110 | return count 111 | 112 | 113 | class BaseLint: 114 | def __init__(self, logger): 115 | self.logger = logger 116 | 117 | def lint(self, path): 118 | pass 119 | 120 | def get_file_linting(self, path): 121 | """Do linting check for given file path.""" 122 | return {} 123 | 124 | def get_output(self, cmd): 125 | try: 126 | fnull = open(os.devnull, "w") 127 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=fnull) 128 | fnull.close() 129 | except (OSError, EnvironmentError): # can't use FileNotFoundError in Python 2 130 | self.logger.debug("Subprocess failed.") 131 | return False 132 | out, err = process.communicate() 133 | return out 134 | 135 | 136 | class PyLint(BaseLint): 137 | def __init__(self, logger): 138 | BaseLint.__init__(self, logger) 139 | # Error codes to ignore e.g. 'E501' (line too long) 140 | self.ignore = [] 141 | # Max length of line 142 | self.max_line_length = 120 # Default is 79 143 | 144 | def lint(self, path): 145 | if not self.has_flake8_support(): 146 | self.logger.warning("Flake8 not available. Can't show linting.") 147 | return False 148 | self.logger.debug(self.has_flake8_support()) 149 | return self.get_file_linting(path) 150 | 151 | def has_flake8_support(self): 152 | output = self.get_output(["flake8", "--version"]) 153 | return output 154 | 155 | def get_file_linting(self, path): 156 | """Do linting check for given file path.""" 157 | output = self.get_output(["flake8", "--max-line-length", str(self.max_line_length), path]) 158 | if output is False: 159 | self.logger.warning("Failed to get linting for file '{0}'.".format(path)) 160 | return False 161 | output = output.decode("utf-8") 162 | # Remove file paths from output 163 | output = output.replace(path+":", "") 164 | lines = output.split("\n") 165 | linting = {} 166 | for line in lines: 167 | if not line: 168 | continue 169 | try: 170 | parts = line.split(":") 171 | line_no = int(parts[0]) 172 | char_no = int(parts[1]) 173 | data = ":".join(parts[2:]).strip() 174 | err_code = data.split(" ")[0] 175 | if err_code in self.ignore: 176 | continue 177 | if line_no not in linting.keys(): 178 | linting[line_no] = [] 179 | linting[line_no].append((char_no, data, err_code)) 180 | except: 181 | self.logger.debug("Failed to parse line:{0}".format(line)) 182 | return linting 183 | 184 | 185 | class JsLint(BaseLint): 186 | def __init__(self, logger): 187 | BaseLint.__init__(self, logger) 188 | 189 | def lint(self, path): 190 | return self.get_file_linting(path) 191 | 192 | def get_file_linting(self, path): 193 | """Do linting check for given file path.""" 194 | output = self.get_output(["jshint", path]) 195 | if output is False: 196 | self.logger.warning("Failed to get linting for file '{0}'.".format(path)) 197 | return False 198 | output = output.decode("utf-8") 199 | # Remove file paths from output 200 | output = output.replace(path+": ", "") 201 | lines = output.split("\n") 202 | linting = {} 203 | for line in lines: 204 | if not line: 205 | continue 206 | try: 207 | parts = line.split(", ") 208 | if len(parts) < 3: 209 | continue 210 | line_no = int(re.sub("\D", "", parts[0])) 211 | char_no = int(re.sub("\D", "", parts[1])) 212 | data = parts[2] 213 | err_code = None 214 | if line_no not in linting.keys(): 215 | linting[line_no] = [] 216 | linting[line_no].append((char_no, data, err_code)) 217 | except: 218 | self.logger.debug("Failed to parse line:{0}".format(line)) 219 | return linting 220 | 221 | 222 | class PhpLint(BaseLint): 223 | def __init__(self, logger): 224 | BaseLint.__init__(self, logger) 225 | 226 | def lint(self, path): 227 | return self.get_file_linting(path) 228 | 229 | def get_output(self, cmd, errors=False): 230 | # Need to override this since php reports the errors to stderr 231 | try: 232 | fnull = open(os.devnull, "w") 233 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 234 | fnull.close() 235 | except (OSError, EnvironmentError): # can't use FileNotFoundError in Python 2 236 | self.logger.debug("Subprocess failed.") 237 | return False 238 | out, err = process.communicate() 239 | return err 240 | 241 | def get_file_linting(self, path): 242 | """Do linting check for given file path.""" 243 | output = self.get_output(["php", "-l", path]) 244 | if output is False: 245 | self.logger.warning("Failed to get linting for file '{0}'.".format(path)) 246 | return False 247 | output = output.decode("utf-8") 248 | lines = output.split("\n") 249 | linting = {} 250 | for line in lines: 251 | line = line.strip() 252 | if not line: 253 | continue 254 | try: 255 | parts = line.split(" in {0} on line ".format(path)) 256 | self.logger.debug(parts) 257 | if len(parts) != 2: 258 | continue 259 | line_no = int(parts[1]) 260 | char_no = 0 261 | data = parts[0] 262 | err_code = None 263 | if line_no not in linting.keys(): 264 | linting[line_no] = [] 265 | linting[line_no].append((char_no, data, err_code)) 266 | except: 267 | self.logger.debug("Failed to parse line:{0}".format(line)) 268 | return linting 269 | 270 | 271 | module = { 272 | "class": Linter, 273 | "name": "linter", 274 | } 275 | -------------------------------------------------------------------------------- /suplemon/modules/lower.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Lower(Module): 7 | """Transform current lines to lower case.""" 8 | 9 | def run(self, app, editor, args): 10 | line_nums = [] 11 | for cursor in editor.cursors: 12 | if cursor.y not in line_nums: 13 | line_nums.append(cursor.y) 14 | new_data = editor.lines[cursor.y].get_data().lower() 15 | editor.lines[cursor.y].data = new_data 16 | 17 | 18 | module = { 19 | "class": Lower, 20 | "name": "lower", 21 | } 22 | -------------------------------------------------------------------------------- /suplemon/modules/lstrip.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class LStrip(Module): 7 | """Trim whitespace from beginning of current lines.""" 8 | 9 | def run(self, app, editor, args): 10 | # TODO: move cursors in sync with line contents 11 | line_nums = editor.get_lines_with_cursors() 12 | for n in line_nums: 13 | line = editor.lines[n] 14 | line.data = line.data.lstrip() 15 | 16 | 17 | module = { 18 | "class": LStrip, 19 | "name": "lstrip", 20 | } 21 | -------------------------------------------------------------------------------- /suplemon/modules/paste.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Paste(Module): 7 | """Toggle paste mode (helpful when pasting over SSH if auto indent is enabled)""" 8 | 9 | def init(self): 10 | # Flag for paste mode 11 | self.active = False 12 | # Initial state of auto indent 13 | self.auto_indent_active = self.app.config["editor"]["auto_indent_newline"] 14 | # Listen for config changes 15 | self.bind_event_after("config_loaded", self.config_loaded) 16 | 17 | def run(self, app, editor, args): 18 | # Simply toggle pastemode when the command is run 19 | self.active = not self.active 20 | self.set_paste_mode(self.active) 21 | self.show_confirmation() 22 | return True 23 | 24 | def config_loaded(self, e): 25 | # Refresh the auto indent state when config is reloaded 26 | self.auto_indent_active = self.app.config["editor"]["auto_indent_newline"] 27 | 28 | def get_status(self): 29 | # Return the paste mode status for the statusbar 30 | return "[PASTEMODE]" if self.active else "" 31 | 32 | def show_confirmation(self): 33 | # Show a status message when pastemode is toggled 34 | state = "activated" if self.active else "deactivated" 35 | self.app.set_status("Paste mode " + state) 36 | 37 | def set_paste_mode(self, active): 38 | # Enable or disable auto indent 39 | if active: 40 | self.app.config["editor"]["auto_indent_newline"] = False 41 | else: 42 | self.app.config["editor"]["auto_indent_newline"] = self.auto_indent_active 43 | 44 | 45 | module = { 46 | "class": Paste, 47 | "name": "paste", 48 | "status": "bottom", 49 | } 50 | -------------------------------------------------------------------------------- /suplemon/modules/reload.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Reload(Module): 7 | """Reload all add-on modules.""" 8 | 9 | def run(self, app, editor, args): 10 | self.app.modules.load() 11 | 12 | 13 | module = { 14 | "class": Reload, 15 | "name": "reload", 16 | } 17 | -------------------------------------------------------------------------------- /suplemon/modules/replace_all.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class ReplaceAll(Module): 7 | """Replace all occurrences in all files of given text with given replacement.""" 8 | 9 | def run(self, app, editor, args): 10 | r_from = self.app.ui.query("Replace text:") 11 | if not r_from: 12 | return False 13 | r_to = self.app.ui.query("Replace with:") 14 | if not r_to: 15 | return False 16 | for file in app.get_files(): 17 | file.editor.replace_all(r_from, r_to) 18 | 19 | 20 | module = { 21 | "class": ReplaceAll, 22 | "name": "replace_all", 23 | } 24 | -------------------------------------------------------------------------------- /suplemon/modules/reverse.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Reverse(Module): 7 | """Reverse text on current line(s).""" 8 | 9 | def run(self, app, editor, args): 10 | line_nums = [] 11 | for cursor in editor.cursors: 12 | if cursor.y not in line_nums: 13 | line_nums.append(cursor.y) 14 | # Reverse string 15 | data = editor.lines[cursor.y].data[::-1] 16 | editor.lines[cursor.y].set_data(data) 17 | 18 | 19 | module = { 20 | "class": Reverse, 21 | "name": "upper", 22 | } 23 | -------------------------------------------------------------------------------- /suplemon/modules/rstrip.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class RStrip(Module): 7 | """Trim whitespace from the end of lines.""" 8 | 9 | def run(self, app, editor, args): 10 | line_nums = editor.get_lines_with_cursors() 11 | for n in line_nums: 12 | line = editor.lines[n] 13 | line.set_data(line.data.rstrip()) 14 | 15 | 16 | module = { 17 | "class": RStrip, 18 | "name": "rstrip", 19 | } 20 | -------------------------------------------------------------------------------- /suplemon/modules/save.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Save(Module): 7 | """Save the current file.""" 8 | 9 | def run(self, app, editor, args): 10 | return app.save_file() 11 | 12 | 13 | module = { 14 | "class": Save, 15 | "name": "save", 16 | } 17 | -------------------------------------------------------------------------------- /suplemon/modules/save_all.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class SaveAll(Module): 7 | """Save all currently open files. Asks for confirmation.""" 8 | 9 | def run(self, app, editor, args): 10 | if not self.app.ui.query_bool("Save all files?", False): 11 | return False 12 | for file in app.get_files(): 13 | file.save() 14 | 15 | 16 | module = { 17 | "class": SaveAll, 18 | "name": "save_all", 19 | } 20 | -------------------------------------------------------------------------------- /suplemon/modules/sort_lines.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class SortLines(Module): 7 | """ 8 | Sort current lines. 9 | 10 | Sorts alphabetically by default. 11 | Add 'length' to sort by length. 12 | Add 'reverse' to reverse the sorting. 13 | """ 14 | 15 | def sort_normal(self, line): 16 | return line.data 17 | 18 | def sort_length(self, line): 19 | return len(line.data) 20 | 21 | def run(self, app, editor, args): 22 | args = args.lower() 23 | 24 | sorter = self.sort_normal 25 | reverse = True if "reverse" in args else False 26 | if "length" in args: 27 | sorter = self.sort_length 28 | 29 | indices = editor.get_lines_with_cursors() 30 | lines = [editor.get_line(i) for i in indices] 31 | 32 | sorted_lines = sorted(lines, key=sorter, reverse=reverse) 33 | 34 | for i, line in enumerate(sorted_lines): 35 | editor.lines[indices[i]] = line 36 | 37 | 38 | module = { 39 | "class": SortLines, 40 | "name": "sort_lines", 41 | } 42 | -------------------------------------------------------------------------------- /suplemon/modules/strip.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Strip(Module): 7 | """Trim whitespace from start and end of lines.""" 8 | 9 | def run(self, app, editor, args): 10 | line_nums = editor.get_lines_with_cursors() 11 | for n in line_nums: 12 | line = editor.lines[n] 13 | line.set_data(line.data.strip()) 14 | 15 | 16 | module = { 17 | "class": Strip, 18 | "name": "strip", 19 | } 20 | -------------------------------------------------------------------------------- /suplemon/modules/system_clipboard.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import subprocess 4 | 5 | from suplemon.suplemon_module import Module 6 | 7 | 8 | class SystemClipboard(Module): 9 | """Integrates the system clipboard with suplemon.""" 10 | 11 | def init(self): 12 | self.init_logging(__name__) 13 | if self.has_xsel_support(): 14 | self.clipboard_type = "xsel" 15 | elif self.has_pb_support(): 16 | self.clipboard_type = "pb" 17 | elif self.has_xclip_support(): 18 | self.clipboard_type = "xclip" 19 | else: 20 | self.logger.warning( 21 | "Can't use system clipboard. Install 'xsel' or 'pbcopy' or 'xclip' for system clipboard support.") 22 | return False 23 | self.bind_event_before("insert", self.insert) 24 | self.bind_event_after("copy", self.copy) 25 | self.bind_event_after("cut", self.copy) 26 | 27 | def copy(self, event): 28 | lines = self.app.get_editor().get_buffer() 29 | data = "\n".join([str(line) for line in lines]) 30 | self.set_clipboard(data) 31 | 32 | def insert(self, event): 33 | data = self.get_clipboard() 34 | lines = data.split("\n") 35 | self.app.get_editor().set_buffer(lines) 36 | 37 | def get_clipboard(self): 38 | try: 39 | if self.clipboard_type == "xsel": 40 | command = ["xsel", "-b"] 41 | elif self.clipboard_type == "pb": 42 | command = ["pbpaste", "-Prefer", "txt"] 43 | elif self.clipboard_type == "xclip": 44 | command = ["xclip", "-selection", "clipboard", "-out"] 45 | else: 46 | return False 47 | data = subprocess.check_output(command, universal_newlines=True) 48 | return data 49 | except: 50 | return False 51 | 52 | def set_clipboard(self, data): 53 | try: 54 | if self.clipboard_type == "xsel": 55 | command = ["xsel", "-i", "-b"] 56 | elif self.clipboard_type == "pb": 57 | command = ["pbcopy"] 58 | elif self.clipboard_type == "xclip": 59 | command = ["xclip", "-selection", "clipboard", "-in"] 60 | else: 61 | return False 62 | p = subprocess.Popen(command, stdin=subprocess.PIPE) 63 | out, err = p.communicate(input=bytes(data, "utf-8")) 64 | return out 65 | except: 66 | return False 67 | 68 | def has_pb_support(self): 69 | output = self.get_output(["which", "pbcopy"]) 70 | return output 71 | 72 | def has_xsel_support(self): 73 | output = self.get_output(["xsel", "--version"]) 74 | return output 75 | 76 | def has_xclip_support(self): 77 | output = self.get_output(["which", "xclip"]) # xclip -version outputs to stderr 78 | return output 79 | 80 | def get_output(self, cmd): 81 | try: 82 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE) 83 | except (OSError, EnvironmentError): # can't use FileNotFoundError in Python 2 84 | return False 85 | out, err = process.communicate() 86 | return out 87 | 88 | 89 | module = { 90 | "class": SystemClipboard, 91 | "name": "system_clipboard", 92 | } 93 | -------------------------------------------------------------------------------- /suplemon/modules/tabstospaces.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class TabsToSpaces(Module): 7 | """Convert tab characters to spaces in the entire file.""" 8 | 9 | def run(self, app, editor, args): 10 | for i, line in enumerate(editor.lines): 11 | new = line.data.replace("\t", " "*editor.config["tab_width"]) 12 | editor.lines[i].set_data(new) 13 | 14 | 15 | module = { 16 | "class": TabsToSpaces, 17 | "name": "tabstospaces", 18 | } 19 | -------------------------------------------------------------------------------- /suplemon/modules/toggle_whitespace.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class ToggleWhitespace(Module): 7 | """Toggle visually showing whitespace.""" 8 | 9 | def run(self, app, editor, args): 10 | # Toggle the boolean 11 | new_value = not self.app.config["editor"]["show_white_space"] 12 | self.app.config["editor"]["show_white_space"] = new_value 13 | 14 | 15 | module = { 16 | "class": ToggleWhitespace, 17 | "name": "toggle_whitespace", 18 | } 19 | -------------------------------------------------------------------------------- /suplemon/modules/upper.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | from suplemon.suplemon_module import Module 4 | 5 | 6 | class Upper(Module): 7 | """Transform current lines to upper case.""" 8 | 9 | def run(self, app, editor, args): 10 | line_nums = [] 11 | for cursor in editor.cursors: 12 | if cursor.y not in line_nums: 13 | line_nums.append(cursor.y) 14 | data = editor.lines[cursor.y].get_data().upper() 15 | editor.lines[cursor.y].set_data(data) 16 | 17 | 18 | module = { 19 | "class": Upper, 20 | "name": "upper", 21 | } 22 | -------------------------------------------------------------------------------- /suplemon/prompt.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Promts based on the Editor class for querying user input. 4 | """ 5 | 6 | import os 7 | 8 | from .editor import Editor 9 | from .line import Line 10 | 11 | 12 | # Python 2 compatibility 13 | try: 14 | FileNotFoundError 15 | except NameError: 16 | FileNotFoundError = IOError 17 | 18 | 19 | class Prompt(Editor): 20 | """An input prompt based on the Editor.""" 21 | def __init__(self, app, window): 22 | Editor.__init__(self, app, window) 23 | self.ready = False 24 | self.canceled = False 25 | self.input_func = lambda: False 26 | self.caption = "" 27 | 28 | def init(self): 29 | Editor.init(self) 30 | # Remove the find feature, otherwise it can be invoked recursively 31 | del self.operations["find"] 32 | 33 | def set_config(self, config): 34 | """Set the configuration for the editor.""" 35 | # Override showing line numbers 36 | config["show_line_nums"] = False 37 | Editor.set_config(self, config) 38 | 39 | def set_input_source(self, input_func): 40 | # Set the input function to use while looping for input 41 | self.input_func = input_func 42 | 43 | def on_ready(self): 44 | """Accepts the current input.""" 45 | self.ready = True 46 | return 47 | 48 | def on_cancel(self): 49 | """Cancels the input prompt.""" 50 | self.set_data("") 51 | self.ready = True 52 | self.canceled = True 53 | return 54 | 55 | def line_offset(self): 56 | """Get the x coordinate of beginning of line.""" 57 | return len(self.caption)+1 58 | 59 | def render_line_contents(self, line, pos, x_offset, max_len): 60 | """Render the prompt line.""" 61 | x_offset = self.line_offset() 62 | # Render the caption 63 | self.window.addstr(pos[1], 0, self.caption) 64 | # Render input 65 | self.render_line_normal(line, pos, x_offset, max_len) 66 | 67 | def handle_input(self, event): 68 | """Handle special bindings for the prompt.""" 69 | if event.key_name in ["ctrl+c", "escape"]: 70 | self.on_cancel() 71 | return False 72 | if event.key_name == "enter": 73 | self.on_ready() 74 | return False 75 | 76 | return Editor.handle_input(self, event) 77 | 78 | def get_input(self, caption="", initial=""): 79 | """Get text input from the user via the prompt.""" 80 | self.caption = caption 81 | self.set_data(initial) 82 | self.end() # Move to the end of the initial text 83 | 84 | self.refresh() 85 | success = self.input_loop() 86 | if success: 87 | return self.get_data() 88 | return False 89 | 90 | def input_loop(self): 91 | # Run the input loop until ready 92 | while not self.ready: 93 | event = self.input_func(True) # blocking 94 | if event: 95 | self.handle_input(event) 96 | self.refresh() 97 | if not self.canceled: 98 | return True 99 | 100 | 101 | class PromptBool(Prompt): 102 | """An input prompt for booleans based on Prompt.""" 103 | 104 | def set_value(self, value): 105 | self.value = value 106 | 107 | def handle_input(self, event): 108 | """Handle special bindings for the prompt.""" 109 | name = event.key_name 110 | if name.lower() == "y": 111 | self.set_value(True) 112 | self.on_ready() 113 | return False 114 | if name.lower() == "n": 115 | self.set_value(False) 116 | self.on_ready() 117 | return False 118 | Prompt.handle_input(self, event) 119 | 120 | def get_input(self, caption="", initial=False): 121 | """Get a boolean value from the user via the prompt.""" 122 | 123 | indicator = "[y/N]" 124 | if initial: 125 | indicator = "[Y/n]" 126 | 127 | self.caption = caption + " " + indicator 128 | self.set_value(initial) 129 | self.end() # Move to the end of the initial text 130 | 131 | self.refresh() 132 | 133 | # Run the input loop until ready 134 | success = self.input_loop() 135 | if success: 136 | return self.value 137 | return False 138 | 139 | 140 | class PromptPassword(Prompt): 141 | """An input prompt for passwords based on Prompt.""" 142 | 143 | def render_line_contents(self, line, pos, x_offset, max_len): 144 | obscured = Line("*" * len(line)) 145 | Prompt.render_line_contents(self, obscured, pos, x_offset, max_len) 146 | 147 | 148 | class PromptFiltered(Prompt): 149 | """An input prompt that allows intercepting and filtering input events.""" 150 | 151 | def __init__(self, app, window, handler=None): 152 | Prompt.__init__(self, app, window) 153 | self.prompt_handler = handler 154 | 155 | def handle_input(self, event): 156 | """Handle special bindings for the prompt.""" 157 | # The cancel and accept keys are kept for concistency 158 | if event.key_name in ["ctrl+c", "escape"]: 159 | self.on_cancel() 160 | return False 161 | if event.key_name == "enter": 162 | self.on_ready() 163 | return False 164 | 165 | if self.prompt_handler and self.prompt_handler(self, event): 166 | # If the prompt handler returns True the default action is skipped 167 | return True 168 | 169 | return Editor.handle_input(self, event) 170 | 171 | 172 | class PromptAutocmp(Prompt): 173 | """An input prompt with basic autocompletion.""" 174 | 175 | def __init__(self, app, window, initial_items=[]): 176 | Prompt.__init__(self, app, window) 177 | # Whether the autocomplete feature is active 178 | self.complete_active = False 179 | # Index of last item that was autocompleted 180 | self.complete_index = 0 181 | # Input data to use for autocompletion (stored when autocompletion is activated) 182 | self.complete_data = "" 183 | # Default autocompletable items 184 | self.complete_items = initial_items 185 | 186 | def handle_input(self, event): 187 | """Handle special bindings for the prompt.""" 188 | name = event.key_name 189 | if self.complete_active: 190 | # Allow accepting completed directories with enter 191 | if name == "enter": 192 | if self.has_match(): 193 | self.deactivate_autocomplete() 194 | return False 195 | # Revert autocompletion with esc 196 | if name == "escape": 197 | self.revert_autocomplete() 198 | self.deactivate_autocomplete() 199 | return False 200 | if name == "tab": 201 | # Run autocompletion when tab is pressed 202 | self.autocomplete() 203 | # Don't pass the event to the parent class 204 | return False 205 | elif name == "shift+tab": 206 | # Go to previous item when shift+tab is pressed 207 | self.autocomplete(previous=True) 208 | # Don't pass the event to the parent class 209 | return False 210 | else: 211 | # If any key other than tab is pressed deactivate the autocompleter 212 | self.deactivate_autocomplete() 213 | Prompt.handle_input(self, event) 214 | 215 | def autocomplete(self, previous=False): 216 | data = self.get_data() # Use the current input by default 217 | if self.complete_active: # If the completer is active use the its initial input value 218 | data = self.complete_data 219 | 220 | name = self.get_completable_name(data) 221 | items = self.get_completable_items(data) 222 | 223 | # Filter the items by name if the input path contains a name 224 | if name: 225 | items = self.filter_items(items, name) 226 | if not items: 227 | # Deactivate completion if there's nothing to complete 228 | self.deactivate_autocomplete() 229 | return False 230 | 231 | if not self.complete_active: 232 | # Initialize the autocompletor 233 | self.complete_active = True 234 | self.complete_data = data 235 | self.complete_index = 0 236 | else: 237 | # Increment the selected item countor 238 | if previous: 239 | # Go back 240 | self.complete_index -= 1 241 | if self.complete_index < 0: 242 | self.complete_index = len(items)-1 243 | else: 244 | # Go forward 245 | self.complete_index += 1 246 | if self.complete_index > len(items)-1: 247 | self.complete_index = 0 248 | 249 | item = items[self.complete_index] 250 | new_data = self.get_full_completion(data, item) 251 | if len(items) == 1: 252 | self.deactivate_autocomplete() 253 | # Set the input data to the completion and move cursor to the end 254 | self.set_data(new_data) 255 | self.end() 256 | 257 | def get_completable_name(self, data=""): 258 | return data 259 | 260 | def get_completable_items(self, data=""): 261 | return self.complete_items 262 | 263 | def get_full_completion(self, data, item): 264 | return item 265 | 266 | def has_match(self): 267 | return False 268 | 269 | def deactivate_autocomplete(self): 270 | self.complete_active = False 271 | self.complete_index = 0 272 | self.complete_data = "" 273 | 274 | def revert_autocomplete(self): 275 | if self.complete_data: 276 | self.set_data(self.complete_data) 277 | 278 | def filter_items(self, items, name): 279 | """Remove items that don't begin with name. Not case sensitive.""" 280 | if not name: 281 | return items 282 | name = name.lower() 283 | return [item for item in items if item.lower().startswith(name)] 284 | 285 | 286 | class PromptFile(PromptAutocmp): 287 | """An input prompt with path autocompletion based on PromptAutocmp.""" 288 | 289 | def __init__(self, app, window): 290 | PromptAutocmp.__init__(self, app, window) 291 | 292 | def has_match(self): 293 | return os.path.isdir(os.path.expanduser(self.get_data())) 294 | 295 | def get_completable_name(self, data=""): 296 | return os.path.basename(data) 297 | 298 | def get_completable_items(self, data=""): 299 | return self.get_path_contents(data) # Get directory listing of input path 300 | 301 | def get_full_completion(self, data, item): 302 | return os.path.join(os.path.dirname(data), item) 303 | 304 | def get_path_contents(self, path): 305 | path = os.path.dirname(os.path.expanduser(path)) 306 | # If we get an empty path use the current directory 307 | if not path: 308 | try: 309 | path = os.getcwd() 310 | except FileNotFoundError: 311 | # This might happen if the cwd has been 312 | # removed after starting suplemon. 313 | return [] 314 | # In case we don't have sufficent permissions 315 | try: 316 | contents = os.listdir(path) 317 | except: 318 | return [] 319 | contents.sort() 320 | items = [] 321 | # Append directory separator to directories 322 | for item in contents: 323 | if os.path.isdir(os.path.join(path, item)): 324 | item = item + os.sep 325 | items.append(item) 326 | return items 327 | -------------------------------------------------------------------------------- /suplemon/suplemon_module.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Base class for extension modules to inherit. 4 | """ 5 | 6 | import os 7 | import json 8 | import logging 9 | 10 | 11 | class Storage: 12 | """ 13 | Semi-automatic key/value store for suplemon modules. 14 | """ 15 | def __init__(self, module): 16 | self.data = {} 17 | self.automatic = False 18 | self.module = module 19 | self.data_subdir = "modules" 20 | self.extension = "json" 21 | self.storage_dir = os.path.join(self.module.app.config.config_dir, self.data_subdir) 22 | if not os.path.exists(self.storage_dir): 23 | try: 24 | os.makedirs(self.storage_dir) 25 | except: 26 | self.module.app.logger.warning( 27 | "Module storage folder '{0}' doesn't exist and couldn't be created.".format( 28 | self.storage_dir)) 29 | 30 | def __getitem__(self, i): 31 | """Get a storage key value.""" 32 | return self.data[i] 33 | 34 | def __setitem__(self, i, v): 35 | """Set a storage key value.""" 36 | self.data[i] = v 37 | if self.automatic: 38 | self.store() 39 | 40 | def __str__(self): 41 | """Convert entire data dict to string.""" 42 | return str(self.data) 43 | 44 | def __len__(self): 45 | """Return length of data dict.""" 46 | return len(self.data) 47 | 48 | def keys(self): 49 | return self.data.keys() 50 | 51 | def items(self): 52 | return self.data.items() 53 | 54 | def get_path(self): 55 | """Get the storage file path.""" 56 | if self.module.get_name(): 57 | return os.path.join(self.storage_dir, self.module.get_name() + "." + self.extension) 58 | return False 59 | 60 | def get_data(self): 61 | """Get the storage data.""" 62 | return self.data 63 | 64 | def set_data(self, data): 65 | """Set the storage data.""" 66 | self.data = data 67 | 68 | def set_automatic(self, auto): 69 | """Set wether the data should be stored automatically when changed.""" 70 | self.automatic = auto 71 | 72 | def store(self): 73 | """Store the storage data to disk.""" 74 | try: 75 | data = json.dumps(self.data) 76 | f = open(self.get_path(), "w") 77 | f.write(data) 78 | f.close() 79 | except: 80 | self.module.logger.exception("Storing module storage failed.") 81 | 82 | def load(self): 83 | """Load the storage data from disk.""" 84 | if os.path.exists(self.get_path()): 85 | try: 86 | f = open(self.get_path()) 87 | data = f.read() 88 | f.close() 89 | except: 90 | self.module.logger.debug("Loading module storage failed.") 91 | return False 92 | else: 93 | return False 94 | 95 | try: 96 | self.data = json.loads(data) 97 | return True 98 | except: 99 | self.module.logger.exception("Parsing module storage failed.") 100 | return False 101 | 102 | 103 | class Module: 104 | def __init__(self, app, name, options=None): 105 | self.app = app 106 | self.name = name 107 | self.options = options 108 | self.logger = None 109 | self.storage = Storage(self) 110 | self.init_logging(self.get_name()) 111 | self.storage.load() 112 | self.init() 113 | 114 | def init(self): 115 | """Initialize the module. 116 | 117 | This function is run when the module is loaded and can be 118 | reimplemented for module specific initializations. 119 | """ 120 | 121 | def get_name(self): 122 | """Get module name.""" 123 | return self.name 124 | 125 | def get_options(self): 126 | """Get module options.""" 127 | return self.options 128 | 129 | def get_status(self): 130 | """Called by app when to get status bar contents.""" 131 | return "" 132 | 133 | def set_name(self, name): 134 | """Set module name.""" 135 | self.name = name 136 | 137 | def set_options(self, options): 138 | """Set module options.""" 139 | self.options = options 140 | 141 | def get_default_config(self): 142 | """Return module default config dict""" 143 | return {} 144 | 145 | def is_runnable(self): 146 | cls_method = getattr(Module, "run") 147 | return self.run.__module__ != cls_method.__module__ 148 | 149 | def init_logging(self, name): 150 | """Initialize the module logger (self.logger). 151 | 152 | Should be called before the module uses logging. 153 | Always pass __name__ as the value to name, for consistency. 154 | 155 | Args: 156 | :param name: should be specified as __name__ 157 | """ 158 | if not self.logger: 159 | self.logger = logging.getLogger("module.{0}".format(name)) 160 | 161 | def run(self, app, editor, args): 162 | """This is called each time the module is run. 163 | 164 | Called when command is issued via prompt or key binding. 165 | 166 | Args: 167 | :param app: the app instance 168 | :param editor: the current editor instance 169 | """ 170 | pass 171 | 172 | def bind_key(self, key): 173 | """Shortcut for binding run method to a key. 174 | 175 | Args: 176 | :param key: 177 | """ 178 | self.app.set_key_binding(key, self._proxy_run) 179 | 180 | def bind_event(self, event, callback): 181 | """Bind a callback to be called before event is run. 182 | 183 | If the callback returns True the event will be canceled. 184 | 185 | :param event: The event name 186 | :param callback: Function to be called 187 | """ 188 | self.app.set_event_binding(event, "before", callback) 189 | 190 | def bind_event_before(self, event, callback): 191 | """Bind a callback to be called before event is run. 192 | 193 | If the callback returns True the event will be canceled. 194 | 195 | :param event: The event name 196 | :param callback: Function to be called 197 | """ 198 | self.app.set_event_binding(event, "before", callback) 199 | 200 | def bind_event_after(self, event, callback): 201 | """Bind a callback to be called after event is run. 202 | 203 | :param event: The event name 204 | :param callback: Function to be called 205 | """ 206 | self.app.set_event_binding(event, "after", callback) 207 | 208 | def _proxy_run(self): 209 | """Calls the run method with necessary arguments.""" 210 | self.run(self.app, self.app.get_editor(), "") 211 | -------------------------------------------------------------------------------- /suplemon/themes.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | """ 3 | Theme loader 4 | """ 5 | import os 6 | import logging 7 | 8 | try: 9 | import xml.etree.cElementTree as ET 10 | except: 11 | import xml.etree.ElementTree as ET 12 | 13 | from . import hex2xterm 14 | 15 | # Map scope name to its color pair index 16 | scope_to_pair = { 17 | "global": 21, 18 | "comment": 22, 19 | "string": 23, 20 | "constant.numeric": 24, 21 | "constant.language": 25, 22 | "constant.character": 26, 23 | "constant.other": 27, 24 | "variable": 28, 25 | "keyword": 29, 26 | "storage": 30, 27 | "storage.type": 31, 28 | "entity.name.class": 32, 29 | "entity.other.inherited-class": 33, 30 | "entity.name.function": 34, 31 | "variable.parameter": 35, 32 | "entity.name.tag": 36, 33 | "entity.other.attribute-name": 37, 34 | "support.function": 38, 35 | "support.constant": 39, 36 | "support.type": 40, 37 | "support.class": 41, 38 | "support.other.variable": 42, 39 | "invalid": 43, 40 | "invalid.deprecated": 44, 41 | "meta.structure.dictionary.json string.quoted.double.json": 45, 42 | "meta.diff": 46, 43 | "meta.diff.header": 47, 44 | "markup.deleted": 48, 45 | "markup.inserted": 49, 46 | "markup.changed": 50, 47 | "constant.numeric.line-number.find-in-files - match": 51, 48 | "entity.name.filename.find-in-files": 52, 49 | } 50 | 51 | 52 | class Theme: 53 | def __init__(self, name, uuid): 54 | self.name = name 55 | self.uuid = uuid 56 | self.scopes = {} 57 | 58 | 59 | class ThemeLoader: 60 | def __init__(self, app=None): 61 | self.app = app 62 | self.logger = logging.getLogger(__name__) 63 | 64 | self.curr_path = os.path.dirname(os.path.realpath(__file__)) 65 | # The themes subdirectory 66 | self.theme_path = os.path.join(self.curr_path, "themes" + os.sep) 67 | # Module instances 68 | self.themes = {} 69 | self.current_theme = None 70 | 71 | def load(self, name): 72 | fullpath = "".join([self.theme_path, name, ".tmTheme"]) 73 | if not os.path.exists(fullpath): 74 | self.logger.warning("fullpath '{0}' doesn't exist!".format(fullpath)) 75 | return None 76 | 77 | self.logger.debug("Loading theme '{0}'.".format(name)) 78 | try: 79 | tree = ET.parse(fullpath) 80 | except: 81 | self.logger.error("Couldn't parse theme '{}'. Falling back to line based highlighting.".format(name)) 82 | return None 83 | root = tree.getroot() 84 | 85 | for child in root: 86 | config = self.parse(child) 87 | if config is None: 88 | return None 89 | 90 | name = config.get("name") 91 | uuid = config.get("uuid") 92 | 93 | theme = Theme(name, uuid) 94 | 95 | try: 96 | settings = config["settings"] 97 | except: 98 | return None 99 | self.set_theme(theme, settings) 100 | 101 | return theme 102 | 103 | def use(self, name): 104 | theme = None 105 | try: 106 | theme = self.themes[name] 107 | except: 108 | theme = self.load(name) 109 | self.themes[name] = theme 110 | if theme is None: 111 | return 112 | self.current_theme = theme 113 | 114 | def get_scope(self, name): 115 | if self.current_theme: 116 | return self.current_theme.scopes.get(name) 117 | return False 118 | 119 | def set_theme(self, theme, settings): 120 | for entry in settings: 121 | if not isinstance(entry, dict): 122 | continue 123 | scope_str = entry.get("scope") or "global" 124 | scopes = scope_str.split(",") 125 | settings = entry.get("settings") 126 | if settings is not None: 127 | for scope in scopes: 128 | theme.scopes[scope.strip()] = settings 129 | 130 | def parse(self, node): 131 | if node.tag == "dict": 132 | return self.parse_dict(node) 133 | elif node.tag == "array": 134 | return self.parse_array(node) 135 | elif node.tag == "string": 136 | return self.parse_text(node) 137 | 138 | return None 139 | 140 | def parse_dict(self, node): 141 | d = {} 142 | key = None 143 | value = None 144 | 145 | for child in node: 146 | if key is None: 147 | if child.tag != "key": # key expected 148 | return None 149 | key = child.text 150 | else: 151 | value = self.parse(child) 152 | if value is not None: 153 | d[key] = value 154 | 155 | key = None 156 | value = None 157 | 158 | return d 159 | 160 | def parse_array(self, node): 161 | l = [] 162 | for child in node: 163 | value = self.parse(child) 164 | if value is not None: 165 | l.append(value) 166 | 167 | return l 168 | 169 | def parse_text(self, node): 170 | # If the node text is a hex color convert it to an xterm equivalent 171 | if node.text: 172 | color = self.convert_color(node.text) 173 | if color is not False: 174 | return color 175 | 176 | return node.text 177 | 178 | def convert_color(self, text): 179 | if text[0] != "#": 180 | return False 181 | # Validate length e.g. #F90, #FF9900, #FF990055 182 | if len(text) not in [4, 7, 9]: 183 | return False 184 | # Strip alpha channel 185 | hex_color = text 186 | if len(hex_color) == 9: 187 | hex_color = hex_color[:7] 188 | # If the color only has one character per channel convert them to two 189 | if len(hex_color) == 4: 190 | hex_color = "#{}{}{}".format(hex_color[1:2]*2, hex_color[2:3]*2, hex_color[3:4]*2) 191 | # Convert the color 192 | try: 193 | xterm_color = int(hex2xterm.hex_to_xterm(hex_color)) 194 | return xterm_color 195 | except: 196 | self.logger.exception("Failed to convert color: {}".format(text)) 197 | 198 | return False 199 | -------------------------------------------------------------------------------- /suplemon/themes/8colors.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | name 5 | Monokai 6 | settings 7 | 8 | 9 | settings 10 | 11 | caret 12 | 7 13 | foreground 14 | 7 15 | 16 | 17 | 18 | name 19 | Comment 20 | scope 21 | comment 22 | settings 23 | 24 | foreground 25 | 5 26 | 27 | 28 | 29 | name 30 | String 31 | scope 32 | string 33 | settings 34 | 35 | foreground 36 | 3 37 | 38 | 39 | 40 | name 41 | Number 42 | scope 43 | constant.numeric 44 | settings 45 | 46 | foreground 47 | 7 48 | 49 | 50 | 51 | 52 | name 53 | Built-in constant 54 | scope 55 | constant.language 56 | settings 57 | 58 | foreground 59 | 6 60 | 61 | 62 | 63 | name 64 | User-defined constant 65 | scope 66 | constant.character, constant.other 67 | settings 68 | 69 | foreground 70 | 7 71 | 72 | 73 | 74 | name 75 | Variable 76 | scope 77 | variable 78 | settings 79 | 80 | foreground 81 | 7 82 | fontStyle 83 | 84 | 85 | 86 | 87 | name 88 | Keyword 89 | scope 90 | keyword 91 | settings 92 | 93 | foreground 94 | 3 95 | 96 | 97 | 98 | name 99 | Storage 100 | scope 101 | storage 102 | settings 103 | 104 | fontStyle 105 | 106 | foreground 107 | 7 108 | 109 | 110 | 111 | name 112 | Storage type 113 | scope 114 | storage.type 115 | settings 116 | 117 | fontStyle 118 | italic 119 | foreground 120 | 7 121 | 122 | 123 | 124 | name 125 | Class name 126 | scope 127 | entity.name.class 128 | settings 129 | 130 | fontStyle 131 | italic 132 | foreground 133 | 2 134 | 135 | 136 | 137 | name 138 | Inherited class 139 | scope 140 | entity.other.inherited-class 141 | settings 142 | 143 | fontStyle 144 | italic underline 145 | foreground 146 | 2 147 | 148 | 149 | 150 | name 151 | Function name 152 | scope 153 | entity.name.function 154 | settings 155 | 156 | fontStyle 157 | italic 158 | foreground 159 | 6 160 | 161 | 162 | 163 | name 164 | Function argument 165 | scope 166 | variable.parameter 167 | settings 168 | 169 | fontStyle 170 | italic 171 | foreground 172 | 7 173 | 174 | 175 | 176 | uuid 177 | D8D5E82E-3D5B-46B5-B38E-8C841C21347D 178 | 179 | 180 | -------------------------------------------------------------------------------- /suplemon/themes/monokai.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | name 5 | Monokai 6 | settings 7 | 8 | 9 | settings 10 | 11 | caret 12 | 254 13 | foreground 14 | 254 15 | invisibles 16 | 237 17 | lineHighlight 18 | 237 19 | selection 20 | 239 21 | findHighlight 22 | 15 23 | findHighlightForeground 24 | 16 25 | selectionBorder 26 | 235 27 | 28 | 29 | 30 | name 31 | Comment 32 | scope 33 | comment 34 | settings 35 | 36 | foreground 37 | 243 38 | 39 | 40 | 41 | name 42 | String 43 | scope 44 | string 45 | settings 46 | 47 | foreground 48 | 228 49 | 50 | 51 | 52 | name 53 | Number 54 | scope 55 | constant.numeric 56 | settings 57 | 58 | foreground 59 | 135 60 | 61 | 62 | 63 | 64 | name 65 | Built-in constant 66 | scope 67 | constant.language 68 | settings 69 | 70 | foreground 71 | 135 72 | 73 | 74 | 75 | name 76 | User-defined constant 77 | scope 78 | constant.character, constant.other 79 | settings 80 | 81 | foreground 82 | 153 83 | 84 | 85 | 86 | name 87 | Variable 88 | scope 89 | variable 90 | settings 91 | 92 | foreground 93 | 153 94 | fontStyle 95 | 96 | 97 | 98 | 99 | name 100 | Keyword 101 | scope 102 | keyword 103 | settings 104 | 105 | foreground 106 | 161 107 | 108 | 109 | 110 | name 111 | Storage 112 | scope 113 | storage 114 | settings 115 | 116 | fontStyle 117 | 118 | foreground 119 | 75 120 | 121 | 122 | 123 | name 124 | Storage type 125 | scope 126 | storage.type 127 | settings 128 | 129 | fontStyle 130 | italic 131 | foreground 132 | 75 133 | 134 | 135 | 136 | name 137 | Class name 138 | scope 139 | entity.name.class 140 | settings 141 | 142 | fontStyle 143 | italic 144 | foreground 145 | 118 146 | 147 | 148 | 149 | name 150 | Tag name 151 | scope 152 | entity.name.tag 153 | settings 154 | 155 | fontStyle 156 | italic 157 | foreground 158 | 81 159 | 160 | 161 | 162 | name 163 | Inherited class 164 | scope 165 | entity.other.inherited-class 166 | settings 167 | 168 | fontStyle 169 | italic underline 170 | foreground 171 | 118 172 | 173 | 174 | 175 | name 176 | Function name 177 | scope 178 | entity.name.function 179 | settings 180 | 181 | fontStyle 182 | italic 183 | foreground 184 | 118 185 | 186 | 187 | 188 | name 189 | Function argument 190 | scope 191 | variable.parameter 192 | settings 193 | 194 | fontStyle 195 | italic 196 | foreground 197 | 214 198 | 199 | 200 | 201 | uuid 202 | D8D5E82E-3D5B-46B5-B38E-8C841C21347D 203 | 204 | 205 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit upon first failure 3 | set -e 4 | 5 | # Allow for skipping lint during development 6 | if test "$SKIP_LINT" != "TRUE"; then 7 | flake8 *.py suplemon/ 8 | fi 9 | 10 | # Run our tests 11 | # python setup.py nosetests 12 | --------------------------------------------------------------------------------