#!/usr/bin/env python3 # # Chalk: A line mode text editor # (c) 2020 Brian Evans, All Rights Reserved # # Chalk is available under the terms of the Floodgap Free # Software License. A copy of the license should be included # with this source code. An online copy can be found here: # https://www.floodgap.com/software/ffsl/license.txt #_ ########################################################### # Imports ########################################################### import sys import os import subprocess import re import readline ########################################################### # Globals ########################################################### content = [] paste_buffer = [] file_changed = False filepath = '' filename = 'scratch buffer' view_loc = {'last': 0, 'count': 0} version = 2.1 ########################################################### # Classes ########################################################### class c: # \001 and \002 fix a readline issue # See: https://stackoverflow.com/questions/9468435/how-to-fix-column-calculation-in-python-readline-if-using-color-prompt black = '' red = '\033[0;31m' b_red = '\033[1;31m' yellow = '\033[1;33m' green = '\033[0;32m' b_green = '\033[1;32m' cyan = '\033[0;36m' b_cyan = '\033[1;36m' purple = '\033[1;35m' blue = '\033[0;34m' b_blue = '\033[1;34m' white = '\033[1;37m' end = '\033[0m' invert = '\033[7m' bold = '\033[1m' ########################################################### # Functions ########################################################### ### ################################# ### Utilities and helpers ################################# def pre_input_hook(txt): readline.insert_text(txt) readline.redisplay() def input_editable(prompt, prefill=''): readline.set_pre_input_hook(lambda: pre_input_hook(prefill)) try: edin = input(prompt) finally: readline.set_pre_input_hook(None) return edin def validate_path(path): path_body_list = path.split('/') path_body_list.pop() path_body = '/'.join(path_body_list) is_path = os.path.isdir(path_body) if not is_path: return False return True def check_file_writable(fnm): if os.path.exists(fnm): if os.path.isfile(fnm): # is it a file or a dir? return os.access(fnm, os.W_OK) else: return False pdir = os.path.dirname(fnm) if not pdir: pdir = '.' return os.access(pdir, os.W_OK) def print_help(): helptext = [ "", "{}Commands are entered as the only entry for their row:{}".format(c.yellow, c.end), "", " {}.?{} - Print this help message".format(c.b_green, c.end), " {}.g{} - Print the ruler/guide".format(c.b_green, c.end), " {}.f{} - Print file info".format(c.b_green, c.end), "", " {}.d{} - Display the whole file".format(c.b_green, c.end), " {}.v{} - View range of lines (will request location/count)".format(c.b_green, c.end), " {}.m{} - View MORE (use after using .v to see more)".format(c.b_green, c.end), "", " {}.#{} - Edit a line (eg .27)".format(c.b_green, c.end), " {}.i{} - Insert empty line(s) (will request location/count)".format(c.b_green, c.end), " {}.x{} - Cut/copy line(s) (will request line range)".format(c.b_green, c.end), "", " {}.c{} - Copy to the paste buffer (will request line range)".format(c.b_green, c.end), " {}.p{} - Paste from the paste buffer (will request destination)".format(c.b_green, c.end), " {}.b{} - Buffer view (print the paste buffer)".format(c.b_green, c.end), "", " {}.s{} - Save changes to the document".format(c.b_green, c.end), " {}.a{} - Save as a new file (will request file location)".format(c.b_green, c.end), " {}. {} - Finish writing/exit (will prompt for save)".format(c.b_green, c.end), "", "{}- - -{}".format(c.yellow, c.end), "" ] for x in helptext: print('{:8} {}'.format(' ',x)) # Print ruler will print the text guide/rule # at the width of the current terminal - ui indent def print_ruler(): try: width = os.get_terminal_size()[0] - 9 except: width = 60 - 9 counter = " " ticker = " " current = 5 while current < width - 5: counter += "{:5}".format(current) ticker += "....|" current += 5 print(counter) print(ticker) def print_file_info(): user_can_write = os.access(filepath, os.W_OK) print('{} Writing:{} {}{}'.format(c.yellow, c.white, filename, c.end)) print('{} Length :{} {} rows / {} chars{}'.format(c.yellow, c.white, len(content), get_character_count(), c.end)) if not filepath: print('{} You are in the scratch buffer. Use \033[3m.a\033[23m to save your work as a file{}'.format(c.bold, c.end)) elif not user_can_write: print('{} You do not have permission to write to {} >>{}'.format(c.bold, filename, c.end)) if file_changed: print('{} File has unsaved changes!{}\n'.format(c.bold, c.end)) # Print banner will print the program name, def print_banner(): print('\n Chalk {} by sloum\n'.format(version)) print_file_info() print(" For a command list, enter {}.?\n{}".format(c.green, c.end)) def get_character_count(): chars = 0 for row in content: chars += len(row) return chars # Build contents from file sets the absolute # file path, the file name, and loads in any # content contained in a file def build_contents_from_file(path): # No path was given, just create an empty # content buffer and it can be saved later if not path: return global content global filepath global filename path = os.path.abspath(path) filepath = os.path.expanduser(path) filename = filepath.split('/')[-1] try: valid_path = validate_path(filepath) if not valid_path: print('Invalid file path') os.exit(2) with open(filepath, 'r') as f: content = f.read().split('\n') if content[-1] == '': content.pop() except FileNotFoundError: content = [] except PermissionError: print('You do not have permission to read {}'.format(path)) os.exit(2) # Yes No queries the user with a yes no question and returns # True on yes and False on no def yes_no(question): confirmation = '' while confirmation not in ['y','yes','n','no']: confirmation = input(question) confirmation = confirmation.lower() return True if confirmation in ['y', 'yes'] else False #### ########################### ##### Input and Command Routers ########################### # Chalk is the entry point into the editor, contains the # input loop and does some routing def chalk(path): global file_changed build_contents_from_file(path) print_banner() print_ruler() while True: ln = input('{:6} \001{}\002>\001{}\002 '.format(len(content), c.yellow, c.end)) if ln == '.': # End the editing session (quit) # Will query for save if the file has been changed quit() elif re.match(r'^\.\d+$',ln): # Edit a previous line edit_line(ln) elif len(ln) == 2 and ln[0] == '.': # Route a command command_router(ln) else: # Add new content content.append(ln) file_changed = True # Command router takes a command line and routes it to its # command function def command_router(ln): if ln == '.?': print_help() elif ln == '.a': save_as() elif ln == '.b': view_paste_buffer() elif ln == '.c': copy_rows() elif ln == '.d': display_file() elif ln == '.f': print_file_info() elif ln == '.g': print_ruler() elif ln == '.i': insert_lines() elif ln == '.m': view_continue() elif ln == '.p': paste_from_buffer() elif ln == '.v': view_rows() elif ln == '.x': cut_lines() elif ln == '.s': save_changes() else: print('{:9}{}Unknown command: {}{}'.format(' ', c.red, ln, c.end)) ##### ################################### ##### Command Functions ################################### # Edit line edits an existing line or prints an error # if the line was unable to be edited def edit_line(ln): global content global file_changed try: row = int(ln[1:]) text = content[row] newln = input_editable('{:6} \001{}\002>\001{}\002 '.format(row, c.b_blue, c.end), content[row]) if newln != text: content[row] = newln file_changed = True except: print('{}{:8} Invalid entry!{}'.format(c.b_red, ' ', c.end)) # Save changes saves changes to a file def save_changes(): global file_changed if not file_changed: return True text = '\n'.join(content) text += '\n' try: with open(filepath, 'w') as f: f.write(text) print(' Saved \033[1m{}\033[0m'.format(filename)) file_changed = False return True except PermissionError: print('{} You do not have permission to write to this file.{}'.format(c.red, c.end)) return False except: print('{} Error while writing to file: {}{}'.format(c.red, e, c.end)) return False # Save as will switch the save location to # a new file path and save the content buffer # to that new path. It will validate the path # beforehand def save_as(): global filepath global filename global file_changed print('{:9}{}Enter the new save path (can be relative):{}'.format(' ', c.cyan, c.end)) path = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.green, c.end)) continue_save_as = yes_no('{:9}{}Are you sure you want to save as {}? (y/n){} '.format(' ', c.cyan, path, c.end)) if not continue_save_as: print('{:9}Save canceled.'.format(' ')) return False path = os.path.abspath(path) fp = os.path.expanduser(path) fn = path.split('/')[-1] valid = check_file_writable(fp) if not valid: print('{:9}{}The path is invalid, save cancelled{}'.format(' ', c.red, c.end)) return False filepath = fp filename = fn file_changed = True return save_changes() # Quit will quit the program, but first will ask to save if there # are any unsaved changes def quit(): if not file_changed: sys.exit(0) save = yes_no(' {}Save changes to {}?{} (Y/n) '.format(c.b_green, filename, c.end)) if save: saved = save_changes() if filepath else save_as() if saved: print(' File "{}" has been saved.'.format(filename)) sys.exit(0) else: sys.exit(1) else: sys.exit(0) # Display file prints the whole file, line by line, to # stdout def display_file(): print('\n - - -') for i, x in enumerate(content): print('{:6} - {}{}{}'.format(i, c.green, x, c.end)) print(' - - -\n') def cut_lines(): global content global file_changed global paste_buffer print('{:9}{}Enter the line number you want to start deleting at:{}'.format(' ', c.cyan, c.end)) beg = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.b_red, c.end)) print('{:9}{}Enter the last line number you want to delete (or $ for end of file){}:'.format(' ', c.cyan, c.end)) end = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.b_red, c.end)) continue_delete = yes_no('{:9}{}Are you sure you want to delete lines {} - {}? (y/n){} '.format(' ', c.cyan, beg, end, c.end)) if not continue_delete: print('{:9}Deletion canceled.'.format(' ')) if end == '$': end = len(content) - 1 try: beg = int(beg) end = int(end) + 1 if beg < 0 or beg > end: print('{}{:9}Invalid entry{}'.format(c.red, ' ', c.end)) return paste_buffer = content[beg:end] if end == len(content): content = content[:beg] else: content = content[:beg] + content[end:] file_changed = True except: print('{}{:9}Invalid entry{}\n'.format(c.red, ' ', c.end)) def insert_lines(): global content global file_changed print('{:9}{}Enter the line number you want to insert lines before:{}'.format(' ', c.cyan, c.end)) beg = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.b_green, c.end)) print('{:9}{}Enter the number of rows you want to insert{}:'.format(' ', c.cyan, c.end)) count = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.b_green, c.end)) continue_insert = yes_no('{:9}{}Are you sure you want to insert {} rows before line {}? (y/n){} '.format(' ', c.cyan, count, beg, c.end)) if not continue_insert: print('{:9}Insertion canceled.'.format(' ')) try: beg = int(beg) count = int(count) if beg < 0 or beg > len(content) or count < 1: print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) return while count > 0: content.insert(beg,'') count -= 1 file_changed = True except: print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) def view_rows(): global view_loc print('{:9}{}Enter the line number you want to start viewing from:{}'.format(' ', c.cyan, c.end)) start = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.yellow, c.end)) print('{:9}{}Enter the number of rows you want to view{}:'.format(' ', c.cyan, c.end)) count = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.yellow, c.end)) try: beg = int(start) counter = int(count) if beg > len(content) - 1: print('{}{:9}Cannot start viewing past the end of the file{}') if beg < 0 or beg > len(content) - 1: print('{}{:9}Invalid view location{}'.format(c.red, ' ', c.end)) return else: view_loc['count'] = counter print('') while counter > 0 and beg < len(content): print('{:6} - {}{}{}'.format(beg, c.green, content[beg], c.end)) counter -= 1 beg += 1 print('') view_loc['last'] = beg if beg < len(content) - 1 else None except: print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) def view_continue(): global view_loc if not view_loc['count'] or view_loc['last'] is None: print('{}{:9}There is not a current view opperation to continue{}'.format(c.red, ' ', c.end)) return beg = view_loc['last'] counter = view_loc['count'] print('') while counter > 0 and beg < len(content): print('{:6} - {}{}{}'.format(beg, c.green, content[beg], c.end)) counter -= 1 beg += 1 print('') view_loc['last'] = beg if beg < len(content) - 1 else None def copy_rows(): global paste_buffer print('{:9}{}Enter the line number you want to start copying from:{}'.format(' ', c.cyan, c.end)) start = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.yellow, c.end)) print('{:9}{}Enter the last line you want to copy ($ for end of file):{}'.format(' ', c.cyan, c.end)) finish = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.yellow, c.end)) if finish == '$': finish = len(content) - 1 try: beg = int(start) end = int(finish) if beg > end or beg < 0 or end > len(content) - 1: print('{}{:9}Invalid entry x{}'.format(c.red, ' ', c.end)) return else: paste_buffer = content[beg:end + 1] except: print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) def paste_from_buffer(): global content global file_changed print('{:9}{}Enter a line number. The pasted data will be inserted {}before{} the given line:{}'.format(' ', c.cyan, '\033[4m', '\033[24m', c.end)) beg = input('{:6} \001{}\002>\001{}\002 '.format(' ', c.b_green, c.end)) continue_paste = yes_no('{:9}{}Are you sure you want to paste from the paste buffer before line {}? (y/n){} '.format(' ', c.cyan, beg, c.end)) if not continue_paste: print('{:9}Paste canceled.'.format(' ')) try: beg = int(beg) if beg < 0 or beg > len(content): print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) return for row in paste_buffer[::-1]: content.insert(beg,row) file_changed = True except: print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end)) def view_paste_buffer(): print('') if len(paste_buffer): for num, ln in enumerate(paste_buffer): print('{:6} - {}{}{}'.format('pb', c.blue, ln, c.end)) else: print('{:6} - {}{}{}'.format('pb', c.blue, 'The paste buffer is currently empty', c.end)) print('') ########################################################### # Init ########################################################### if __name__ == '__main__': args = sys.argv if len(args) > 2: print('Incorrect number of arguments:') print('chalk [\033[3mfile path\033[0m]') sys.exit(1) elif len(args) == 2: if len(args[1]) and args[1][0] == "-": print('Unknown flag {}'.format(args[1])) print('chalk [\033[3mfile path\033[0m]') sys.exit(1) filepath = args[1] # Set readline settings readline.parse_and_bind('set editing-mode emacs') readline.parse_and_bind('set show-mode-in-prompt off') # Run the editor chalk(filepath)