diff --git a/README.md b/README.md index 2c60c95..eff0a22 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ http://github.com/richrd/suplemon ![Suplemon in action](http://bittemple.org/misc/suplemon/suplemon-demo.gif) -## Get it! +## Try it! You can just clone the repo, and try Suplemon, or also install it system wide. @@ -97,6 +97,7 @@ Suplemon is licensed under the MIT license. ## Configuration +### Main Config The suplemon config file is stored at ```~/.config/suplemon/suplemon-config.json```. The best way to edit it is to run the ```config``` command (Run commands via ```Ctrl+E```). @@ -104,8 +105,10 @@ That way Suplemon will automatically reload the configuration when you save the To view the default configuration and see what options are available run ```config defaults``` via ```Ctrl+E```. +### Keymap Config -## Keyboard shortcuts +Below are the default key mappings used in suplemon. They can be edited by running the ```keymap``` command. +To view the default keymap file run ```keymap default``` * Ctrl + Q > Exit diff --git a/suplemon/config.py b/suplemon/config.py index 0d93a0a..e6ea4af 100644 --- a/suplemon/config.py +++ b/suplemon/config.py @@ -8,18 +8,22 @@ import logging from . import helpers +from . import suplemon_module class Config: def __init__(self, app): self.app = app self.logger = logging.getLogger(__name__) - self.default_filename = "defaults.json" + self.default_config_filename = "defaults.json" + self.default_keymap_filename = "keymap.json" self.config_filename = "suplemon-config.json" + self.keymap_filename = "suplemon-keymap.json" self.home_dir = os.path.expanduser("~") self.fpath = os.path.join(self.home_dir, ".config", "suplemon") self.defaults = {} + self.keymap = {} self.config = {} def init(self): @@ -29,6 +33,9 @@ def init(self): def path(self): return os.path.join(self.fpath, self.config_filename) + def keymap_path(self): + return os.path.join(self.fpath, self.keymap_filename) + def set_path(self, path): parts = os.path.split(path) self.fpath = parts[0] @@ -48,17 +55,43 @@ def load(self): self.logger.info("Failed to load config file '{0}'.".format(path)) self.config = dict(self.defaults) return False + self.load_keys() return config - def load_defaults(self): - path = os.path.join(self.app.path, "config", self.default_filename) + def load_keys(self): + path = self.keymap_path() + keymap = False if not os.path.exists(path): + self.logger.debug("Keymap file '{0}' doesn't exist.".format(path)) + return False + keymap = self.load_config_file(path) + if not keymap: + self.logger.info("Failed to load keymap file '{0}'.".format(path)) return False - defaults = self.load_config_file(path) - if not defaults: - self.logger.warning("Failed to load default config file! ('{0}')".format(path)) + self.keymap += keymap # Append the user key map + return True + + def load_defaults(self): + if not self.load_default_config() or not self.load_default_keys(): + return False + return True + + def load_default_config(self): + path = os.path.join(self.app.path, "config", self.default_config_filename) + config = self.load_config_file(path) + if not config: + self.logger.error("Failed to load default config file '{0}'!".format(path)) + return False + self.defaults = config + return True + + def load_default_keys(self): + path = os.path.join(self.app.path, "config", self.default_keymap_filename) + config = self.load_config_file(path) + if not config: + self.logger.error("Failed to load default keymap file '{0}'!".format(path)) return False - self.defaults = defaults + self.keymap = config return True def reload(self): @@ -82,20 +115,8 @@ def merge_defaults(self, config): for sec_key in curr_item.keys(): if sec_key not in config[prim_key].keys(): config[prim_key][sec_key] = curr_item[sec_key] - self.merge_keys(config) return config - def merge_keys(self, config): - """Fill in config with default keys.""" - # Do merge for app and editor keys - for dest in ["app", "editor"]: - key_config = config[dest]["keys"] - key_defaults = self.defaults[dest]["keys"] - for key in key_defaults.keys(): - # Fill in each key that's not defined yet - if key not in key_config.keys(): - key_config[key] = key_defaults[key] - def load_config_file(self, path): try: f = open(path) @@ -147,3 +168,37 @@ def __str__(self): def __len__(self): """Return length of top level config variables.""" return len(self.config) + + +class ConfigModule(suplemon_module.Module): + """Helper for shortcut for openning config files.""" + def init(self): + self.conf_name = "defaults.json" + self.conf_default_path = os.path.join(self.app.path, "config", self.conf_name) + self.conf_user_path = self.app.config.path() + + def run(self, app, editor, args): + if args == "defaults": + # Open the default config in a new file only for viewing + self.open(app, self.conf_default_path, read_only=True) + else: + self.open(app, self.conf_user_path) + + def open(self, app, path, read_only=False): + if read_only: + f = open(path) + data = f.read() + f.close() + file = app.new_file() + file.set_name(self.conf_name) + file.set_data(data) + app.switch_to_file(app.last_file_index()) + else: + # Open the user config file for editing + f = app.file_is_open(path) + if f: + app.switch_to_file(app.get_file_index(f)) + else: + if not app.open_file(path): + app.new_file(path) + app.switch_to_file(app.last_file_index()) diff --git a/suplemon/config/defaults.json b/suplemon/config/defaults.json index 7404405..260501a 100644 --- a/suplemon/config/defaults.json +++ b/suplemon/config/defaults.json @@ -19,27 +19,7 @@ // How long curses will wait to detect ESC key "escdelay": 50, // Wether to use special unicode symbols for decoration - "use_unicode_symbols": true, - // Key bindings for app functions - "keys": { - "ctrl+h": "help", - "ctrl+s": "save_file", - "ctrl+e": "run_command", - "ctrl+f": "find", - "ctrl+g": "go_to", - "ctrl+o": "open", - "ctrl+w": "close_file", - "ctrl+n": "new_file", - "ctrl+q": "ask_exit", - "ctrl+p": "comment", - "ctrl+pageup": "next_file", - "ctrl+pagedown": "prev_file", - "f1": "save_file_as", - "f2": "reload_file", - "f7": "toggle_whitespace", - "f8": "toggle_mouse", - "f11": "toggle_fullscreen" - } + "use_unicode_symbols": true }, // Editor settings "editor": { @@ -78,6 +58,10 @@ }, // Wether to visually show white space chars "show_white_space": false, + // Show tab indicators in whitespace + "show_tab_indicators": true, + // Tab indicator charatrer + "tab_indicator_character": "\u203A", // Line numbering "show_line_nums": true, // Naive line highlighting @@ -91,46 +75,7 @@ // Wether to use copy/paste across multiple files "use_global_buffer": true, // Find with regex by default - "regex_find": false, - // Key bindings for editor functions - "keys": { - "up": "arrow_up", - "down": "arrow_down", - "left": "arrow_left", - "right": "arrow_right", - "enter": "enter", - "backspace": "backspace", - "delete": "delete", - "insert": "insert", - "tab": "tab", - "shift+tab": "untab", - "home": "home", - "end": "end", - "escape": "escape", - "pageup": "page_up", - "pagedown": "page_down", - "f5": "undo", - "f6": "redo", - "f9": "toggle_line_nums", - "f10": "toggle_line_ends", - "f11": "toggle_highlight", - "alt+up": "new_cursor_up", - "alt+down": "new_cursor_down", - "alt+left": "new_cursor_left", - "alt+right": "new_cursor_right", - "alt+pageup": "push_up", - "alt+pagedown": "push_down", - "ctrl+c": "copy", - "ctrl+x": "cut", - "ctrl+k": "duplicate_line", - "ctrl+v": "insert", - "ctrl+d": "find_next", - "ctrl+a": "find_all", - "ctrl+left": "jump_left", - "ctrl+right": "jump_right", - "ctrl+up": "jump_up", - "ctrl+down": "jump_down" - } + "regex_find": false }, // UI Display Settings "display": { @@ -148,6 +93,6 @@ // Show the bottom status bar "show_bottom_bar": true, // Invert status bar colors (switch text and background colors) - "invert_status_bars": true + "invert_status_bars": false } } \ No newline at end of file diff --git a/suplemon/config/keymap.json b/suplemon/config/keymap.json new file mode 100644 index 0000000..90190b3 --- /dev/null +++ b/suplemon/config/keymap.json @@ -0,0 +1,63 @@ +// Suplemon Default Key Map + +// This file contains the default key map for Suplemon and should not be edited. +// If the file doesn't exist, or if it has errors Suplemon can't run. +// Suplemon supports single line comments in JSON as seen here. + +[ + // App + {"keys": ["ctrl+h"], "command": "help"}, + {"keys": ["ctrl+s"], "command": "save_file"}, + {"keys": ["ctrl+e"], "command": "run_command"}, + {"keys": ["ctrl+f"], "command": "find"}, + {"keys": ["ctrl+g"], "command": "go_to"}, + {"keys": ["ctrl+o"], "command": "open"}, + {"keys": ["ctrl+w"], "command": "close_file"}, + {"keys": ["ctrl+n"], "command": "new_file"}, + {"keys": ["ctrl+q"], "command": "ask_exit"}, + {"keys": ["ctrl+p"], "command": "comment"}, + {"keys": ["ctrl+pageup"], "command": "next_file"}, + {"keys": ["ctrl+pagedown"], "command": "prev_file"}, + {"keys": ["f1"], "command": "save_file_as"}, + {"keys": ["f2"], "command": "reload_file"}, + {"keys": ["f7"], "command": "toggle_whitespace"}, + {"keys": ["f8"], "command": "toggle_mouse"}, + {"keys": ["f11"], "command": "toggle_fullscreen"}, + // Editor + {"keys": ["up"], "command": "arrow_up"}, + {"keys": ["down"], "command": "arrow_down"}, + {"keys": ["left"], "command": "arrow_left"}, + {"keys": ["right"], "command": "arrow_right"}, + {"keys": ["enter"], "command": "enter"}, + {"keys": ["backspace"], "command": "backspace"}, + {"keys": ["delete"], "command": "delete"}, + {"keys": ["insert"], "command": "insert"}, + {"keys": ["tab"], "command": "tab"}, + {"keys": ["shift+tab"], "command": "untab"}, + {"keys": ["home"], "command": "home"}, + {"keys": ["end"], "command": "end"}, + {"keys": ["escape"], "command": "escape"}, + {"keys": ["pageup"], "command": "page_up"}, + {"keys": ["pagedown"], "command": "page_down"}, + {"keys": ["ctrl+z", "f5"], "command": "undo"}, + {"keys": ["ctrl+y", "f6"], "command": "redo"}, + {"keys": ["f9"], "command": "toggle_line_nums"}, + {"keys": ["f10"], "command": "toggle_line_ends"}, + {"keys": ["f11"], "command": "toggle_highlight"}, + {"keys": ["alt+up"], "command": "new_cursor_up"}, + {"keys": ["alt+down"], "command": "new_cursor_down"}, + {"keys": ["alt+left"], "command": "new_cursor_left"}, + {"keys": ["alt+right"], "command": "new_cursor_right"}, + {"keys": ["alt+pageup"], "command": "push_up"}, + {"keys": ["alt+pagedown"], "command": "push_down"}, + {"keys": ["ctrl+c"], "command": "copy"}, + {"keys": ["ctrl+x"], "command": "cut"}, + {"keys": ["ctrl+k"], "command": "duplicate_line"}, + {"keys": ["ctrl+v"], "command": "insert"}, + {"keys": ["ctrl+d"], "command": "find_next"}, + {"keys": ["ctrl+a"], "command": "find_all"}, + {"keys": ["ctrl+left"], "command": "jump_left"}, + {"keys": ["ctrl+right"], "command": "jump_right"}, + {"keys": ["ctrl+up"], "command": "jump_up"}, + {"keys": ["ctrl+down"], "command": "jump_down"} +] \ No newline at end of file diff --git a/suplemon/cursor.py b/suplemon/cursor.py index 8e78614..db9d52a 100644 --- a/suplemon/cursor.py +++ b/suplemon/cursor.py @@ -123,6 +123,9 @@ def __ne__(self, item): def __str__(self): return "Cursor({x},{y})".format(x=self.x, y=self.y) + def __repr__(self): + return self.__str__() + def tuple(self): """Return the cursor as a tuple. diff --git a/suplemon/editor.py b/suplemon/editor.py index b7d41e8..5a98621 100644 --- a/suplemon/editor.py +++ b/suplemon/editor.py @@ -3,8 +3,6 @@ Editor class for extending viewer with text editing features. """ -import re - from . import helpers from .line import Line @@ -51,10 +49,6 @@ def __init__(self, app, window): """ Viewer.__init__(self, app, window) - # Copy/paste buffer - self.buffer = [] - # Last search used in 'find' - self.last_find = "" # History of editor states for undo/redo self.history = [State()] # Current state index of the editor @@ -62,9 +56,9 @@ def __init__(self, app, window): # Last editor action that was used (for undo/redo) self.last_action = None - self.operations = { - "home": self.home, # Home - "end": self.end, # End + def init(self): + Viewer.init(self) + operations = { "backspace": self.backspace, # Backspace "delete": self.delete, # Delete "insert": self.insert, # Insert @@ -72,10 +66,6 @@ def __init__(self, app, window): "tab": self.tab, # Tab "untab": self.untab, # Shift + Tab "escape": self.escape, # Escape - "arrow_right": self.arrow_right, # Arrow Right - "arrow_left": self.arrow_left, # Arrow Left - "arrow_up": self.arrow_up, # Arrow Up - "arrow_down": self.arrow_down, # Arrow Down "new_cursor_up": self.new_cursor_up, # Alt + Up "new_cursor_down": self.new_cursor_down, # Alt + Down "new_cursor_left": self.new_cursor_left, # Alt + Left @@ -92,27 +82,9 @@ def __init__(self, app, window): "copy": self.copy, # Ctrl + C "cut": self.cut, # Ctrl + X "duplicate_line": self.duplicate_line, # Ctrl + W - "find_next": self.find_next, # Ctrl + D - "find_all": self.find_all, # Ctrl + A - "jump_left": self.jump_left, # Ctrl + Left - "jump_right": self.jump_right, # Ctrl + Right - "jump_up": self.jump_up, # Ctrl + Up - "jump_down": self.jump_down, # Ctrl + Down } - - def get_buffer(self): - """Returns the current buffer. - - Returns the local buffer or the global buffer depending on config. - """ - if self.app.config["editor"]["use_global_buffer"]: - return self.app.global_buffer - else: - return self.buffer - - def get_key_bindings(self): - """Get list of editor key bindings.""" - return self.config["keys"] + for key in operations.keys(): + self.operations[key] = operations[key] def set_buffer(self, buffer): """Sets local or global buffer depending on config.""" @@ -174,7 +146,19 @@ def restore_state(self, index=None): state = self.history[index] state.restore(self) self.current_state = index - self.refresh() + + def handle_input(self, event): + done = Viewer.handle_input(self, event) + if not done: + key = event.key_code + name = event.key_name + if isinstance(key, str): + self.type(key) + return True + elif name and not name.startswith("KEY_"): + self.type(name) + return True + return False def undo(self): """Undo the last command or change.""" @@ -193,102 +177,6 @@ def redo(self): # Cursor operations # - def arrow_right(self): - """Move cursors right.""" - for cursor in self.cursors: - line = self.lines[cursor.y] - if cursor.y != len(self.lines)-1 and (cursor.x >= len(line) or len(line) == 0): - cursor.move_down() - cursor.set_x(0) - elif cursor.x < len(self.lines[cursor.y]) and len(line) > 0: - cursor.move_right() - self.move_cursors() - self.scroll_down() - - def arrow_left(self): - """Move cursors left.""" - for cursor in self.cursors: - if cursor.y != 0 and cursor.x == 0: - cursor.move_up() - cursor.set_x(len(self.lines[cursor.y])+1) - self.move_cursors((-1, 0)) - self.scroll_up() - - def arrow_up(self): - """Move cursors up.""" - self.move_cursors((0, -1)) - self.scroll_up() - - def arrow_down(self): - """Move cursors down.""" - self.move_cursors((0, 1)) - self.scroll_down() - - def jump_left(self): - """Jump one 'word' to the left.""" - chars = self.config["punctuation"] - for cursor in self.cursors: - line = self.lines[cursor.y] - if cursor.x == 0: - if cursor.y > 0: - # Jump to end of previous line - cursor.set_x(len(self.lines[cursor.y-1])) - cursor.move_up() - continue - if cursor.x <= len(line): - cur_chr = line[cursor.x-1] - else: - cur_chr = line[cursor.x] - while cursor.x > 0: - next = cursor.x-2 - if next < 0: - next = 0 - if cur_chr == " ": - cursor.move_left() - if line[next] != " ": - break - else: - cursor.move_left() - if line[next] in chars: - break - self.move_cursors() - - def jump_right(self): - """Jump one 'word' to the right.""" - chars = self.config["punctuation"] - for cursor in self.cursors: - line = self.lines[cursor.y] - if cursor.x == len(line): - if cursor.y < len(self.lines): - # Jump to start of next line - cursor.set_x(0) - cursor.move_down() - continue - cur_chr = line[cursor.x] - while cursor.x < len(line): - next = cursor.x+1 - if next == len(line): - next -= 1 - if cur_chr == " ": - cursor.move_right() - if line[next] != " ": - break - else: - cursor.move_right() - if line[next] in chars: - break - self.move_cursors() - - def jump_up(self): - """Jump up 3 lines.""" - self.move_cursors((0, -3)) - self.scroll_up() - - def jump_down(self): - """Jump down 3 lines.""" - self.move_cursors((0, 3)) - self.scroll_down() - def new_cursor_up(self): """Add a new cursor one line up.""" x = self.get_cursor().x @@ -344,34 +232,6 @@ def escape(self): self.move_cursors() self.render() - def page_up(self): - """Move half a page up.""" - amount = int(self.get_size()[1]/2) * -1 - self.move_cursors((0, amount)) - self.scroll_up() - - def page_down(self): - """Move half a page down.""" - amount = int(self.get_size()[1]/2) - self.move_cursors((0, amount)) - self.scroll_down() - - def home(self): - """Move to start of line or text on that line.""" - for cursor in self.cursors: - wspace = helpers.whitespace(self.lines[cursor.y]) - if cursor.x == wspace: - cursor.set_x(0) - else: - cursor.set_x(wspace) - self.move_cursors() - - def end(self): - """Move to end of line.""" - for cursor in self.cursors: - cursor.set_x(len(self.lines[cursor.y])) - self.move_cursors() - # # Text editing operations # @@ -682,92 +542,6 @@ def go_to_pos(self, line_no, col=0): self.scroll_to_line(cur.y) self.move_cursors() - def find(self, what, findall=False): - """Find what in data (from top to bottom). Adds a cursor when found.""" - # Sorry for this colossal function - if not what: - return - last_cursor = self.get_last_cursor() - y = last_cursor.y - - found = False - new_cursors = [] - # Loop through all lines starting from the last cursor - while y < len(self.lines): - line = self.lines[y] - x_offset = 0 # Which character to begin searching from - if y == last_cursor.y: - # On the current line begin from the last cursor x pos - x_offset = last_cursor.x - - # Find all occurances of search string - s = str(line[x_offset:]) # Data to search in - pattern = re.escape(what) # Default to non regex pattern - if self.config["regex_find"]: - try: # Try to search with the actual regex - indices = [match.start() for match in re.finditer(what, s)] - except: # Revert to normal search - indices = [match.start() for match in re.finditer(pattern, s)] - else: - indices = [match.start() for match in re.finditer(pattern, s)] - - # Loop through the indices and add cursors if they don't exist yet - for i in indices: - new = Cursor(i+x_offset, y) - if not self.cursor_exists(new): - found = True - new_cursors.append(new) - if not findall: - break - if new not in new_cursors: - new_cursors.append(new) - if found and not findall: - break - y += 1 - - if not new_cursors: - self.app.set_status("Can't find '" + what + "'") - # self.last_find = "" - return - else: - # If we only have one cursor, and it's not - # where the first occurance is, just remove it - if len(self.cursors) == 1 and self.cursors[0].tuple() != new_cursors[0].tuple(): - self.cursors = [] - self.last_find = what # Only store string if it's really found - - # Add the new cursors - for cursor in new_cursors: - self.cursors.append(cursor) - - destination = self.get_last_cursor().y - self.scroll_to_line(destination) - self.store_action_state("find") # Store undo point - - def find_next(self): - """Find next occurance.""" - what = self.last_find - if what == "": - cursor = self.get_cursor() - search = "^([\w\-]+)" - line = self.lines[cursor.y][cursor.x:] - matches = re.match(search, line) - if matches: - what = matches.group(0) - else: - if line: - what = line[0] - # Escape the data if regex is enabled - if self.config["regex_find"]: - what = re.escape(what) - - self.last_find = what - self.find(what) - - def find_all(self): - """Find all occurances.""" - self.find(self.last_find, True) - def duplicate_line(self): """Copy current line and add it below as a new line.""" curs = sorted(self.cursors, key=lambda c: (c.y, c.x)) @@ -778,37 +552,81 @@ def duplicate_line(self): self.move_cursors() self.store_action_state("duplicate_line") + +class PromptEditor(Editor): + """An input prompt based on the Editor.""" + def __init__(self, app, window): + Editor.__init__(self, app, window) + self.ready = 0 + self.canceled = 0 + self.input_func = lambda: False + self.caption = "" + + def init(self): + Editor.init(self) + # Remove the find feature, otherwise it can be invoked recursively + del self.operations["find"] + + def set_config(self, config): + """Set the configuration for the editor.""" + # Override showing line numbers + config["show_line_nums"] = False + Editor.set_config(self, config) + + def set_input_source(self, input_func): + # Set the input function to use while looping for input + self.input_func = input_func + + def on_ready(self): + """Accepts the current input.""" + self.ready = 1 + return + + def on_cancel(self): + """Cancels the input prompt.""" + self.set_data("") + self.ready = 1 + self.canceled = 1 + return + + def line_offset(self): + """Get the x coordinate of beginning of line.""" + return len(self.caption)+1 + + def render_line_contents(self, line, pos, x_offset, max_len): + """Render the prompt line.""" + x_offset = self.line_offset() + # Render the caption + self.window.addstr(pos[1], 0, self.caption) + # Render input + self.render_line_normal(line, pos, x_offset, max_len) + def handle_input(self, event): - """Handle input.""" - if event.type == "mouse": - return False - key = event.key_code + """Handle special bindings for the prompt.""" name = event.key_name - # Try match a key to a method and call it - - key_bindings = self.get_key_bindings() - operation = None - if key in key_bindings.keys(): - operation = key_bindings[key] - elif name in key_bindings.keys(): - operation = key_bindings[name] - if operation: - self.run_operation(operation) - # Try to type the key into the editor - else: - if isinstance(key, str): - self.type(key) - elif name and not name.startswith("KEY_"): - self.type(name) - return False + if name in ["ctrl+c", "escape"]: + self.on_cancel() + return False + if name == "enter": + self.on_ready() + return False - def run_operation(self, operation): - """Run an editor core operation.""" - if operation in self.operations.keys(): - cancel = self.app.trigger_event_before(operation) - if cancel: - return False - result = self.operations[operation]() - self.app.trigger_event_after(operation) - return result - return False + return Editor.handle_input(self, event) + + def get_input(self, caption="", initial=""): + """Get text input from the user via the prompt.""" + self.caption = caption + self.set_data(initial) + self.end() # Move to the end of the initial text + + self.refresh() + + # Run the input loop until ready + while not self.ready: + event = self.input_func(True) # blocking + if event: + self.handle_input(event) + self.refresh() + if self.canceled: + return False + return self.get_data() diff --git a/suplemon/helpers.py b/suplemon/helpers.py index 245513c..fdd26aa 100644 --- a/suplemon/helpers.py +++ b/suplemon/helpers.py @@ -47,7 +47,7 @@ def multisplit(data, delimiters): def get_error_info(): """Return info about last error.""" - msg = str(traceback.format_exc()) + "\n" + str(sys.exc_info()) + msg = "{0}\n{1}".format(str(traceback.format_exc()), str(sys.exc_info())) return msg diff --git a/suplemon/key_mappings.py b/suplemon/key_mappings.py index 75b2713..c0ee2a3 100644 --- a/suplemon/key_mappings.py +++ b/suplemon/key_mappings.py @@ -67,7 +67,7 @@ "^W": "ctrl+w", "^X": "ctrl+x", "^Y": "ctrl+y", - # "^Z": "ctrl+z", # Conflicts with suspend + "^Z": "ctrl+z", # Conflicts with suspend 544: "ctrl+left", 559: "ctrl+right", diff --git a/suplemon/lexer.py b/suplemon/lexer.py index 9bb3637..9151396 100644 --- a/suplemon/lexer.py +++ b/suplemon/lexer.py @@ -1,12 +1,27 @@ # -*- encoding: utf-8 import pygments +import pygments.token import pygments.lexers class Lexer: def __init__(self, app): self.app = app + self.token_map = { + pygments.token.Comment: "comment", + pygments.token.Comment.Single: "comment", + pygments.token.Operator: "keyword", + pygments.token.Name.Function: "entity.name.function", + pygments.token.Name.Class: "entity.name.class", + pygments.token.Name.Tag: "entity.name.tag", + pygments.token.Name.Attribute: "entity.other.attribute-name", + pygments.token.Name.Variable: "variable", + pygments.token.Name.Builtin.Pseudo: "constant.language", + pygments.token.Literal.String: "string", + pygments.token.Literal.String.Doc: "string", + pygments.token.Punctuation: "punctuation", + } def lex(self, code, lex): """Return tokenified code. @@ -31,24 +46,14 @@ def lex(self, code, lex): token = word[0] scope = "global" - if token in pygments.token.Keyword: - scope = "keyword" - elif token == pygments.token.Comment: - scope = "comment" - elif token in pygments.token.Literal.String: - scope = "string" + if token in self.token_map.keys(): + scope = self.token_map[token] elif token in pygments.token.Literal.Number: scope = "constant.numeric" - elif token == pygments.token.Name.Function: - scope = "entity.name.function" - elif token == pygments.token.Name.Class: - scope = "entity.name.class" - elif token == pygments.token.Name.Tag: - scope = "entity.name.tag" - elif token == pygments.token.Operator: + elif token in pygments.token.Name: + scope = "entity.name" + elif token in pygments.token.Keyword: scope = "keyword" - elif token == pygments.token.Name.Builtin.Pseudo: - scope = "constant.language" scopes.append((scope, word[1])) return scopes diff --git a/suplemon/logger.py b/suplemon/logger.py index 7fe70c6..d80d9ec 100644 --- a/suplemon/logger.py +++ b/suplemon/logger.py @@ -20,7 +20,10 @@ def __init__(self, capacity, fd_target): :param object fd_target: File descriptor to write output to (e.g. `sys.stdout`) """ # Call our BufferingHandler init - super(BufferingTargetHandler, self).__init__(capacity) + if issubclass(BufferingTargetHandler, object): + super(BufferingTargetHandler, self).__init__(capacity) + else: + BufferingHandler.__init__(self, capacity) # Save target for later self._fd_target = fd_target @@ -41,7 +44,10 @@ def close(self): self.release() # Then, run our normal close actions - super(BufferingTargetHandler, self).close() + if issubclass(BufferingTargetHandler, object): + super(BufferingTargetHandler, self).close() + else: + BufferingHandler.close(self) # Initialize logging diff --git a/suplemon/main.py b/suplemon/main.py index e44aa5a..df16204 100644 --- a/suplemon/main.py +++ b/suplemon/main.py @@ -9,7 +9,7 @@ import sys from . import ui -from . import modules +from . import module_loader from . import themes from . import helpers @@ -44,6 +44,9 @@ def __init__(self, filenames=None, config_file=None): self.global_buffer = [] self.event_bindings = {} + # Maximum amount of inputs to process at once + self.max_input = 100 + # Save filenames for later self.filenames = filenames @@ -55,7 +58,6 @@ def __init__(self, filenames=None, config_file=None): "help": self.help, "save_file": self.save_file, "run_command": self.query_command, - "find": self.find, "go_to": self.go_to, "open": self.open, "close_file": self.close_file, @@ -84,6 +86,12 @@ def init(self): # Can't run without config return False self.config.load() + + # Unicode symbols don't play nice with Python 2 so disable them + if sys.version_info[0] < 3: + self.config["app"]["use_unicode_symbols"] = False + + # Configure logger self.debug = self.config["app"]["debug"] debug_level = self.config["app"]["debug_level"] self.logger.debug("Setting debug_level to {0}.".format(debug_level)) @@ -95,7 +103,7 @@ def init(self): self.ui.init() # Load extension modules - self.modules = modules.ModuleLoader(self) + self.modules = module_loader.ModuleLoader(self) self.modules.load() # Load themes @@ -125,7 +133,7 @@ def run_wrapped(self, *args): # Load ui and files etc self.load() # Initial render - self.get_editor().resize() + self.get_editor().refresh() self.ui.refresh() # Start mainloop self.main_loop() @@ -149,7 +157,7 @@ def load(self): self.load_files() self.running = 1 self.trigger_event_after("app_loaded") - + def on_input(self, event): # Handle the input or give it to the editor if not self.handle_input(event): @@ -166,7 +174,7 @@ def main_loop(self): # Run through max 100 inputs (so the view is updated at least every 100 characters) i = 0 - while i < 100: + while i < self.max_input: event = self.ui.get_input(False) # non-blocking if not event: @@ -182,14 +190,14 @@ def main_loop(self): if event: got_input = True - self.on_input(event) + self.on_input(event) # PERF: Up to 30% processing time self.block_rendering = False - # TODO: why do I need resize here? - # (View won't update after switching files, WTF) self.trigger_event_after("mainloop") - self.get_editor().resize() + # Rendering happens here + # TODO: Optimize performance. Can make up 45% of processing time in the loop. + self.get_editor().refresh() self.ui.refresh() def get_status(self): @@ -211,7 +219,11 @@ def get_file_index(self, file_obj): def get_key_bindings(self): """Return the list of key bindings.""" - return self.config["app"]["keys"] + bindings = {} + for binding in self.config.keymap: + for key in binding["keys"]: + bindings[key] = binding["command"] + return bindings def get_event_bindings(self): """Return the dict of event bindings.""" @@ -225,12 +237,12 @@ def set_key_binding(self, key, operation): :param key: What key or key combination to bind. :param str operation: Which operation to run. """ - self.get_key_bindings()[key] = operation + self.config.keymap.prepend({"keys": [key], "command": operation}) def set_event_binding(self, event, when, callback): """Bind a callback to be run before or after an event. - Bind callback to run before or after event occurs. Th when parameter + Bind callback to run before or after event occurs. The when parameter should be 'before' or 'after'. If using 'before' the callback can inhibit running the event if it returns True @@ -294,14 +306,20 @@ def handle_key(self, event): :rtype: boolean """ key_bindings = self.get_key_bindings() + + operation = None if event.key_name in key_bindings.keys(): operation = key_bindings[event.key_name] elif event.key_code in key_bindings.keys(): operation = key_bindings[event.key_code] - else: - return False - self.run_operation(operation) - return True + + if operation in self.operations.keys(): + self.run_operation(operation) + return True + elif operation in self.modules.modules.keys(): + self.run_module(operation) + + return False def handle_mouse(self, event): """Handle a mouse input event. @@ -413,13 +431,6 @@ def go_to(self): if file_index != -1: self.switch_to_file(file_index) - def find(self): - """Find in file.""" - editor = self.get_editor() - what = self.ui.query("Find:", editor.last_find) - if what: - editor.find(what) - def find_file(self, s): """Return index of file matching string.""" i = 0 @@ -433,22 +444,30 @@ def run_command(self, data): """Run editor commands.""" parts = data.split(" ") cmd = parts[0].lower() + if cmd in self.operations.keys(): + return self.run_operation(cmd) + args = " ".join(parts[1:]) self.logger.debug("Looking for command '{0}'".format(cmd)) if cmd in self.modules.modules.keys(): self.logger.debug("Trying to run command '{0}'".format(cmd)) self.get_editor().store_action_state(cmd) - try: - self.modules.modules[cmd].run(self, self.get_editor(), args) - except: - self.set_status("Running command failed!") - self.logger.exception("Running command failed!") + if not self.run_module(cmd, args): return False else: - self.set_status("Command '" + cmd + "' not found.") + self.set_status("Command '{0}' not found.".format(cmd)) return False return True + def run_module(self, module_name, args=""): + try: + self.modules.modules[module_name].run(self, self.get_editor(), args) + return True + except: + self.set_status("Running command failed!") + self.logger.exception("Running command failed!") + return False + def run_operation(self, operation): """Run an app core operation.""" # Support arbitrary callables. TODO: deprecate @@ -543,6 +562,7 @@ def setup_editor(self, editor): """Setup an editor instance with configuration.""" config = self.config["editor"] editor.set_config(config) + editor.init() ########################################################################### # File operations @@ -559,7 +579,7 @@ def open(self): return True if not self.open_file(name): - self.set_status("Failed to load '" + name + "'") + self.set_status("Failed to load '{0}'".format(name)) return False self.switch_to_file(self.last_file_index()) return True @@ -582,11 +602,11 @@ def save_file(self, file=False): if not f.get_name(): return self.save_file_as(f) if f.save(): - self.set_status("Saved [" + helpers.curr_time_sec() + "] '" + f.name + "'") + self.set_status("Saved [{0}] '{1}'".format(helpers.curr_time_sec(), f.name)) if f.path() == self.config.path(): self.reload_config() return True - self.set_status("Couldn't write to '" + f.name + "'") + self.set_status("Couldn't write to '{0}'".format(f.name)) return False def save_file_as(self, file=False): @@ -595,12 +615,19 @@ def save_file_as(self, file=False): name = self.ui.query("Save as:", f.name) if not name: return False + target_dir = os.path.dirname(name) + if target_dir and not os.path.exists(target_dir): + if self.ui.query_bool("The path doesn't exist, do you want to create it?"): + self.logger.debug("Creating missing folders in save path.") + os.makedirs(target_dir) + else: + return False f.set_name(name) return self.save_file(f) def reload_file(self): """Reload the current file.""" - if self.ui.query_bool("Reload '" + self.get_file().name + "'?"): + if self.ui.query_bool("Reload '{0}'?".format(self.get_file().name)): if self.get_file().reload(): return True return False diff --git a/suplemon/modules.py b/suplemon/module_loader.py similarity index 100% rename from suplemon/modules.py rename to suplemon/module_loader.py diff --git a/suplemon/modules/battery.py b/suplemon/modules/battery.py index 494e77e..b849c3e 100644 --- a/suplemon/modules/battery.py +++ b/suplemon/modules/battery.py @@ -32,9 +32,9 @@ def value_str(self): val = self.value() if val: if self.app.config["app"]["use_unicode_symbols"]: - return "\u26A1" + str(val) + "%" + return "\u26A1{0}%".format(str(val)) else: - return "BAT " + str(val) + "%" + return "BAT {0}%".format(str(val)) return "" def get_status(self): diff --git a/suplemon/modules/config.py b/suplemon/modules/config.py index e021a00..f77ba3f 100644 --- a/suplemon/modules/config.py +++ b/suplemon/modules/config.py @@ -2,35 +2,20 @@ import os -from suplemon.suplemon_module import Module +from suplemon import config -class Config(Module): - """Shortcut to openning the current config file.""" +class SuplemonConfig(config.ConfigModule): + """Shortcut to openning the keymap config file.""" + def __init__(self, app): + config.ConfigModule.__init__(self, app) - def run(self, app, editor, args): - if args == "defaults": - # Open the default config in a new file only for viewing - path = os.path.join(app.path, "config/defaults.json") - f = open(path) - data = f.read() - f.close() - file = app.new_file() - file.set_name("defaults.json") - file.set_data(data) - app.switch_to_file(app.last_file_index()) - else: - # Open the user config file for editing - path = app.config.path() - f = app.file_is_open(path) - if f: - app.switch_to_file(app.get_file_index(f)) - else: - if not app.open_file(path): - app.new_file(path) - app.switch_to_file(app.last_file_index()) + def init(self): + self.conf_name = "defaults.json" + self.conf_default_path = os.path.join(self.app.path, "config", self.conf_name) + self.conf_user_path = self.app.config.path() module = { - "class": Config, + "class": SuplemonConfig, "name": "config", } diff --git a/suplemon/modules/keymap.py b/suplemon/modules/keymap.py new file mode 100644 index 0000000..4b6c0d4 --- /dev/null +++ b/suplemon/modules/keymap.py @@ -0,0 +1,21 @@ +# -*- encoding: utf-8 + +import os + +from suplemon import config + + +class KeymapConfig(config.ConfigModule): + """Shortcut to openning the keymap config file.""" + def __init__(self, app): + config.ConfigModule.__init__(self, app) + + def init(self): + self.conf_name = "keymap.json" + self.conf_default_path = os.path.join(self.app.path, "config", self.conf_name) + self.conf_user_path = self.app.config.keymap_path() + +module = { + "class": KeymapConfig, + "name": "keymap", +} diff --git a/suplemon/modules/linter.py b/suplemon/modules/linter.py index ee95c2d..8fa189f 100644 --- a/suplemon/modules/linter.py +++ b/suplemon/modules/linter.py @@ -28,7 +28,7 @@ def run(self, app, editor, args): """Run the linting command.""" editor = self.app.get_file().get_editor() count = self.get_msg_count(editor) - status = str(count) + " lines with linting errors in this file." + status = "{0} lines with linting errors in this file.".format(str(count)) self.app.set_status(status) def mainloop(self, event): @@ -41,7 +41,7 @@ def mainloop(self, event): line_no = cursor.y + 1 msg = self.get_msgs_on_line(editor, cursor.y) if msg: - self.app.set_status("Line " + str(line_no) + ": " + msg) + self.app.set_status("Line {0}: {1}".format(str(line_no), msg)) def lint_current_file(self, event): self.lint_file(self.app.get_file()) @@ -115,7 +115,7 @@ def get_output(self, cmd): process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=fnull) fnull.close() except (OSError, EnvironmentError): # can't use FileNotFoundError in Python 2 - self.logger.exception("Subprocess failed.") + self.logger.debug("Subprocess failed.") return False out, err = process.communicate() return out diff --git a/suplemon/modules/system_clipboard.py b/suplemon/modules/system_clipboard.py index ea7c911..752abd7 100644 --- a/suplemon/modules/system_clipboard.py +++ b/suplemon/modules/system_clipboard.py @@ -9,7 +9,7 @@ class SystemClipboard(Module): def init(self): self.init_logging(__name__) if not self.has_xsel_support(): - self.logger.warning("xsel not available. Can't use system clipboard.") + self.logger.warning("Can't use system clipboard. Install 'xsel' for system clipboard support.") return False self.bind_event_before("insert", self.insert) self.bind_event_after("copy", self.copy) diff --git a/suplemon/ui.py b/suplemon/ui.py index 8cd57d4..945d587 100644 --- a/suplemon/ui.py +++ b/suplemon/ui.py @@ -6,6 +6,7 @@ import os import logging +from .editor import PromptEditor from .key_mappings import key_map # Curses can't be imported yet but we'll @@ -96,6 +97,7 @@ def init(self): # Now import curses, otherwise ESCDELAY won't have any effect import curses import curses.textpad # noqa + self.logger.debug("Loaded curses {0}".format(curses.version.decode())) def run(self, func): """Run the application main function via the curses wrapper for safety.""" @@ -260,10 +262,9 @@ def resize(self, yx=None): self.screen.erase() curses.resizeterm(yx[0], yx[1]) self.setup_windows(resize=True) - self.screen.refresh() def check_resize(self): - """Check if terminal has resized.""" + """Check if terminal has resized and resize if needed.""" yx = self.screen.getmaxyx() if self.current_yx != yx: self.current_yx = yx @@ -285,10 +286,10 @@ def show_top_status(self): display = self.app.config["display"] head_parts = [] if display["show_app_name"]: - name_str = "Suplemon Editor v" + self.app.version + " -" + name_str = "Suplemon Editor v{0} -".format(self.app.version) if self.app.config["app"]["use_unicode_symbols"]: logo = "\u2688" # Simple lemon (filled) - name_str = " " + logo + " " + name_str + name_str = " {0} {1}".format(logo, name_str) head_parts.append(name_str) # Add module statuses to the status bar @@ -327,7 +328,7 @@ def file_list_str(self): append += ["", is_changed_symbol][f.is_changed()] fname = prepend + f.name + append if not str_list: - str_list.append("[" + fname + "]") + str_list.append("[{0}]".format(fname)) else: str_list.append(fname) return " ".join(str_list) @@ -337,15 +338,16 @@ def show_bottom_status(self): editor = self.app.get_editor() size = self.get_size() cur = editor.get_cursor() - data = "@ " + str(cur[0]) + "," + str(cur[1]) + " " + \ - "cur:" + str(len(editor.cursors)) + " " + \ - "buf:" + str(len(editor.get_buffer())) - if self.app.config["app"]["debug"]: - data += " cs:"+str(editor.current_state)+" hist:"+str(len(editor.history)) # Undo / Redo debug - # if editor.last_find: - # find = editor.last_find - # if len(find) > 10:find = find[:10]+"..." - # data = "find:'"+find+"' " + data + data = "@ {0},{1} cur:{2} buf:{3}".format( + str(cur[0]), + str(cur[1]), + str(len(editor.cursors)), + str(len(editor.get_buffer())) + ) + + # Deprecate? + # if self.app.config["app"]["debug"]: + # data += " cs:"+str(editor.current_state)+" hist:"+str(len(editor.history)) # Undo / Redo debug # Add module statuses to the status bar for name in self.app.modules.modules.keys(): @@ -406,42 +408,25 @@ def show_legend(self): x += len(key[1])+2 self.legend_win.refresh() - def show_capture_status(self, s="", value=""): - """Show status when capturing input.""" - self.status_win.erase() - self.status_win.addstr(0, 0, s, curses.A_REVERSE) - self.status_win.addstr(0, len(s), value) - - def _process_query_key(self, key): - """Process keystrokes from the Textbox window.""" - if key in [3, 27]: # Support canceling query with Ctrl+C or ESC - raise KeyboardInterrupt - # Standardize some keycodes - rewrite = { - 127: 263, - 8: 263, - } - # self.logger.debug("Query key input: {0}".format(str(key))) - if key in rewrite.keys(): - key = rewrite[key] - return key - def _query(self, text, initial=""): """Ask for text input via the status bar.""" - self.show_capture_status(text, initial) - self.text_input = curses.textpad.Textbox(self.status_win) - try: - out = self.text_input.edit(self._process_query_key) - except: - return False - # If input begins with prompt, remove the prompt text - if len(out) >= len(text): - if out[:len(text)] == text: - out = out[len(text):] - if len(out) > 0 and out[-1] == " ": - out = out[:-1] - out = out.rstrip("\r\n") + # Disable render blocking + blocking = self.app.block_rendering + self.app.block_rendering = 0 + + # Create our text input + self.text_input = PromptEditor(self.app, self.status_win) + self.text_input.set_config(self.app.config["editor"].copy()) + self.text_input.set_input_source(self.get_input) + self.text_input.init() + + # Get input from the user + out = self.text_input.get_input(text, initial) + + # Restore render blocking + self.app.block_rendering = blocking + return out def query(self, text, initial=""): @@ -486,7 +471,6 @@ def get_input(self, blocking=True): event.set_key_name("ctrl+c") return event except: - self.logger.debug("Failed to get input!") # No input available return False finally: diff --git a/suplemon/viewer.py b/suplemon/viewer.py index 6f9e3dd..d192b2a 100644 --- a/suplemon/viewer.py +++ b/suplemon/viewer.py @@ -4,11 +4,13 @@ """ import os +import re import sys import imp import curses import logging +from . import helpers from .line import Line from .cursor import Cursor @@ -21,7 +23,7 @@ pygments = False -class Viewer: +class BaseViewer: def __init__(self, app, window): """ Handle Viewer initialization @@ -50,76 +52,43 @@ def __init__(self, app, window): self.x_scroll = 0 self.cursors = [Cursor()] - # Lexer for translating tokens to strings - self.lexer = None - # Built in syntax definition (for commenting etc.) - self.syntax = None - # Normal Pygments lexer - self.pygments_syntax = None + # Copy/paste buffer + self.buffer = [] + + # Last search used in 'find' + self.last_find = "" + + # Runnable methods + self.operations = { + "arrow_right": self.arrow_right, # Arrow Right + "arrow_left": self.arrow_left, # Arrow Left + "arrow_up": self.arrow_up, # Arrow Up + "arrow_down": self.arrow_down, # Arrow Down + "jump_left": self.jump_left, # Ctrl + Left + "jump_right": self.jump_right, # Ctrl + Right + "jump_up": self.jump_up, # Ctrl + Up + "jump_down": self.jump_down, # Ctrl + Down + "page_up": self.page_up, # Page Up + "page_down": self.page_down, # Page Down + "home": self.home, # Home + "end": self.end, # End + "find": self.find_query, # Ctrl + F + "find_next": self.find_next, # Ctrl + D + "find_all": self.find_all, # Ctrl + A + } - self.setup_linelight() - if self.app.config["editor"]["show_highlighting"]: - self.setup_highlight() + def init(self): + pass - def setup_linelight(self): - """Setup line based highlighting.""" - ext = self.file_extension - # Check if a file extension is redefined - # Maps e.g. 'scss' to 'css' - if ext in self.extension_map.keys(): - ext = self.extension_map[ext] # Use it - curr_path = os.path.dirname(os.path.realpath(__file__)) + def get_buffer(self): + """Returns the current buffer. - filename = ext + ".py" - path = os.path.join(curr_path, "linelight", filename) - module = False - if os.path.isfile(path): - try: - module = imp.load_source(ext, path) - except: - self.logger.error("Failed to load syntax file '{0}'!".format(path), exc_info=True) + Returns the local buffer or the global buffer depending on config. + """ + if self.config["use_global_buffer"]: + return self.app.global_buffer else: - return False - - if not module or "Syntax" not in dir(module): - self.logger.error("File doesn't match API!") - return False - self.syntax = module.Syntax() - - def setup_highlight(self): - """Setup Pygments based highlighting.""" - if not pygments: - # If Pygments lib not available - self.logger.info("Pygments not available, please install python3-pygments for proper syntax highlighting.") - return False - self.lexer = Lexer(self.app) - ext = self.file_extension.lower() - if not ext: - return False - # Check if a file extension is redefined - # Maps e.g. 'scss' to 'css' - if ext in self.extension_map.keys(): - ext = self.extension_map[ext] # Use it - try: - self.pygments_syntax = pygments.lexers.get_lexer_by_name(ext) - self.logger.debug("Loaded Pygments lexer '{0}'.".format(ext)) - except: - self.logger.debug("Failed to load Pygments lexer '{0}'.".format(ext)) - return False - if ext == "php": - # Hack to highlight PHP even without tags - self.pygments_syntax.options.update({"startinline": 1}) - self.pygments_syntax.startinline = 1 - - def size(self): - """Get editor size (x,y). (Deprecated, use get_size).""" - self.logger.warning("size() is deprecated, please use get_size()") - return self.get_size() - - def cursor(self): - """Return the main cursor. (Deprecated, use get_cursor)""" - self.logger.warning("cursor() is deprecated, please use get_cursor()") - return self.get_cursor() + return self.buffer def get_size(self): """Get editor size (x,y).""" @@ -183,20 +152,6 @@ def get_lines_with_cursors(self): line_nums.sort() return line_nums - def get_line_color(self, raw_line): - """Return a color based on line contents. - - :param str raw_line: The line from which to get a color value. - :return: A color value for given raw_data. - :rtype: int - """ - if self.syntax: - try: - return self.syntax.get_color(raw_line) - except: - return 0 - return 0 - def get_data(self): """Get editor contents. @@ -266,7 +221,7 @@ def set_file_extension(self, ext): if ext and ext != self.file_extension: self.file_extension = ext self.setup_linelight() - if self.app.config["editor"]["show_highlighting"]: + if self.config["show_highlighting"]: self.setup_highlight() def add_cursor(self, cursor): @@ -305,11 +260,38 @@ def toggle_highlight(self): """Toggle syntax highlighting.""" return False + ########################################################################### + # Curses + ########################################################################### + + def move_win(self, yx): + """Move the editor window to position yx.""" + # Must try & catch since mvwin might + # crash with incorrect coordinates + try: + self.window.mvwin(yx[0], yx[1]) + except: + self.logger.warning("Moving window failed!", exc_info=True) + + def refresh(self): + """Refresh the editor curses window.""" + self.move_cursors() + self.render() + self.window.refresh() + + def resize(self, yx=None): + """Resize the UI.""" + if not yx: + yx = self.window.getmaxyx() + self.window.resize(yx[0], yx[1]) + self.move_cursors() + self.refresh() + def render(self): """Render the editor curses window.""" if self.app.block_rendering: return - + self.window.erase() i = 0 max_y = self.get_size()[1] @@ -346,10 +328,10 @@ def render_line_contents(self, line, pos, x_offset, max_len): :param x_offset: Offset from left edge of screen. Currently same as x position. :param max_len: Maximum amount of chars that will fit on screen. """ - show_highlighting = self.app.config["editor"]["show_highlighting"] + show_highlighting = self.config["show_highlighting"] if pygments and show_highlighting and self.pygments_syntax and self.app.themes.current_theme: self.render_line_pygments(line, pos, x_offset, max_len) - elif self.app.config["editor"]["show_line_colors"]: + elif self.config["show_line_colors"]: self.render_line_linelight(line, pos, x_offset, max_len) else: self.render_line_normal(line, pos, x_offset, max_len) @@ -372,7 +354,9 @@ def render_line_pygments(self, line, pos, x_offset, max_len): # and tokens should be cached in line instances. That way we can # support multi line comment syntax etc. It should also perform # better, since we only need to re-highlight lines when they change. + # TODO 2: Optimize lexer performance tokens = self.lexer.lex(line_data, self.pygments_syntax) + first_token = True for token in tokens: if token[1] == "\n": break @@ -383,6 +367,9 @@ def render_line_pygments(self, line, pos, x_offset, max_len): # TODO: get whitespace color from theme pair = 9 # Gray text on normal background curs_color = curses.color_pair(pair) + # Only add tab indicators to the inital whitespace + if first_token and self.config["show_tab_indicators"]: + text = self.add_tab_indicators(text) self.window.addstr(y, x_offset, text, curs_color) else: # Color with pygments @@ -396,11 +383,13 @@ def render_line_pygments(self, line, pos, x_offset, max_len): self.window.addstr(y, x_offset, text, curs_color) else: self.window.addstr(y, x_offset, text) + if first_token: + first_token = False x_offset += len(text) def render_line_linelight(self, line, pos, x_offset, max_len): """Render line with naive line based highlighting.""" - x, y = pos + y = pos[1] line_data = line.get_data() line_data = self._prepare_line_for_rendering(line_data, max_len) curs_color = curses.color_pair(self.get_line_color(line)) @@ -408,19 +397,33 @@ def render_line_linelight(self, line, pos, x_offset, max_len): def render_line_normal(self, line, pos, x_offset, max_len): """Render line without any highlighting.""" - x, y = pos + y = pos[1] line_data = line.get_data() line_data = self._prepare_line_for_rendering(line_data, max_len) self.window.addstr(y, x_offset, line_data) + def add_tab_indicators(self, data): + new_data = "" + i = 0 + for char in data: + if i == 0: + new_data += self.config["tab_indicator_character"] + else: + new_data += char + i += 1 + if i > self.config["tab_width"]-1: + i = 0 + return new_data + def replace_whitespace(self, data): + # TODO: Optimize performance """Replace unsafe whitespace with alternative safe characters Replace unsafe whitespace with normal space or visible replacement. For example tab characters make cursors go out of sync with line contents. """ - for key in self.config["white_space_map"].keys(): + for key in self.config["white_space_map"]: char = " " if self.config["show_white_space"]: char = self.config["white_space_map"][key] @@ -462,7 +465,7 @@ def render_cursors(self): """Render editor window cursors.""" if self.app.block_rendering: return - + max_x, max_y = self.get_size() for cursor in self.cursors: x = cursor.x - self.x_scroll + self.line_offset() @@ -477,17 +480,9 @@ def render_cursors(self): continue self.window.chgat(y, cursor.x+self.line_offset()-self.x_scroll, 1, self.cursor_style) - def refresh(self): - """Refresh the editor curses window.""" - self.window.refresh() - - def resize(self, yx=None): - """Resize the UI.""" - if not yx: - yx = self.window.getmaxyx() - self.window.resize(yx[0], yx[1]) - self.move_cursors() - self.refresh() + ########################################################################### + # Scrolling + ########################################################################### def scroll_up(self): """Scroll view up if neccesary.""" @@ -513,24 +508,16 @@ def scroll_to_line(self, line_no): new_y = 0 self.y_scroll = new_y - def move_win(self, yx): - """Move the editor window to position yx.""" - # Must try & catch since mvwin might - # crash with incorrect coordinates - try: - self.window.mvwin(yx[0], yx[1]) - except: - self.logger.warning("Moving window failed!", exc_info=True) - def move_y_scroll(self, delta): """Add delta the y scroll axis scroll""" self.y_scroll += delta - def move_cursors(self, delta=None, noupdate=False): + ########################################################################### + # Cursors + ########################################################################### + + def move_cursors(self, delta=None): """Move all cursors with delta. To avoid refreshing the screen set noupdate to True.""" - if self.app.block_rendering: - noupdate = True - for cursor in self.cursors: if delta: if delta[0] != 0 and cursor.x >= 0: @@ -561,8 +548,7 @@ def move_cursors(self, delta=None, noupdate=False): self.x_scroll -= abs(cur.x - self.x_scroll) # FIXME if cur.x - self.x_scroll+offset < offset: self.x_scroll -= 1 - if not noupdate: - self.purge_cursors() + self.purge_cursors() def move_x_cursors(self, line, col, delta): """Move all cursors starting at line and col with delta on the x axis.""" @@ -604,7 +590,6 @@ def purge_cursors(self): ref.append(cursor.tuple()) new.append(cursor) self.cursors = new - self.render() def purge_line_cursors(self, line_no): """Remove all but first cursor on given line.""" @@ -621,3 +606,347 @@ def purge_line_cursors(self, line_no): for line_cursors in cursor: self.remove_cursor(cursor) return True + + ########################################################################### + # Input Handling + ########################################################################### + + def get_key_bindings(self): + """Get list of editor key bindings.""" + return self.app.get_key_bindings() + + def handle_input(self, event): + """Handle input.""" + if event.type == "mouse": + return False + key = event.key_code + name = event.key_name + # Try match a key to a method and call it + + key_bindings = self.get_key_bindings() + operation = None + if key in key_bindings: + operation = key_bindings[key] + elif name in key_bindings: + operation = key_bindings[name] + if operation: + self.run_operation(operation) + return True + return False + + def run_operation(self, operation): + """Run an editor core operation.""" + if operation in self.operations: + cancel = self.app.trigger_event_before(operation) + if cancel: + return False + result = self.operations[operation]() + self.app.trigger_event_after(operation) + return result + return False + + ########################################################################### + # Operations + ########################################################################### + + def arrow_right(self): + """Move cursors right.""" + for cursor in self.cursors: + line = self.lines[cursor.y] + # If we are at the end of the line + if cursor.x >= len(line) or len(line) == 0: + # If there is another line, then move down + if cursor.y != len(self.lines)-1: + cursor.move_down() + cursor.set_x(0) + # Otherwise, move the cursor right + else: + cursor.move_right() + self.move_cursors() + self.scroll_down() + + def arrow_left(self): + """Move cursors left.""" + for cursor in self.cursors: + if cursor.y != 0 and cursor.x == 0: + cursor.move_up() + cursor.set_x(len(self.lines[cursor.y])+1) + self.move_cursors((-1, 0)) + self.scroll_up() + + def arrow_up(self): + """Move cursors up.""" + self.move_cursors((0, -1)) + self.scroll_up() + + def arrow_down(self): + """Move cursors down.""" + self.move_cursors((0, 1)) + self.scroll_down() + + def home(self): + """Move to start of line or text on that line.""" + for cursor in self.cursors: + wspace = helpers.whitespace(self.lines[cursor.y]) + if cursor.x == wspace: + cursor.set_x(0) + else: + cursor.set_x(wspace) + self.move_cursors() + + def end(self): + """Move to end of line.""" + for cursor in self.cursors: + cursor.set_x(len(self.lines[cursor.y])) + self.move_cursors() + + def page_up(self): + """Move half a page up.""" + amount = int(self.get_size()[1]/2) * -1 + self.move_cursors((0, amount)) + self.scroll_up() + + def page_down(self): + """Move half a page down.""" + amount = int(self.get_size()[1]/2) + self.move_cursors((0, amount)) + self.scroll_down() + + def jump_left(self): + """Jump one 'word' to the left.""" + chars = self.config["punctuation"] + for cursor in self.cursors: + line = self.lines[cursor.y] + if cursor.x == 0: + if cursor.y > 0: + # Jump to end of previous line + cursor.set_x(len(self.lines[cursor.y-1])) + cursor.move_up() + continue + if cursor.x <= len(line): + cur_chr = line[cursor.x-1] + else: + cur_chr = line[cursor.x] + while cursor.x > 0: + next = cursor.x-2 + if next < 0: + next = 0 + if cur_chr == " ": + cursor.move_left() + if line[next] != " ": + break + else: + cursor.move_left() + if line[next] in chars: + break + self.move_cursors() + + def jump_right(self): + """Jump one 'word' to the right.""" + chars = self.config["punctuation"] + for cursor in self.cursors: + line = self.lines[cursor.y] + if cursor.x == len(line): + if cursor.y < len(self.lines): + # Jump to start of next line + cursor.set_x(0) + cursor.move_down() + continue + cur_chr = line[cursor.x] + while cursor.x < len(line): + next = cursor.x+1 + if next == len(line): + next -= 1 + if cur_chr == " ": + cursor.move_right() + if line[next] != " ": + break + else: + cursor.move_right() + if line[next] in chars: + break + self.move_cursors() + + def jump_up(self): + """Jump up 3 lines.""" + self.move_cursors((0, -3)) + self.scroll_up() + + def jump_down(self): + """Jump down 3 lines.""" + self.move_cursors((0, 3)) + self.scroll_down() + + def find_query(self): + """Find in file via user input.""" + what = self.app.ui.query("Find:", self.last_find) + if what: + self.find(what) + + def find(self, what, findall=False): + """Find what in data (from top to bottom). Adds a cursor when found.""" + # Sorry for this colossal function + if not what: + return + last_cursor = self.get_last_cursor() + y = last_cursor.y + + found = False + new_cursors = [] + # Loop through all lines starting from the last cursor + while y < len(self.lines): + line = self.lines[y] + x_offset = 0 # Which character to begin searching from + if y == last_cursor.y: + # On the current line begin from the last cursor x pos + x_offset = last_cursor.x + + # Find all occurances of search string + s = str(line[x_offset:]) # Data to search in + pattern = re.escape(what) # Default to non regex pattern + if self.config["regex_find"]: + try: # Try to search with the actual regex + indices = [match.start() for match in re.finditer(what, s)] + except: # Revert to normal search + indices = [match.start() for match in re.finditer(pattern, s)] + else: + # Use normal search + indices = [match.start() for match in re.finditer(pattern, s)] + + # Loop through the indices and add cursors if they don't exist yet + for i in indices: + new = Cursor(i+x_offset, y) + if not self.cursor_exists(new): + found = True + if new not in new_cursors: # Make sure we don't get duplicates + new_cursors.append(new) + if not findall: + break + if new not in new_cursors: + new_cursors.append(new) + if found and not findall: + break + y += 1 + + if not new_cursors: + self.app.set_status("Can't find '{0}'".format(what)) + return False + else: + # If we only have one cursor, and it's not + # where the first occurance is, just remove it + if len(self.cursors) == 1 and self.cursors[0].tuple() != new_cursors[0].tuple(): + self.cursors = [] + self.last_find = what # Only store string if it's really found + + # Add the new cursors + for cursor in new_cursors: + if not self.cursor_exists(cursor): + self.cursors.append(cursor) + + destination = self.get_last_cursor().y + self.scroll_to_line(destination) + self.store_action_state("find") # Store undo point + + def find_next(self): + """Find next occurance.""" + what = self.last_find + if what == "": + cursor = self.get_cursor() + search = "^([\w\-]+)" + line = self.lines[cursor.y][cursor.x:] + matches = re.match(search, line) + if matches: + what = matches.group(0) + else: + if line: + what = line[0] + # Escape the data if regex is enabled + if self.config["regex_find"]: + what = re.escape(what) + self.last_find = what + self.find(what) + + def find_all(self): + """Find all occurances.""" + self.find(self.last_find, True) + + +class Viewer(BaseViewer): + def __init__(self, app, window): + BaseViewer.__init__(self, app, window) + + # Lexer for translating tokens to strings + self.lexer = None + # Built in syntax definition (for commenting etc.) + self.syntax = None + # Normal Pygments lexer + self.pygments_syntax = None + + self.setup_linelight() + + def init(self): + if self.config["show_highlighting"]: + self.setup_highlight() + + def setup_linelight(self): + """Setup line based highlighting.""" + ext = self.file_extension + # Check if a file extension is redefined + # Maps e.g. 'scss' to 'css' + if ext in self.extension_map: + ext = self.extension_map[ext] # Use it + curr_path = os.path.dirname(os.path.realpath(__file__)) + + filename = ext + ".py" + path = os.path.join(curr_path, "linelight", filename) + module = False + if os.path.isfile(path): + try: + module = imp.load_source(ext, path) + except: + self.logger.error("Failed to load syntax file '{0}'!".format(path), exc_info=True) + else: + return False + + if not module or "Syntax" not in dir(module): + self.logger.error("File doesn't match API!") + return False + self.syntax = module.Syntax() + + def setup_highlight(self): + """Setup Pygments based highlighting.""" + if not pygments: + # If Pygments lib not available + self.logger.info("Pygments not available, please install python3-pygments for proper syntax highlighting.") + return False + self.lexer = Lexer(self.app) + ext = self.file_extension.lower() + if not ext: + return False + # Check if a file extension is redefined + # Maps e.g. 'scss' to 'css' + if ext in self.extension_map: + ext = self.extension_map[ext] # Use it + try: + self.pygments_syntax = pygments.lexers.get_lexer_by_name(ext) + self.logger.debug("Loaded Pygments lexer '{0}'.".format(ext)) + except: + self.logger.debug("Failed to load Pygments lexer '{0}'.".format(ext)) + return False + if ext == "php": + # Hack to highlight PHP even without tags + self.pygments_syntax.options.update({"startinline": 1}) + self.pygments_syntax.startinline = 1 + + def get_line_color(self, raw_line): + """Return a color based on line contents. + + :param str raw_line: The line from which to get a color value. + :return: A color value for given raw_data. + :rtype: int + """ + if self.syntax: + color = self.syntax.get_color(raw_line) + if color is not None: + return color + return 0