diff --git a/.gitignore b/.gitignore index 9efad1c..ee44a96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -__pycache__ -core/__pycache__ -mode/__pycache__ -.idea \ No newline at end of file +.idea +target diff --git a/.replit b/.replit new file mode 100644 index 0000000..6074353 --- /dev/null +++ b/.replit @@ -0,0 +1 @@ +run = "cargo run" diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aa77f7c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + +[[package]] +name = "lambda" +version = "0.1.0" +dependencies = [ + "crossterm", +] + +[[package]] +name = "libc" +version = "0.2.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "mio" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "signal-hook" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..894e8dd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lambda" +version = "0.1.0" +authors = ["Madeline "] +edition = "2021" +description = "Next generation hackable text editor for nerds" +homepage = "https://github.com/SpyHoodle/lambda" +repository = "https://github.com/SpyHoodle/lambda" +readme = "README.md" +include = ["src/*.rs", "Cargo.toml"] +categories = ["text-editors"] +keywords = ["text-editor", "editor", "terminal", "tui"] + +[profile.release] +panic = abort + +[dependencies] +crossterm = "0.25" diff --git a/README.md b/README.md index b760fd7..7389f60 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,29 @@ -# λ lambda +# λ Lambda +A next-generation hackable incredibly performant rust text editor for nerds. +> ⚠️ Lambda is in *very* early stages at the moment. Lambda's goals are still being decided and features may completely change. -Next generation hackable text editor for nerds. +## Overview +Lambda is a similar text editor to `vim` or `kakoune`, taking some ideas from `xi`. +The main goal is to build the best text editor possible by taking ideas from existing text editors and implementing them in the best possible way. -### Let it be known! - -Lambda is in *very* early stages at the moment. Features may change completely, or even be removed.
-Don't expect lambda to stay the way it is. Updates are pushed often. - -### Overview - -Lambda is a similar text editor to `vim` or `kakoune`.
-However, it takes a different approach to most of the features seen in other editors. - -- Lambda is written in Python, so it is easy to hack and learn. - - It also has a good amount of comments! -- Lambda is incredibly modular, so you can easily add new features. -- Lambda follows the unix philosophy of "do one thing and do it well." - - It has no bloated features, like splits or tabs - - It contains the bare necessities and provides a few extra modules -- Lambda isn't limited to modes or keybindings. - - Keybindings and modes can be easily changed - - Other modes can be used by holding down keybindings (i.e. `ctrl-x` inside of `insert` mode) -- Lambda is extremely fast and makes use of efficient memory management. - - Neovim is slow, and actually requires [a plugin to speed it up](https://github.com/lewis6991/impatient.nvim). -- Lambda has much better default keybindings than other text editors. - -### Getting started +- Lambda is written in Rust, so it's incredibly fast and logical + - It's also full of comments, so anyone can try and learn what it's doing +- Lambda is very modular and extensible, so features can be easily added through a variety of methods + - Need to run a set of keybinds in order? Create a macro + - Need to create a completely new feature? Just fork it and write it in Rust +- Lambda is separated between the core and the standard terminal implementation + - This means that anyone can implement their own keybinds, ui, themes, styling, etc. + - This also means that there is one standard way for managing the text itself - inside of the lambda core +- Lambda is a modal text editor and uses ideas from kakoune, and is designed for the select -> action structure + - Since anyone can implement their own keybinds, it's possible to make a vim implementation that uses the action -> select structure +- Lambda follows the unix philosophy of "do one thing and do it well" + - It has no bloated features like splits or tabs + - It contains the bare necessities and provides a few extra modules +- Lambda has much better default keybindings than other text editors +## Getting started +You'll need `cargo` (ideally from `rustup`) and an up to date version of `rust`. ```bash -git clone https://github.com/SpyHoodle/lambda.git # Clone the repository -cd lambda # Enter lambda directory -chmod +x install.sh # Make the install script executable -./install.sh # Run the install script -``` \ No newline at end of file +git clone https://github.com/SpyHoodle/lambda.git # Clone the repositiory +cargo run # Build and run lambda! +``` diff --git a/TODO.md b/TODO.md index a16511d..798b9f4 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,2 @@ -## TODO \ No newline at end of file +- [ ] Make components (i.e statusbar) modular +- [ ] Modularise functions diff --git a/core/buffers.py b/core/buffers.py deleted file mode 100644 index ad7279e..0000000 --- a/core/buffers.py +++ /dev/null @@ -1,90 +0,0 @@ -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 [""] - - def render(self, instance): - for y, line in enumerate(self.data[instance.offset[0]:]): - if y <= instance.safe_height: - for x, character in enumerate(line[instance.offset[1]:]): - if x <= instance.safe_width: - instance.screen.addstr(y, x + instance.components.get_component_width( - instance.components.components["left"]), character) - - # Write blank spaces for the rest of the line - if instance.safe_width - len(line) > 0: - instance.screen.addstr(y, instance.components.get_component_width( - instance.components.components["left"]) + len(line), " " * (instance.safe_width - len(line))) - - @staticmethod - def delete_line(instance, y: int = None): - # Default to the cursor position - y = y or instance.cursor[0] - - # Remove a line from the buffer - instance.buffer.data.pop(y) - - @staticmethod - def insert_line(instance, y: int = None): - # Default to the cursor position - y = y or instance.cursor[0] - - # Insert a line into the buffer - instance.buffer.data.insert(y, "") - - @staticmethod - def delete_char(instance, y: int = None, x: int = None): - # Default to the cursor position - y = y or instance.cursor[0] - x = x or instance.cursor[1] - - # Remove a character from the line at a given index - instance.buffer.data[y] = instance.buffer.data[y][:x - 1] + instance.buffer.data[y][x:] - - @staticmethod - def insert_char(instance, char: (str, chr), y: int = None, x: int = None): - # Default to the cursor position - y = y or instance.cursor[0] - x = x or instance.cursor[1] - - # Insert a character into the line at a given index - instance.buffer.data[y] = instance.buffer.data[y][:x] + char + instance.buffer.data[y][x:] - - -def open_file(file_path): - # Open the file - with open(file_path) as f: - # Convert it into a list of lines - lines = f.readlines() - - # Add a line if the file is empty or if the last line is not empty - if lines[-1].endswith("\n") or not len(lines): - lines.append("") - - # Remove the newlines - lines = [line.rstrip("\n") for line in lines] - - # 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_path) - - # 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 deleted file mode 100644 index 1680042..0000000 --- a/core/colors.py +++ /dev/null @@ -1,51 +0,0 @@ -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(): - # 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 deleted file mode 100644 index 64c2ef0..0000000 --- a/core/components.py +++ /dev/null @@ -1,73 +0,0 @@ -import curses - -from core import utils - - -class StatusBar: - def __init__(self, instance): - self.mode = instance.mode.upper() - self.file = instance.buffer.name or "[No Name]" - self.icon = instance.config["icon"] or "λ" - self.theme = "default" - self.colors = [7, 5, 13] - self.components = [self.icon, self.mode, self.file] - - def update(self, instance): - self.mode = instance.mode.upper() - self.components = [self.icon, self.mode, self.file] - - def render(self, instance): - # Clear the status bar - utils.clear_line(instance, instance.height - 2, 0) - - # Update variables - self.update(instance) - - if self.theme == "inverted": - # Initialise the x position for each component - x = 1 - - # Render each component - for count, component in enumerate(self.components): - instance.screen.addstr(instance.height - 2, x, component, - curses.color_pair(self.colors[count]) | curses.A_BOLD) - x += len(component) + 1 - - else: - # Initialise temporary colors for inverted theme - colors = [] - - # Add 1 to each color temporarily - for color in self.colors: - colors.append(color + 1) - - # Initialise the x position for each component - x = 0 - - # Render each component - for count, component in enumerate(self.components): - component = f" {component} " - instance.screen.addstr(instance.height - 2, x, component, - curses.color_pair(colors[count]) | curses.A_BOLD) - x += len(component) - - # Add a space at the end of the status bar - instance.screen.addstr(instance.height - 2, x, " " * (instance.width - x), - curses.color_pair(2)) - - -class Components: - def __init__(self, instance, components: dict = None): - self.components = components or { - "left": [" "], - "bottom": [StatusBar(instance)], - } - curses.endwin() - - @staticmethod - def get_component_width(component: list) -> int: - return len(max(component)) - - def render(self, instance): - for component in self.components["bottom"]: - component.render(instance) diff --git a/core/cursors.py b/core/cursors.py deleted file mode 100644 index c76079f..0000000 --- a/core/cursors.py +++ /dev/null @@ -1,68 +0,0 @@ -import curses - - -def mode(to_mode: str): - if to_mode == "block": - print("\033[2 q") - - elif to_mode == "line": - print("\033[6 q") - - elif to_mode == "hidden": - curses.curs_set(0) - - elif to_mode == "visible": - curses.curs_set(1) - - -def push(instance, direction: (int, str)): - if direction in (0, "up", "north"): - # If the cursor isn't at the top of the screen - if instance.raw_cursor[0] > 0: - # Move the cursor up - instance.raw_cursor[0] -= 1 - - # Move the buffer upwards if the cursor is at the top of the screen and not at the top of the buffer - if instance.raw_cursor[0] == 0 and instance.cursor[0] == instance.offset[0] and instance.cursor[0] != 0: - instance.offset[0] -= 1 - - elif direction in (2, "down", "south"): - if instance.raw_cursor[0] == instance.safe_height and instance.cursor[0] != len(instance.buffer.data) - 1: - instance.offset[0] += 1 - - # If the cursor isn't at the bottom of the screen - elif instance.raw_cursor[0] != instance.safe_height and instance.cursor[0] != len(instance.buffer.data) - 1: - # Move the cursor down - instance.raw_cursor[0] += 1 - - elif direction in (1, "right", "east"): - # Move the cursor one to the right - instance.raw_cursor[1] += 1 - - elif direction in (3, "left", "west"): - # Move the cursor one to the left - instance.raw_cursor[1] -= 1 - - -def check(instance, cursor: list) -> list: - # Prevent the cursor from going outside the buffer - cursor[1] = min(len(instance.buffer.data[instance.cursor[0]]) - 1, cursor[1]) - - # Prevent any negative values - cursor[0] = max(0, cursor[0]) - cursor[1] = max(0, cursor[1]) - - # Prevent the cursor from going outside the screen - cursor[1] = min(instance.safe_width, cursor[1]) - cursor[0] = min(instance.safe_height, cursor[0]) - - return cursor - - -def move(instance): - # Run a final check to see if the cursor is valid - instance.raw_cursor = check(instance, instance.raw_cursor) - - # Moves the cursor to anywhere on the screen - instance.screen.move(instance.raw_cursor[0], instance.raw_cursor[1] + - instance.components.get_component_width(instance.components.components["left"])) diff --git a/core/modes.py b/core/modes.py deleted file mode 100644 index 04ae88c..0000000 --- a/core/modes.py +++ /dev/null @@ -1,34 +0,0 @@ -from mode import normal, insert, command - - -def activate(instance, mode): - # Visibly update the mode - instance.mode = mode - - # Refresh the screen - instance.refresh() - - if mode == "command": - # Activate command mode - instance.components.components["bottom"][0].colors[1] = 5 - command.activate(instance) - - elif mode == "insert": - # Activate insert mode - instance.components.components["bottom"][0].colors[1] = 11 - insert.activate() - - elif mode == "normal": - # Activate normal mode - instance.components.components["bottom"][0].colors[1] = 5 - normal.activate() - - -def handle_key(instance, key): - # Normal mode - default keybindings - if instance.mode == "normal": - normal.execute(instance, key) - - # Insert mode - inserting text to the buffer - elif instance.mode == "insert": - insert.execute(instance, key) diff --git a/core/utils.py b/core/utils.py deleted file mode 100644 index 61ba8ef..0000000 --- a/core/utils.py +++ /dev/null @@ -1,209 +0,0 @@ -import curses -import json -import os -import sys -import traceback -from pathlib import Path - -from core import cursors -from core.colors import Codes as Col - - -def gracefully_exit(): - # Close the curses window - curses.endwin() - - # Finally, exit the program - sys.exit() - - -def clear_line(instance, y: int, x: int): - # Clear the line at the screen at position y, x - instance.screen.insstr(y, x, " " * (instance.width - x)) - - -def pause_screen(message: str): - # End the curses session - curses.endwin() - - # Print the message and wait for enter key - input(f"{message}\n\n Press enter to continue...") - - -def load_file(file_path: str) -> dict: - # load the json file with read permissions - with open(file_path, "r") as f: - return json.load(f) - - -def save_file(instance, file_path: str, data: list): - # Save the data to the file - with open(file_path, "w") as f: - try: - # For each line in the file - for index, line in enumerate(data): - if index == len(data) - 1: - # If this is the last line, write it without a newline - f.write(line) - - else: - # Otherwise, write the line with a newline - f.write(f"{line}\n") - - except (OSError, IOError): - # If the file could not be written, show an error message - error(instance, f"File {file_path} could not be saved.") - - -def load_config_file() -> dict: - # Parse the path of the config file - config_file_path = f"{Path.home()}/.config/lambda/config.json" - - # Only if the config file exists, attempt to load it - if os.path.exists(config_file_path): - # Return the loaded config - return load_file(config_file_path) - - -def welcome(instance): - # 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((instance.safe_width // 2) - (len(title) // 2) - len(title) % 2 + 2) - start_y = int((instance.safe_height // 2) - 1) - - # Rendering title - instance.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((instance.safe_width // 2) - (len(text) // 2) - len(text) % 2 + 2) - instance.screen.addstr(start_y, start_x, text) - - -def prompt(instance, message: str, color: int = 1) -> (list, None): - # Initialise the input list - inp = [] - - # Write whitespace over characters to refresh it - clear_line(instance, instance.height - 1, len(message) + len(inp) - 1) - - # 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_line(instance, instance.height - 1, len(message) + len(inp) - 1) - - 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 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!/_-0123456789 " - if chr(key) in valid and len(inp) < (instance.width - 2): - inp.append(chr(key)) - - # Refresh the screen - instance.refresh() - - # 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 press_key_to_continue(instance, message: str, color: int = 1): - # Hide the cursor - cursors.mode("hidden") - - # Clear the bottom of the screen - clear_line(instance, instance.height - 1, 0) - - # Write the entire message to the screen - instance.screen.addstr(instance.height - 1, 0, message, curses.color_pair(color)) - instance.screen.addstr(instance.height - 1, len(message) + 1, f"(press any key)") - - # Wait for a keypress - instance.screen.getch() - - # Clear the bottom of the screen - clear_line(instance, instance.height - 1, 0) - - # Show the cursor - cursors.mode("visible") - - -def error(instance, message: str): - # Parse the error message - error_message = f"ERROR: {message}" - - # Create a prompt - press_key_to_continue(instance, error_message, 3) - - -def fatal_error(exception: Exception): - # End the curses session - curses.endwin() - - # Print the error message and traceback - print(f"{Col.red}FATAL ERROR:{Col.end} " - f"{Col.yellow}{exception}{Col.end}\n") - print(traceback.format_exc()) - - # Exit, with an error exit code - sys.exit(0) - - -def goodbye(instance): - try: - # Confirm before exiting - choice = prompt(instance, "Really quit lambda? (y/n): ", 11) - - # If the user confirms, exit - if choice and choice[0] == "y": - gracefully_exit() - - # Clear the prompt if the user cancels - else: - clear_line(instance, instance.height - 1, 0) - - except KeyboardInterrupt: - # If the user presses Ctrl+C, just exit - gracefully_exit() - - except Exception as exception: - # If there is an error, print the error message and traceback - fatal_error(exception) diff --git a/install.sh b/install.sh deleted file mode 100755 index 0354a6e..0000000 --- a/install.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -# Copy lambda to ~/.local/share -mkdir -p ~/.local/share/lambda -cp -rf . ~/.local/share/lambda - -# Copy lambda launcher -chmod +x ./lambda -rm -rf ~/.local/bin/lambda -ln -s ~/.local/share/lambda/lambda ~/.local/bin/lambda -chmod +x ~/.local/bin/lambda \ No newline at end of file diff --git a/lambda b/lambda deleted file mode 100755 index 7c1d1e5..0000000 --- a/lambda +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -python3 ~/.local/share/lambda/main.py "$@" diff --git a/main.py b/main.py deleted file mode 100644 index 247bc62..0000000 --- a/main.py +++ /dev/null @@ -1,126 +0,0 @@ -import argparse -import curses -import os - -from core import colors, cursors, buffers, modes, utils -from core.buffers import Buffer -from core.components import Components - - -class Lambda: - def __init__(self, buffer: Buffer = None, config: dict = None): - self.cursor = [0, 0] - self.raw_cursor = [0, 0] - self.offset = [0, 0] - self.height = 0 - self.width = 0 - self.safe_height = 0 - self.safe_width = 0 - self.mode = "normal" - self.config = config or {"icon": "λ"} - self.buffer = buffer or [""] - self.screen = curses.initscr() - self.components = Components(self) - - def update_dimensions(self): - # Calculate the entire height and width of the terminal - self.height, self.width = self.screen.getmaxyx() - - # Calculate the safe area for the buffer by removing heights & widths of components - self.safe_height = self.height - len(self.components.components["bottom"]) - 2 - self.safe_width = self.width - self.components.get_component_width(self.components.components["left"]) - 1 - - def refresh(self): - # Calculate the cursor position in the file - self.cursor[0], self.cursor[1] = self.raw_cursor[0] + self.offset[0], self.raw_cursor[1] + self.offset[1] - - # Update the dimensions of the terminal - self.update_dimensions() - - # Write the buffer to the screen - self.buffer.render(self) - - # Refresh the on-screen components - self.components.render(self) - - # Move the cursor - cursors.move(self) - - def start(self): - # Change the escape key delay to 25ms - # Fixes an issue where the "esc" key takes way too long to press - os.environ.setdefault("ESCDELAY", "25") - - # Initialise colors - colors.init_colors() - - # Change the cursor shape - cursors.mode("block") - - # Don't echo any key-presses - curses.noecho() - - # Show a welcome message if lambda opens with no file - if not self.buffer.path: - # Update the screen variables - self.refresh() - - # Show the welcome message - utils.welcome(self) - - # Main loop - self.run() - - def run(self): - try: - # The main loop, which runs until the user quits - while True: - # Update the screen variables - self.refresh() - - # Wait for a keypress - key = self.screen.getch() - - # Handle the key - modes.handle_key(self, key) - - # Refresh and clear the screen - self.screen.erase() - - except KeyboardInterrupt: # Ctrl-C - # Create a goodbye prompt - utils.goodbye(self) - - # Run the main loop again - self.run() - - -def main(): - # Shell 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 at the shell - args = parser.parse_args() - - # Load the file into a Buffer object - buffer = buffers.load_file(args.file) - - # Load the config - config = utils.load_config_file() - - # Load lambda with the buffer object - instance = Lambda(buffer, config) - - # Start the screen, this will loop until exit - try: - instance.start() - - # Excepts *any* errors that occur - except Exception as exception: - utils.fatal_error(exception) - - -if __name__ == "__main__": - main() diff --git a/mode/command.py b/mode/command.py deleted file mode 100644 index 51d28e8..0000000 --- a/mode/command.py +++ /dev/null @@ -1,41 +0,0 @@ -from core import utils - - -def execute(instance, commands: list): - # Only if commands are given - if commands: - # Check each command in the list of commands - for command in commands: - if command == "d": # Debug - # Create the debug prompt - utils.press_key_to_continue(instance, f"Cursor: {instance.cursor} Raw: {instance.raw_cursor} " - f"Len: {len(instance.buffer.data)}") - - elif command == "t": # Toggle - # Toggle the status bar theme - if instance.components.components["bottom"][0].theme == "default": - instance.components.components["bottom"][0].theme = "inverted" - else: - instance.components.components["bottom"][0].theme = "default" - - elif command == "w": # Write - # Write to the file - utils.save_file(instance, instance.buffer.path, instance.buffer.data) - - elif command == "q": # Quit - # Create a goodbye prompt - utils.goodbye(instance) - - else: # Invalid command - utils.error(instance, f"invalid command: '{command}'") - - -def activate(instance): - # Create a prompt, which returns the input (commands) - commands = utils.prompt(instance, ":") - - # Execute the commands given - execute(instance, commands) - - # Return to normal mode once all commands are executed - instance.mode = "normal" diff --git a/mode/insert.py b/mode/insert.py deleted file mode 100644 index 2e7d9cc..0000000 --- a/mode/insert.py +++ /dev/null @@ -1,29 +0,0 @@ -import curses - -from core import cursors, modes - - -def execute(instance, key): - if key == 27: # Escape - # Switch to normal mode - modes.activate(instance, "normal") - - elif key in (curses.KEY_BACKSPACE, 127, '\b'): # Backspace - if instance.cursor[1] > 0: - # Delete the character before the cursor - instance.buffer.delete_char(instance) - - # Move the cursor one to the left - cursors.push(instance, 3) - - else: - # Insert the character - instance.buffer.insert_char(instance, chr(key)) - - # Move the cursor one to the right - cursors.push(instance, 1) - - -def activate(): - # Switch the cursor to a line - cursors.mode("line") diff --git a/mode/normal.py b/mode/normal.py deleted file mode 100644 index 4bc0d44..0000000 --- a/mode/normal.py +++ /dev/null @@ -1,45 +0,0 @@ -import curses - -from core import cursors, modes, utils - - -def execute(instance, key): - if key == curses.BUTTON1_CLICKED: - # Move the cursor to the position clicked - utils.prompt(instance, str(curses.getmouse())) - - elif key in (ord("j"), curses.KEY_DOWN): - # Move the cursor down - cursors.push(instance, "down") - - elif key in (ord("k"), curses.KEY_UP): - # Move the cursor up - cursors.push(instance, "up") - - elif key in (ord("l"), curses.KEY_RIGHT): - # Move the cursor right - cursors.push(instance, "right") - - elif key in (ord("h"), curses.KEY_LEFT): - # Move the cursor left - cursors.push(instance, "left") - - elif key == ord("i"): - # Activate insert mode - modes.activate(instance, "insert") - - elif key == ord("I"): - # Move the cursor to the right - cursors.push(instance, "right") - - # Then activate insert mode - modes.activate(instance, "insert") - - elif key in (ord(":"), ord(";")): - # Activate command mode - modes.activate(instance, "command") - - -def activate(): - # Switch the cursor to a block - cursors.mode("block") diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..ecb4a2c --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,60 @@ +pub struct Config<'a> { + pub logo: &'a str, + pub friendly_name: &'a str, +} + +impl<'a> Config<'a> { + pub fn new() -> Self { + Self { + logo: "λ", + friendly_name: "Lambda", + } + } +} + +pub struct Buffer<'a> { + pub data: Vec, + pub name: &'a str, + pub path: &'a str, +} + +#[allow(dead_code)] +pub enum Mode { + Normal, + Insert, + Select, + Command, +} + +impl Mode { + pub fn as_str(&self) -> &str { + match self { + Mode::Normal => "NORMAL", + Mode::Insert => "INSERT", + Mode::Select => "SELECT", + Mode::Command => "COMMAND", + } + } +} + +pub struct Editor<'a> { + pub config: Config<'a>, + pub buffer: Buffer<'a>, + pub cursors: Vec, + pub mode: Mode, +} + +impl<'a> Editor<'a> { + pub fn new() -> Self { + Editor { + config: Config::new(), + buffer: Buffer { + data: Vec::from([String::from("Hello"), String::from("World")]), + name: "[No Name]", + path: "/home/spy", + }, + cursors: Vec::from([0]), + mode: Mode::Normal, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6e25b09 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9 @@ +mod editor; +mod terminal; +mod tui; + +fn main() { + let lambda = editor::Editor::new(); + let mut screen = terminal::Screen::new().unwrap(); + tui::start(&mut screen, lambda); +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..2d32374 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,154 @@ +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::style::Print; +use crossterm::terminal; +use crossterm::{execute, ErrorKind}; +use std::io::stdout; + +// Struct for holding coordinates +#[derive(Copy, Clone)] +pub struct Coords { + pub x: usize, + pub y: usize, +} + +// Creating a coordinates from two values +impl Coords { + pub fn from(x: usize, y: usize) -> Self { + Self { x, y } + } +} + +// A cursor for writing to the terminal screen +#[derive(Copy, Clone)] +pub struct Cursor { + pub position: Coords, + pub hidden: bool, +} + +// When a cursor is created +impl Cursor { + pub fn new() -> Result { + let mut cursor = Self { + position: Coords::from(0, 0), + hidden: true, + }; + Cursor::move_to(&mut cursor, Coords::from(0, 0)); + Cursor::hide(&mut cursor); + Ok(cursor) + } +} + +// Cursor methods +impl Cursor { + pub fn move_to(&mut self, position: Coords) { + // Set the new position of the cursor + self.position = position; + + // Move the cursor to the desired posiition in the terminal + execute!(stdout(), MoveTo(position.x as u16, position.y as u16)).unwrap(); + } + + pub fn hide(&mut self) { + // Remember that the cursor is hidden + self.hidden = true; + + // Hide the cursor from the terminal screen + execute!(stdout(), Hide).unwrap(); + } + + pub fn show(&mut self) { + // Remember that the cursor isn't hidden + self.hidden = false; + + // Show the cursor to the terminal screen + execute!(stdout(), Show).unwrap(); + } +} + +// A struct for holding the size of the terminal +pub struct Size { + pub width: usize, + pub height: usize, +} + +// The terminal screen +pub struct Screen { + pub size: Size, + pub cursor: Cursor, +} + +// For when a new terminal screen is created +impl Screen { + pub fn new() -> Result { + // Get the size of the terminal + let size = terminal::size()?; + + // Define a new terminal screen struct + let mut screen = Self { + size: Size { + width: size.0 as usize, + height: size.1 as usize, + }, + cursor: Cursor::new().unwrap(), + }; + + // Empty the terminal screen + Screen::clear(); + + // Enter the terminal screen + screen.enter(); + + // Return a result containing the terminal + Ok(screen) + } +} + +// Terminal functions and methods for managing the terminal +impl Screen { + pub fn refresh(&mut self) -> Result<(), ErrorKind>{ + // Clear the screen + Screen::clear(); + + // Update the screen dimensions + let size = terminal::size()?; + self.size.width = size.0 as usize; + self.size.height = size.1 as usize; + + // Return Ok if was successful + Ok(()) + } + + pub fn enter(&mut self) { + // Hide the cursor + self.cursor.hide(); + + // Enter the terminal screen + terminal::enable_raw_mode().unwrap(); + execute!(stdout(), terminal::EnterAlternateScreen).unwrap(); + } + + pub fn exit(&mut self) { + // Show the cursor + self.cursor.show(); + + // Exit the terminal screen + execute!(stdout(), terminal::LeaveAlternateScreen).unwrap(); + terminal::disable_raw_mode().unwrap(); + } + + pub fn clear() { + // Clears the terminal screen + execute!(stdout(), terminal::Clear(terminal::ClearType::All)).unwrap(); + } + + pub fn write(text: &str) { + // Writes a line to a current cursor position + execute!(stdout(), Print(text)).unwrap(); + } + + pub fn write_at(&mut self, text: String, position: Coords) { + // Writes a line at a set of coordinates + self.cursor.move_to(position); + Screen::write(&text); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..c6a5343 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,131 @@ +use crate::editor::Editor; +use crate::terminal::{Coords, Screen}; +use crossterm::style::Stylize; +use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyModifiers}; + +// Utils +fn with_spaces(text: &str) -> String { + format!(" {} ", text) +} +fn calc_x(screen_width: usize, item_length: usize) -> usize { + (screen_width / 2) - (item_length / 2) +} +fn longest_element_in_array(elements: Vec<&str>) -> usize { + elements.iter().max_by_key(|x: &&&str| x.len()).unwrap().len() +} + +pub fn draw_status(screen: &mut Screen, editor: &Editor) -> Result<(), ()> { + // Calculate where to draw the status bar + let status_height = screen.size.height - 2; + + // Get the editor logo from the config + let editor_logo = &with_spaces(editor.config.logo) as &str; + // Get the current mode into a string + let mode_string = &with_spaces(editor.mode.as_str()) as &str; + // Get the current open file name + let file_name = &with_spaces(editor.buffer.name) as &str; + + // Calculate the total length of all the status bar components + let total_length = editor_logo.len() + mode_string.len() + file_name.len() + 1; + + // If the screen isn't wide enough, panic as we can't draw the status bar + if screen.size.width < total_length { + Err(()) + + } else { + // Write the editor logo + screen.write_at( + editor_logo.yellow().bold().reverse().to_string(), + Coords::from(0, status_height), + ); + + // Calculate where to write the current mode + let x = editor_logo.len() - 1; + // Write the current mode + screen.write_at( + mode_string.green().bold().reverse().to_string(), + Coords::from(x, status_height), + ); + // Calculate where to write the file name + let x = x + mode_string.len(); + // Write the current file name + screen.write_at( + file_name.magenta().bold().reverse().to_string(), + Coords::from(x, status_height), + ); + + // Draw the rest of the status bar + let x = x + file_name.len(); + screen.write_at( + " ".repeat(screen.size.width - x).reverse().to_string(), + Coords::from(x, status_height), + ); + + Ok(()) + } +} + +pub fn draw_welcome(screen: &mut Screen, editor: &Editor) { + // The welcome message + let title = format!("{} {}", editor.config.logo, editor.config.friendly_name); + let message: [&str; 5] = [ + "Hackable text editor for nerds", + "", + "Type :help to open the README.md document", + "Type :o to open a file and edit", + "Type :q! or to quit lambda", + ]; + + // If the screen is big enough, we can draw + if screen.size.width > longest_element_in_array(message.to_vec()) && screen.size.height > message.len() + 4 { + // The starting y position in the centre of the screen + let mut y = (screen.size.height / 2) - (message.len() / 2) - 2; + + // Calculate where to place the title + let x = calc_x(screen.size.width, title.len()); + + // Write the title to the screen + screen.write_at(title.yellow().to_string(), Coords::from(x, y)); + + for line in message { + // Each line has different width so requires a different x position to center it + let x = calc_x(screen.size.width, line.len()) ; + + // For each line we move downwards so increment y + y += 1; + + // Write the line to the screen at position (x, y) + screen.write_at(line.to_string(), Coords::from(x, y)); + } + } +} + +pub fn start(screen: &mut Screen, editor: Editor) { + loop { + // Refresh the screen + screen.refresh().unwrap(); + + // Draw the welcome message + draw_welcome(screen, &editor); + + // Draw the status bar + draw_status(screen, &editor).unwrap(); + + // Check for any key presses + match read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::CONTROL, + .. + }) => break, + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + .. + }) => break, + _ => (), + } + } + + screen.exit(); +}