💾 Archived View for gemini.ctrl-c.club › ~nttp › toys › snaked.py captured on 2024-06-16 at 14:00:50.
⬅️ Previous capture (2020-09-24)
-=-=-=-=-=-=-
#!/usr/bin/env python3 # coding=utf-8 # # Snaked: line editor written in and for Python. # 2020-09-08 Felix Pleșoianu <https://felix.plesoianu.ro/> # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. """Snaked: line editor written in and for Python.""" from __future__ import division from __future__ import print_function try: import readline except ImportError as e: print("(Command line editing is unavailable.)\n") import os import sys import glob import shlex import cmd VERSION = "1.3.1" app_banner = """ Welcome to Snaked, a line editor written in and for Python, version {}. Type ? to see a list of commands. Prefix lines with > to append text, or with . to insert. """.format(VERSION) help_text = {} help_text["about"] = """ Snaked is a line editor with some less usual features: - modern user interface, with familiar commands in plain English; - ability to open multiple files and shuffle text between them; - commands for running arbitrary Python code; - operating system interface, mainly for directory navigation; - built-in help for all commands plus adjacent topics. Editing text from the command line doesn't have to be cryptic! """ help_text["cursor"] = """ The cursor is a number indicating where new lines are inserted. When opening a file, the cursor is placed at the end by default. It can be set with the `at` command, or implicitly as part of navigation, search, cut / copy / paste and so on. The cursor is always between one and the file's line count. It never becomes smaller than the mark during normal operation. """ help_text["mark"] = """ The mark is a numeric index that sits opposite the cursor to define the selection. When opening a file it's set to the end by default. Several commands set the mark implicitly: those for navigation, search and editing. The mark is always between one and the file's line count. It never becomes larger than the cursor during normal operation. """ help_text["ranges"] = """ Several commands accept a range of line numbers. Valid ranges include: 10- -20 10-20 - 15 1-end Note that lines are numbered from 1, and ranges are inclusive. """ help_text["selection"] = """ In Snaked, the selection is simply all lines between mark and cursor, inclusively. That can be none at all, if they happen to be at the same position. Navigation and search commands always set the selection. Commands like cut / copy / paste work on the selection if no explicit range is given. Pasting text replaces the selection if any, while inserting lines extends it instead. Since the `at` command sets both mark and cursor to the same position in the file, it effectively clears the selection. """ def parse_range(text, limit): if text.isdigit(): n = parse_ln(text, limit) return n, n ln = text.split("-") if len(ln) == 2: return parse_ln(ln[0], limit), parse_ln(ln[1], limit) else: raise ValueError("Bad range: " + text) def parse_ln(text, limit): if text == "": return 0 elif text == "end": return limit elif text.isdigit(): return min(int(text), limit) else: raise ValueError("Bad range index: " + text) def shell_parse(text): try: args = shlex.split(text) except ValueError as e: print(e) return None if len(args) == 0: return "" elif len(args) == 1: return args[0] else: print("Too many arguments.") return None class TextFile: def __init__(self, name=None): self.name = name self.lines = [] self.mark = 0 self.cursor = 0 self.modified = False def __str__(self): return self.name if self.name != None else "(untitled)" def canonize(self): if self.name == None: raise RuntimeError("Text file is untitled.") else: self.name = os.path.abspath( os.path.expanduser(self.name)) return self def load(self): if self.name == None: raise RuntimeError("Text file is untitled.") else: with open(self.name, "r") as f: lines = f.readlines() self.lines = [i.rstrip("\n") for i in lines] self.mark = len(lines) self.cursor = self.mark return self class Editor(cmd.Cmd): #intro = app_banner prompt = "" def __init__(self, quiet=False, safe=False, strict=False): cmd.Cmd.__init__(self) self.files = [TextFile()] self.current = 0 self.step_size = 20 self.clipboard = [] self.search_string = "" self.quiet = bool(quiet) self.safe = bool(safe) self.strict = bool(strict) @property def modified(self): """Return true if any open file is modified.""" return any(i.modified for i in self.files) def do_eval(self, args): """Evaluate a Python expression and show the result.""" if self.safe: print("Command disabled in safe mode: eval.") else: try: print(eval(args)) except Exception as e: print(e) def do_exec(self, args): """Execute arbitrary Python code exactly as entered.""" if self.safe: print("Command disabled in safe mode: exec.") else: try: exec(args) except Exception as e: print(e) def do_pwd(self, args): """Print out the current working directory.""" print(os.getcwd()) def do_dir(self, args): """List contents of the current working directory. """ if args == "": args = "*" print("\n".join(sorted(glob.glob(args)))) def do_cd(self, args): """Change current working directory to given path.""" args = shell_parse(args) if args == None: return elif args == "": args = "~" try: os.chdir(os.path.expanduser(args)) if not self.quiet: print("Now working in", os.getcwd()) except OSError as e: print("Can't change directory:", e, file=sys.stderr) def complete_cd(self, text, line, begidx, endidx): return glob.glob(os.path.expanduser(text) + "*") def do_new(self, args): """Start a new file and set it as current.""" if self.strict: print("Command disabled in strict mode: new") else: self.current = len(self.files) self.files.append(TextFile()) if not self.quiet: print("New file created.") def open_file(self, fn): try: f = TextFile(fn).canonize().load() self.current = len(self.files) self.files.append(f) return True except OSError as e: print("Can't open file:", e, file=sys.stderr) return False except IOError as e: print("Can't open file:", e, file=sys.stderr) return False def do_open(self, args): """Open a file from disc or list open files.""" if self.strict: print("Command disabled in strict mode: open") return args = shell_parse(args) if args == None: return elif args == "": for i, f in enumerate(self.files): print("{:7d} {}".format(i + 1, f), end='') if i == self.current: print(" (current)") else: print() return elif self.open_file(args): if not self.quiet: print("Opened", self.files[self.current].name) def complete_open(self, text, line, begidx, endidx): return glob.glob(os.path.expanduser(text) + "*") def do_close(self, args): """Close current file and set next file as current.""" if self.strict: print("Command disabled in strict mode: close") return elif self.files[self.current].modified and args != "!": print("File was modified.", "Enter `close !` to override.") return elif not self.quiet: print("Closing file", self.files[self.current]) del self.files[self.current] if len(self.files) == 0: self.files.append(TextFile()) if self.current >= len(self.files): self.current = 0 if not self.quiet: print("Current file now", self.files[self.current]) def do_save(self, args): """Save current file, possibly under a new name.""" args = shell_parse(args) if args == None: return elif args != "": args = os.path.abspath( os.path.expanduser(args)) elif self.files[self.current].name != None: args = self.files[self.current].name else: print("File not saved yet." "Please save with name.") return lines = self.files[self.current].lines lines = [i + "\n" for i in lines] try: with open(args, "w") as f: f.writelines(lines) self.files[self.current].name = args self.files[self.current].modified = False if not self.quiet: print("Saved", args) except OSError as e: print("Can't save file:", e, file=sys.stderr) except IOError as e: print("Can't save file:", e, file=sys.stderr) def complete_save(self, text, line, begidx, endidx): return glob.glob(os.path.expanduser(text) + "*") def do_reload(self, args): """Reload current file from disk if present.""" cf = self.files[self.current] if cf.name == None: print("File is new and unsaved.") elif not os.path.exists(cf.name): print("File not present on disk.") elif args != "!": print("Enter `reload !` to confirm.") else: cf.load() cf.modified = False if not self.quiet: print("Reloaded", cf.name) def do_focus(self, args): """Show or set current file by index.""" if args == "": i = self.current f = self.files[i] print("Currently working on file:") print("{:2d} {}".format(i + 1, f)) elif args.isdigit(): self.current = int(args) - 1 if self.current < 0: self.current = 0 elif self.current >= len(self.files): self.current = len(self.files) - 1 else: print("File number expected.", file=sys.stderr) def do_at(self, args): """Show or set the current insertion cursor.""" cf = self.files[self.current] if args == "": print("Currently inserting at line:", cf.cursor + 1) elif args.isdigit(): cf.mark = int(args) - 1 if cf.mark < 0: cf.mark = 0 elif cf.mark > len(cf.lines): cf.mark = len(cf.lines) cf.cursor = cf.mark elif args == "end": cf.mark = len(cf.lines) cf.cursor = cf.mark else: print("Line number expected.", file=sys.stderr) def help_about(self): print(help_text["about"]) def help_cursor(self): print(help_text["cursor"]) def help_mark(self): print(help_text["mark"]) def help_ranges(self): print(help_text["ranges"]) def help_selection(self): print(help_text["selection"]) def select_range(self, text): try: cf = self.files[self.current] m, c = parse_range(text, len(cf.lines)) if c == 0: c = len(cf.lines) if m > c: m, c = c, m if m > 0: m -= 1 cf.mark, cf.cursor = m, c except ValueError as e: print(e, file=sys.stderr) def print_selection(self): cf = self.files[self.current] selection = cf.lines[cf.mark:cf.cursor] for i, ln in enumerate(selection, cf.mark): print("{:7d}".format(i + 1), ln) def do_first(self, args): """List the first N lines in the current file.""" cf = self.files[self.current] if args == "": cf.mark = 0 cf.cursor = self.step_size self.print_selection() elif args.isdigit(): cf.mark = 0 cf.cursor = min(int(args), len(cf.lines)) self.print_selection() else: print("Line number expected.", file=sys.stderr) def do_next(self, args): """List the next N lines in the current file.""" cf = self.files[self.current] ll = len(cf.lines) if cf.mark >= ll: print("(already at end of file)") elif args == "": cf.mark = cf.cursor cf.cursor = min(cf.mark + self.step_size, ll) self.print_selection() elif args.isdigit(): cf.mark = cf.cursor cf.cursor = min(cf.mark + int(args), ll) self.print_selection() else: print("Line number expected.", file=sys.stderr) def do_last(self, args): """List the last N lines in the current file.""" cf = self.files[self.current] if args == "": cf.mark = max(0, len(cf.lines) - self.step_size) cf.cursor = len(cf.lines) self.print_selection() elif args.isdigit(): cf.mark = max(len(cf.lines) - int(args), 0) cf.cursor = len(cf.lines) self.print_selection() else: print("Line number expected.", file=sys.stderr) def do_lines(self, args): """List lines in given range from current file.""" if args == "": print("Line range expected.", file=sys.stderr) return try: self.select_range(args) self.print_selection() except ValueError as e: print(e, file=sys.stderr) def do_delete(self, args): """Delete all lines in selection or given range.""" if args != "": self.select_range(args) cf = self.files[self.current] if not self.quiet: print(len(cf.lines[cf.mark:cf.cursor]), "lines deleted.") del cf.lines[cf.mark:cf.cursor] cf.cursor = cf.mark cf.modified = True def do_cut(self, args): """Cut selection or given range to clipboard.""" if args != "": self.select_range(args) cf = self.files[self.current] self.clipboard = cf.lines[cf.mark:cf.cursor] del cf.lines[cf.mark:cf.cursor] cf.cursor = cf.mark cf.modified = True if not self.quiet: print(len(self.clipboard), "lines cut to clipboard.") def do_copy(self, args): """Copy selection or given range to clipboard.""" if args != "": self.select_range(args) cf = self.files[self.current] self.clipboard = cf.lines[cf.mark:cf.cursor] if not self.quiet: print(len(self.clipboard), "lines copied to clipboard.") def do_paste(self, args): """Paste contents of clipboard at cursor position.""" if len(self.clipboard) == 0: print("Clipboard is empty.") else: cf = self.files[self.current] cf.lines[cf.mark:cf.cursor] = self.clipboard cf.modified = True if not self.quiet: print(len(self.clipboard), "lines pasted.") def do_back(self, args): """Delete the last line in the selection, if any.""" cf = self.files[self.current] if cf.cursor > cf.mark: cf.cursor -= 1 del cf.lines[cf.cursor] cf.modified = True if not self.quiet: print("Previous line deleted.") else: print("No more lines in selection.") def do_find(self, args): """Start a search or (with no argument) continue it.""" cf = self.files[self.current] args = shell_parse(args) if args == None: return elif args != "": self.search_string = args for i, ln in enumerate(cf.lines): if self.search_string in ln: cf.mark = i cf.cursor = i + 1 self.print_selection() return print("Search string not found.") self.search_string = "" elif self.search_string == "": print("No search string given.") else: lines = cf.lines[cf.cursor:] for i, ln in enumerate(lines, cf.cursor): if self.search_string in ln: cf.mark = i cf.cursor = i + 1 self.print_selection() return print("Search string not found.") self.search_string = "" def do_replace(self, args): """Replace found string with arg in selected line.""" if self.search_string == "": print("No search in progress.") else: args = shell_parse(args) if args == None: return cf = self.files[self.current] ln = cf.lines[cf.mark] ln = ln.replace(self.search_string, args) cf.lines[cf.mark] = ln cf.modified = True if not self.quiet: print("Replaced all in line", cf.mark + 1) def do_shell(self, args): """Run the given operating system command as-is.""" if self.safe: print("Command disabled in safe mode: shell.") else: os.system(args) def do_quit(self, args): """Quit program and return to the operating system.""" if self.modified and args != "!": print("Some files were modified.", "Enter `quit !` to quit anyway.") return False if not self.quiet: print("\nThanks for using Snaked!", "See you around!") return True def default(self, line): if line == "quit!" or line == "q!": return self.do_quit("!") elif line == "wq": self.do_save("") return self.do_quit("") elif line == "q": return self.do_quit("") elif line[0] == "/": self.do_find(line[1:]) elif line == "n": self.do_find("") elif line[0] == '>': cf = self.files[self.current] if cf.cursor < len(cf.lines): cf.mark = len(cf.lines) cf.cursor = cf.mark cf.lines.append(line[1:]) cf.cursor += 1 cf.modified = True elif line[0] == '.': cf = self.files[self.current] cf.lines.insert(cf.cursor, line[1:]) cf.cursor += 1 cf.modified = True elif line == "EOF": return self.do_quit("") else: print("\nUnknown command.", "Type `help` for a list.") def emptyline(self): print("No command given.") if __name__ == "__main__": import argparse cmdline = argparse.ArgumentParser( description="Line editor written in and for Python.") cmdline.add_argument("-v", "--version", action="version", version="Snaked version {}.".format(VERSION)) cmdline.add_argument("-q", "--quiet", action="store_true", help="don't print confirmation messages") cmdline.add_argument("-r", "--strict", action="store_true", help="disable new, open and close commands") cmdline.add_argument("-s", "--safe", action="store_true", help="disable access to Python and the OS") cmdline.add_argument("file", nargs="*", help="open given file before prompting for commands") args = cmdline.parse_args() editor = Editor( quiet=args.quiet, safe=args.safe, strict=args.strict) if len(args.file) > 0: for i in args.file: if os.path.exists(i): editor.open_file(i) else: f = TextFile(i).canonize() editor.current = len(editor.files) editor.files.append(f) if len(editor.files) > 1: # No need for the default new file anymore. del editor.files[0] editor.current = 0 if editor.quiet: editor.cmdloop(None) else: editor.cmdloop(app_banner)