diff --git a/core/buffers.py b/core/buffers.py new file mode 100644 index 0000000..aee180a --- /dev/null +++ b/core/buffers.py @@ -0,0 +1,46 @@ +import os + + +class Buffer: + def __init__(self, path: str, name: str = None, data: list = None): + self.path = path + self.name = name or "[No Name]" + self.data = data or [[""]] + + @staticmethod + def remove_char(string: str, index: int) -> str: + # Remove a character from a string at a given index + return string[:index] + string[index + 1:] + + @staticmethod + def insert_char(string: str, index: int, char: (str, chr)) -> str: + # Insert a character into a string at a given index + return string[:index] + char + string[index:] + + +def open_file(file_name): + # Open the file + with open(file_name) as f: + # Convert it into a list of lines + lines = f.readlines() + + # Return the list of lines + return lines + + +def load_file(file_path=None): + # Default settings for a file + file_name = "[No Name]" + file_data = [[""]] + + if file_path: + # Set the file's name + file_name = os.path.basename(file_path) + + # Only if the file actually exists + if os.path.exists(file_path): + # Open the file as a list of lines + file_data = open_file(file_name) + + # Return a dictionary which will become all the data about the buffer + return Buffer(file_path, file_name, file_data) diff --git a/core/colors.py b/core/colors.py index cfea4b2..52ac799 100644 --- a/core/colors.py +++ b/core/colors.py @@ -1,20 +1,51 @@ import curses +class Codes: + # Color codes + red = '\033[91m' + green = '\033[92m' + blue = '\033[94m' + yellow = '\033[93m' + cyan = '\033[96m' + magenta = '\033[95m' + white = '\033[97m' + selected_white = '\033[47m' + selected_green = '\033[42m' + strike = '\033[9m' + italic = '\033[3m' + end = '\033[0m' + + def init_colors(): - # Start colors in curses + # Activate color support curses.start_color() + + # Foreground: WHITE, Background: BLACK curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) + # Foreground: BLACK, Background: WHITE curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) + # Foreground: RED, Background: BLACK curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) + # Foreground: BLACK, Background: RED curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_RED) + # Foreground: GREEN, Background: BLACK curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK) + # Foreground: BLACK, Background: GREEN curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_GREEN) + # Foreground: YELLOW, Background: BLACK curses.init_pair(7, curses.COLOR_YELLOW, curses.COLOR_BLACK) + # Foreground: BLACK, Background: YELLOW curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) + # Foreground: CYAN, Background: BLACK curses.init_pair(9, curses.COLOR_CYAN, curses.COLOR_BLACK) + # Foreground: BLACK, Background: CYAN curses.init_pair(10, curses.COLOR_BLACK, curses.COLOR_CYAN) + # Foreground: BLUE, Background: BLACK curses.init_pair(11, curses.COLOR_BLUE, curses.COLOR_BLACK) + # Foreground: BLACK, Background: BLUE curses.init_pair(12, curses.COLOR_BLACK, curses.COLOR_BLUE) + # Foreground: MAGENTA, Background: BLACK curses.init_pair(13, curses.COLOR_MAGENTA, curses.COLOR_BLACK) + # Foreground: BLACK, Background: MAGENTA curses.init_pair(14, curses.COLOR_BLACK, curses.COLOR_MAGENTA) diff --git a/core/components.py b/core/components.py new file mode 100644 index 0000000..db98030 --- /dev/null +++ b/core/components.py @@ -0,0 +1,31 @@ +from core import utils +import curses + + +class StatusBar: + def __init__(self, instance): + self.instance = instance + self.mode = instance.mode.upper() + self.file = instance.buffer.name or "[No Name]" + self.icon = instance.config["icon"] or "λ" + self.colors = (7, 5, 13) + self.components = [self.icon, self.mode, self.file] + + def render(self): + x = 1 + utils.clear(self.instance, self.instance.height - 2, 0) + for count, component in enumerate(self.components): + self.instance.screen.addstr(self.instance.height - 2, x, component, + curses.color_pair(self.colors[count]) | curses.A_BOLD) + x += len(component) + 1 + + +class Components: + def __init__(self, components: dict = None): + self.components = components or { + "bottom": [StatusBar], + } + + def render(self, instance): + for component in self.components["bottom"]: + component(instance).render() diff --git a/core/cursors.py b/core/cursors.py new file mode 100644 index 0000000..34071ec --- /dev/null +++ b/core/cursors.py @@ -0,0 +1,40 @@ +import curses + + +def cursor_mode(mode): + if mode == "block": + print("\033[2 q") + + elif mode == "line": + print("\033[6 q") + + elif mode == "hidden": + curses.curs_set(0) + + elif mode == "visible": + curses.curs_set(1) + + +def cursor_push(cursor: list, direction: (int, str)) -> list: + if direction in (0, "up", "north"): + # Decrease the y position + cursor[0] -= 1 + + elif direction in (1, "right", "east"): + # Increase the x position + cursor[1] += 1 + + elif direction in (2, "down", "south"): + # Increase the y position + cursor[0] += 1 + + elif direction in (3, "left", "west"): + # Decrease the x position + cursor[1] -= 1 + + return cursor + + +def cursor_move(screen, cursor: list): + # Moves the cursor to anywhere on the screen + screen.move(cursor[0], cursor[1]) diff --git a/core/modes.py b/core/modes.py new file mode 100644 index 0000000..64ecd7c --- /dev/null +++ b/core/modes.py @@ -0,0 +1,33 @@ +from mode import normal, insert, command + + +def activate(instance, mode) -> object: + # Visibly update the mode + instance.mode = mode + instance.update() + + if mode == "command": + instance = command.activate(instance) + + elif mode == "insert": + instance = insert.activate(instance) + + elif mode == "normal": + instance = normal.activate(instance) + + return instance + + +def handle_key(instance, key): + if instance.mode == "normal": + instance = normal.execute(instance, key) + + # Insert mode - inserting text to the buffer + elif instance.mode == "insert": + instance = insert.execute(instance, key) + + # Command mode - extra commands for lambda + elif instance.mode == "command": + instance = command.activate(instance) + + return instance diff --git a/core/utils.py b/core/utils.py index 00367e1..7b7b5c6 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,51 +1,109 @@ -from sys import exit import curses +import sys -def refresh_window_size(screen, data): - # Get the height and width of the screen - data["height"], data["width"] = screen.getmaxyx() - - # Return the data as changes may have been made - return data +def clear(instance, y: int, x: int): + # Clear the line at the screen at position y, x + instance.screen.insstr(y, x, " " * (instance.width - x)) -def clear_line(screen, data, line): - # Clear the specified line - screen.addstr(line, 0, " " * (data["width"] - 1), curses.color_pair(1)) +def welcome(screen): + # Get window height and width + height, width = screen.getmaxyx() + + # Startup text + title = "λ Lambda" + subtext = [ + "Next generation hackable text editor for nerds", + "", + "Type :h to open the README.md document", + "Type :o to open a file and edit", + "Type :q or to quit lambda.py" + ] + + # Centering calculations + start_x_title = int((width // 2) - (len(title) // 2) - len(title) % 2) + start_y = int((height // 2) - 2) + + # Rendering title + screen.addstr(start_y, start_x_title, title, curses.color_pair(7) | curses.A_BOLD) + + # Print the subtext + for text in subtext: + start_y += 1 + start_x = int((width // 2) - (len(text) // 2) - len(text) % 2) + screen.addstr(start_y, start_x, text) -def prompt(screen, data, text): - # Print the prompt - screen.addstr(data["height"] - 1, 0, text, curses.color_pair(11)) +def prompt(instance, message: str, color: int = 1) -> (list, None): + # Initialise the input list + inp = [] - # Wait for and capture a key press from the user - return screen.getch() + # Write the message to the screen + instance.screen.addstr(instance.height - 1, 0, message, curses.color_pair(color)) + + while True: + # Wait for a keypress + key = instance.screen.getch() + + # Subtracting a key (backspace) + if key in (curses.KEY_BACKSPACE, 127, '\b'): + # Write whitespace over characters to refresh it + clear(instance, instance.height - 1, 0) + + if inp: + # Subtract a character from the input list + inp.pop() + + else: + # Exit the prompt without returning the input + return None + + elif key == 27: + # Exit the prompt, without returning the input + return None + + elif key in (curses.KEY_ENTER, ord('\n'), ord('\r'), ord(":"), ord(";")): + # Return the input list + return inp + + else: + # If any other key is typed, append it + # As long as the key is in the valid list + valid = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ!" + if chr(key) in valid and len(inp) < (instance.width - 2): + inp.append(chr(key)) + + # Write the message to the screen + instance.screen.addstr(instance.height - 1, 0, message, curses.color_pair(color)) + + # Join the input together for visibility on the screen + input_text = "".join(inp) + + # Write the input text to the screen + instance.screen.addstr(instance.height - 1, len(message), input_text) -def goodbye(screen, data): - # Create a goodbye prompt - key = prompt(screen, data, "Really quit? (y or n): ") +def goodbye(instance): + choice = prompt(instance, "Really quit lambda? (y/n): ", 11) + if choice and choice[0] == "y": + curses.endwin() + sys.exit() - # Clear the bottom line - clear_line(screen, data, data["height"] - 1) - - if key == ord("y"): - # Only exit if the key was "y", a confirmation - exit() - - # Clear the bottom line again - clear_line(screen, data, data["height"] - 1) + else: + clear(instance, instance.height - 1, 0) -def error(screen, data, error_msg): - # Print the error message to the bottom line - error_msg = f"ERROR: {error_msg}" - screen.addstr(data["height"] - 1, 0, error_msg, curses.color_pair(3)) - screen.addstr(data["height"] - 1, len(error_msg) + 1, "(press any key) ", curses.color_pair(1)) +def error(instance, message: str): + # Parse the error message + error_message = f"ERROR: {message}" - # Wait for a key to be pressed - screen.getch() + # Write the entire message to the screen + instance.screen.addstr(instance.height - 1, 0, f"ERROR: {message}", curses.color_pair(3)) + instance.screen.addstr(instance.height - 1, len(error_message) + 1, f"(press any key)") - # Clear the bottom line - clear_line(screen, data, data["height"] - 1) + # Wait for a keypress + instance.screen.getch() + + # Clear the bottom of the screen + clear(instance, instance.height - 1, 0) diff --git a/main.py b/main.py new file mode 100644 index 0000000..964e2f2 --- /dev/null +++ b/main.py @@ -0,0 +1,110 @@ +from core import colors, cursors, buffers, modes, utils +from core.buffers import Buffer +from core.components import Components +import traceback +import argparse +import curses +import sys +import os + + +class Lambda: + def __init__(self, buffer: Buffer): + self.screen = curses.initscr() + self.buffer = buffer + self.cursor = [0, 0] + self.mode = "normal" + self.components = Components() + self.height = self.screen.getmaxyx()[0] + self.width = self.screen.getmaxyx()[1] + self.safe_height = self.height - len(self.components.components["bottom"]) + self.config = {"icon": "λ"} + + def update_dimensions(self): + self.height, self.width = self.screen.getmaxyx() + self.safe_height = self.height - len(self.components.components["bottom"]) + + def update(self): + # Update the dimensions + self.update_dimensions() + + # Refresh the on-screen components + self.components.render(self) + + # Move the cursor + cursors.cursor_move(self.screen, self.cursor) + + def start(self): + # Initialise colors + colors.init_colors() + + # Change the cursor shape + cursors.cursor_mode("block") + + # Turn no echo on + curses.noecho() + + # Show a welcome message if lambda opens with no file + if not self.buffer.path: + utils.welcome(self.screen) + + # Main loop + self.run() + + def run(self): + while True: + # Write the buffer to the screen + # buffers.write_buffer(screen, buffer) + + # Update the screen + self.update() + + # Wait for a keypress + key = self.screen.getch() + + # Handle the key + modes.handle_key(self, key) + + # Refresh and clear the screen + self.screen.refresh() + self.screen.clear() + + +def main(): + # Command line arguments + parser = argparse.ArgumentParser(description="Next generation hackable text editor for nerds.") + parser.add_argument("file", metavar="file", type=str, nargs="?", + help="The name of a file for lambda to open") + + # Collect the arguments passed into lambda + args = parser.parse_args() + + # Load the file into buffer + buffer = buffers.load_file(args.file) + + # Change the escape delay to 25ms + # Fixes an issue where esc takes way too long to press + os.environ.setdefault("ESCDELAY", "25") + + # Load lambda with the buffer + screen = Lambda(buffer) + + # Start the screen + try: + screen.start() + + # KeyboardInterrupt is thrown when ctrl+c is pressed + except KeyboardInterrupt: + curses.endwin() + sys.exit() + + except Exception as exception: + curses.endwin() + print(f"{colors.Codes.red}FATAL ERROR:{colors.Codes.end} " + f"{colors.Codes.yellow}{exception}{colors.Codes.end}\n") + print(traceback.format_exc()) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/mode/command.py b/mode/command.py new file mode 100644 index 0000000..82a4a5a --- /dev/null +++ b/mode/command.py @@ -0,0 +1,34 @@ +from core import utils + + +def execute(instance, commands): + if not commands: + # Quit if there are no commands, don't check anything + return instance + + for command in commands: + if command == "w": + # Write to the file + pass + + elif command == "q": + # Load a goodbye prompt + utils.goodbye(instance) + + else: + utils.error(instance, f"not an editor command: '{command}'") + + return instance + + +def activate(instance): + # Start the prompt + commands = utils.prompt(instance, ":") + + # Execute the commands + instance = execute(instance, commands) + + # Return to normal mode + instance.mode = "normal" + + return instance diff --git a/mode/insert.py b/mode/insert.py new file mode 100644 index 0000000..ba0ea9b --- /dev/null +++ b/mode/insert.py @@ -0,0 +1,18 @@ +from core import cursors +from mode import normal + + +def execute(instance, key): + if key == 27: + # Switch to normal mode + instance.mode = "normal" + normal.activate(instance) + + return instance + + +def activate(instance): + # Switch the cursor to a line + cursors.cursor_mode("line") + + return instance diff --git a/mode/normal.py b/mode/normal.py new file mode 100644 index 0000000..cd3d9de --- /dev/null +++ b/mode/normal.py @@ -0,0 +1,45 @@ +from core import cursors, modes +from mode import insert +from mode import command + + +def execute(instance, key): + if key == ord("j"): + # Move the cursor down + instance.cursor = cursors.cursor_push(instance.cursor, "down") + + elif key == ord("k"): + # Move the cursor up + instance.cursor = cursors.cursor_push(instance.cursor, "up") + + elif key == ord("l"): + # Move the cursor right + instance.cursor = cursors.cursor_push(instance.cursor, "right") + + elif key == ord("h"): + # Move the cursor left + instance.cursor = cursors.cursor_push(instance.cursor, "left") + + elif key == ord("i"): + # Activate insert mode + instance = modes.activate(instance, "insert") + + elif key == ord("I"): + # Move the cursor to the right + instance.cursor = cursors.cursor_push(instance.cursor, "right") + + # Then activate insert mode + instance = modes.activate(instance, "insert") + + elif key in (ord(":"), ord(";")): + # Activate command mode + instance = modes.activate(instance, "command") + + return instance + + +def activate(instance): + # Switch the cursor to a block + cursors.cursor_mode("block") + + return instance \ No newline at end of file