├── .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 | [](https://travis-ci.org/richrd/suplemon) [](https://webchat.freenode.net/?channels=%23suplemon)
5 | [](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 | 
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: [](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 |
--------------------------------------------------------------------------------