0
0
Fork 0
chalk/chalk

516 lines
15 KiB
Python
Executable File

#!/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 = ''
###########################################################
# 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 = '\001\033[0;31m\002'
b_red = '\001\033[1;31m\002'
yellow = '\001\033[1;33m\002'
green = '\001\033[0;32m\002'
b_green = '\001\033[1;32m\002'
cyan = '\001\033[0;36m\002'
b_cyan = '\001\033[1;36m\002'
purple = '\001\033[1;35m\002'
blue = '\001\033[0;34m\002'
b_blue = '\001\033[1;34m\002'
white = '\001\033[1;37m\002'
end = '\001\033[0m\002'
bold = '\001\033[1m\002'
###########################################################
# 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),
"",
" {}!d{} - Display the whole file".format(c.b_green, c.end),
" {}!v{} - View range of lines (will request line range)".format(c.b_green, c.end),
"",
" {}!#{} - Edit a line (i.e. !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)
# Print banner will print the program name,
def print_banner(fn):
print('\n Chalk 1.0 by sloum')
print('\n{} Writing:{} {}{}'.format(c.yellow, c.white, fn, c.end))
print(" For a command list, enter {}!?\n{}".format(c.green, c.end))
# 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):
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 filename
global file_changed
build_contents_from_file(path)
print_banner(filename)
print_ruler()
while True:
ln = input('{:6} {}>{} '.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 == '!g':
print_ruler()
elif ln == '!d':
display_file()
elif ln == '!v':
view_rows()
elif ln == '!i':
insert_lines()
elif ln == '!x':
cut_lines()
elif ln == '!c':
copy_rows()
elif ln == '!p':
paste_from_buffer()
elif ln == '!b':
view_paste_buffer()
elif ln == '!s':
save_changes()
elif ln == '!a':
save_as()
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} {}>{} '.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} {}>{} '.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(' '))
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))
filepath = fp
filename = fn
file_changed = True
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 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} {}>{} '.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} {}>{} '.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} {}>{} '.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} {}>{} '.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():
print('{:9}{}Enter the line number you want to start viewing from:{}'.format(' ', c.cyan, c.end))
start = input('{:6} {}>{} '.format(' ', c.yellow, c.end))
print('{:9}{}Enter the last line you want to view ($ for end of file):{}'.format(' ', c.cyan, c.end))
finish = input('{:6} {}>{} '.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:
print('')
for num, ln in enumerate(content[beg:end+1]):
print('{:6} - {}{}{}'.format(num+beg, c.green, ln, c.end))
print('')
except:
print('{}{:8} Invalid entry{}'.format(c.red, ' ', c.end))
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} {}>{} '.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} {}>{} '.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} {}>{} '.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 [file path]')
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)