├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .todo.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── logo.png ├── logo.svg └── showcase.gif ├── build.sh ├── config ├── .oxrc └── minimal.lua ├── kaolinite ├── .further.todo.md ├── .gitignore ├── .todo.md ├── Cargo.toml ├── src │ ├── document │ │ ├── cursor.rs │ │ ├── disk.rs │ │ ├── editing.rs │ │ ├── lines.rs │ │ ├── mod.rs │ │ └── words.rs │ ├── event.rs │ ├── lib.rs │ ├── map.rs │ ├── searching.rs │ └── utils.rs └── tests │ ├── data │ ├── big.txt │ ├── empty.txt │ ├── no_eol.txt │ ├── saving.txt │ └── unicode.txt │ └── test.rs ├── plugins ├── ai.lua ├── autoindent.lua ├── discord_rpc.lua ├── emmet.lua ├── git.lua ├── live_html.lua ├── pairs.lua ├── pomodoro.lua ├── quickcomment.lua ├── themes │ ├── default16.lua │ ├── galaxy.lua │ ├── omni.lua │ ├── transparent.lua │ └── tropical.lua ├── todo.lua ├── typing_speed.lua └── update_notification.lua ├── src ├── cli.rs ├── config │ ├── assistant.rs │ ├── colors.rs │ ├── editor.rs │ ├── filetree.rs │ ├── highlighting.rs │ ├── interface.rs │ ├── keys.rs │ ├── mod.rs │ ├── runner.rs │ └── tasks.rs ├── editor │ ├── cursor.rs │ ├── documents.rs │ ├── editing.rs │ ├── filetree.rs │ ├── filetypes.rs │ ├── interface.rs │ ├── macros.rs │ ├── mod.rs │ ├── mouse.rs │ └── scanning.rs ├── error.rs ├── events.rs ├── main.rs ├── plugin │ ├── bootstrap.lua │ ├── networking.lua │ ├── plugin_manager.lua │ └── run.lua ├── pty.rs └── ui.rs ├── test.sh └── update.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | *.oxrc linguist-language=Lua 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ox_editor 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the bug?** 11 | 12 | 13 | 14 | **What did you do to get the bug?** 15 | 16 | 17 | 18 | **What behaviour were you expecting?** 19 | 20 | 21 | 22 | **Screenshots (if applicable)** 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What feature would you like to see? (give details)** 11 | 12 | 13 | 14 | **Are there any alternatives you have considered?** 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | push: 4 | branches: 5 | - master 6 | 7 | name: Build and test 8 | 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | override: true 20 | - run: RUSTFLAGS="-D warnings" cargo check --workspace --examples 21 | 22 | fmt: 23 | name: Rustfmt 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions-rs/toolchain@v1 28 | with: 29 | profile: minimal 30 | toolchain: stable 31 | override: true 32 | - run: rustup component add rustfmt 33 | - run: cargo fmt --all -- --check 34 | 35 | test: 36 | name: Test 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions-rs/toolchain@v1 41 | with: 42 | profile: minimal 43 | toolchain: stable 44 | override: true 45 | - run: cargo test --all 46 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | homebrew: 9 | name: Bump Homebrew formula 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: mislav/bump-homebrew-formula-action@v1.7 13 | if: "!contains(github.ref, '-')" # skip prereleases 14 | with: 15 | formula-name: ox 16 | env: 17 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.swp 3 | *.swo 4 | *.swn 5 | /.vscode 6 | *.html 7 | -------------------------------------------------------------------------------- /.todo.md: -------------------------------------------------------------------------------- 1 | - [ ] Supporting infrastructure 2 | - [ ] Syntax highlighting assistant 3 | - [ ] File tree* 4 | - [ ] Implement file tree 5 | - [ ] Code prettifier 6 | - [ ] Implement code prettification infrastructure 7 | - [ ] AI & Autocomplete* 8 | - [ ] Implement code autocomplete infrastructure 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "kaolinite", 5 | ] 6 | 7 | [package] 8 | name = "ox" 9 | version = "0.7.7" 10 | edition = "2021" 11 | authors = ["Curlpipe <11898833+curlpipe@users.noreply.github.com>"] 12 | description = "A simple but flexible text editor." 13 | homepage = "https://github.com/curlpipe/ox" 14 | repository = "https://github.com/curlpipe/ox" 15 | readme = "README.md" 16 | include = ["src/*.rs", "Cargo.toml", "config/.oxrc"] 17 | categories = ["text-editors"] 18 | keywords = ["text-editor", "editor", "terminal", "tui"] 19 | license = "GPL-2.0" 20 | 21 | [package.metadata.generate-rpm] 22 | assets = [ 23 | { source = "target/release/ox", dest = "/usr/bin/ox", mode = "0755" }, 24 | { source = "LICENSE", dest = "/usr/share/doc/ox/LICENSE", doc = true, mode = "0644" }, 25 | { source = "README.md", dest = "/usr/share/doc/ox/README.md", doc = true, mode = "0644" } 26 | ] 27 | 28 | #[profile.release] 29 | #debug = true 30 | #lto = true 31 | #panic = "abort" 32 | #codegen-units = 1 33 | 34 | [dependencies] 35 | alinio = "0.2.1" 36 | base64 = "0.22.1" 37 | crossterm = "0.28.1" 38 | jargon-args = "0.2.7" 39 | kaolinite = { path = "./kaolinite" } 40 | mlua = { version = "0.10", features = ["lua54", "vendored"] } 41 | error_set = "0.7" 42 | shellexpand = "3.1.0" 43 | synoptic = "2.2.9" 44 | regex = "1.11.1" 45 | 46 | # Non-windows dependencies (for terminal) 47 | [target.'cfg(not(target_os = "windows"))'.dependencies] 48 | ptyprocess = "0.4.1" 49 | mio = { version = "1.0.3", features = ["os-ext"] } 50 | nix = { version = "0.29.0", features = ["fs"] } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | 5 | Logo 6 | 7 | 8 |

Ox editor

9 | 10 |

11 | The simple but flexible text editor 12 |

13 |

14 | 15 |
16 |
17 |

18 | 19 | 20 | ![Build Status](https://img.shields.io/github/forks/curlpipe/ox.svg?style=for-the-badge) 21 | ![Build Status](https://img.shields.io/github/stars/curlpipe/ox.svg?style=for-the-badge) 22 | ![License](https://img.shields.io/github/license/curlpipe/ox.svg?style=for-the-badge) 23 | 24 | [About](#about) - [Installation](#installation) - [Quick Start Guide](#quick-start-guide) 25 | 26 | ## About 27 | 28 | Ox is a text editor that can be used to write everything from text to code. 29 | 30 | If you're looking for a text editor that... 31 | 1. :feather: Is lightweight and efficient 32 | 2. :wrench: Can be configured to your heart's content 33 | 3. :package: Has useful features out of the box and a library of plug-ins for everything else 34 | 35 | ...then Ox is right up your street 36 | 37 | It runs in your terminal as a text-user-interface, just like vim, nano and micro, however, it is not based on any existing editors and has been built from the ground up. 38 | 39 | It works best on linux, but macOS and Windows are also supported. 40 | 41 | ## Selling Points 42 | 43 | ### Strong configurability 44 | 45 | - :electric_plug: Plug-In system where you can write your own plug-ins or choose from pre-existing ones 46 | - 💬 Discord RPC 47 | - 📗 Git integration 48 | - 🕸️ Emmet and HTML viewer 49 | - ⏲️ Pomodoro timer and todo list tracker 50 | - 🤖 AI code & advice 51 | - :wrench: Configure everything including colours, key bindings and behaviours 52 | - :moon: Write Lua code for configuration 53 | - :handshake: A set-up wizard to make Ox yours from the start 54 | 55 | ### Out of the box features 56 | 57 | - :paintbrush: Syntax highlighting 58 | - :arrow_right_hook: Undo and redo 59 | - :mag: Search and replace text 60 | - :file_folder: Opening multiple files at once 61 | - :eye: UI that shows you the state of the editor and file 62 | - :computer_mouse: You can move the cursor and select text with your mouse 63 | - :writing_hand: Convenient shortcuts when writing code 64 | - :crossed_swords: Multi-editing features such as multiple cursors and recordable macros 65 | - :window: Splits to view multiple documents on the same screen at the same time 66 | - :file_cabinet: File tree to view, open, create, delete, copy and move files 67 | - :keyboard: Access to terminals within the editor 68 | 69 | ### Detailed Documentation 70 | 71 | Become a power user and take advantage of everything on offer. 72 | 73 | Found on the [wiki page](https://github.com/curlpipe/ox/wiki/) 74 | 75 | This will take you step-by-step in great detail through 6 different stages: 76 | 77 | 1. **Installation** - advice and how-tos on installation 78 | 2. **Configuring** - changing the layout, adding to and changing the syntax highlighting 79 | 3. **General Editing** - editing a document and controlling the editor 80 | 4. **Command Line** - using the command line interface 81 | 5. **Plugins** - installing or uninstalling community plug-ins and writing or distributing your own plug-ins 82 | 6. **Roadmap** - planned features 83 | 84 | ## Installation 85 | 86 | To get started, please click on your operating system 87 | 88 | - :penguin: [Linux](#linux) 89 | - :window: [Windows](#windows) 90 | - :apple: [MacOS](#macos) 91 | 92 | ### Linux 93 | 94 | Here are the list of available methods for installing on Linux: 95 | - [Manually](#manual) 96 | - [Binary](#binaries) 97 | - [Arch Linux](#arch-linux) 98 | - [Fedora](#fedora) 99 | - [Debian / Ubuntu](#debian) 100 | 101 | #### Arch Linux 102 | 103 | Install one of the following from the AUR: 104 | - `ox-bin` - install the pre-compiled binary (fastest) 105 | - `ox-git` - compile from source (best) 106 | 107 | #### Fedora 108 | 109 | You can find an RPM in the [releases page](https://github.com/curlpipe/ox/releases) 110 | 111 | Install using the following command: 112 | 113 | ```sh 114 | sudo dnf install /path/to/rpm/file 115 | ``` 116 | 117 | #### Debian 118 | 119 | You can find a deb file in the [releases page](https://github.com/curlpipe/ox/releases) 120 | 121 | Install using the following command: 122 | 123 | ```sh 124 | sudo dpkg -i /path/to/deb/file 125 | ``` 126 | 127 | ### Windows 128 | 129 | Here are the list of available methods for installing on Windows: 130 | - [Manually (best)](#manual) 131 | - [Binary](#binaries) 132 | 133 | 134 | 135 | ### MacOS 136 | 137 | Here are the list of available methods for installing on macOS: 138 | - [Manually](#manual) 139 | - [Binary](#binaries) 140 | - [Homebrew](#homebrew) 141 | - [MacPorts](#macports) 142 | 143 | #### Homebrew 144 | 145 | Install `ox` from Homebrew core tap. 146 | 147 | ```sh 148 | brew install ox 149 | ``` 150 | 151 | #### MacPorts 152 | 153 | On macOS, you can install `ox` via [MacPorts](https://www.macports.org) 154 | 155 | ```sh 156 | sudo port selfupdate 157 | sudo port install ox 158 | ``` 159 | 160 | ### Binaries 161 | 162 | There are precompiled binaries available for all platforms in the [releases page](https://github.com/curlpipe/ox/releases). 163 | 164 | - For Linux: download the `ox` executable and copy it to `/usr/bin/ox`, then run `sudo chmod +x /usr/bin/ox` 165 | - For MacOS: download the `ox-macos` executable and copy it to `/usr/local/bin/ox`, then run `sudo chmod +x /usr/local/bin/ox` 166 | - For Windows: download the `ox.exe` executable and copy it into a location in `PATH` see [this guide](https://zwbetz.com/how-to-add-a-binary-to-your-path-on-macos-linux-windows/#windows-cli) for how to do it 167 | 168 | ### Manual 169 | 170 | This is the absolute best way to install Ox, it will ensure you always have the latest version and everything works for your system. 171 | 172 | You must have a working installation of the Rust compiler to use this method. Visit the website for [rustup](https://rustup.rs/) and follow the instructions there for your operating system. 173 | 174 | Now with a working version of rust, you can run the command: 175 | 176 | ```sh 177 | cargo install --git https://github.com/curlpipe/ox 178 | ``` 179 | 180 | This will take at worst around 2 minutes. On some more modern systems, it will take around 30 seconds. 181 | 182 | Please note that you should add `.cargo/bin` to your path, which is where the `ox` executable will live, although `rustup` will likely do that for you, so no need to worry too much. 183 | 184 | ## Quick Start Guide 185 | 186 | Once you have installed Ox, it's time to get started. 187 | 188 | ### Set-Up 189 | 190 | You can open Ox using the command 191 | 192 | ```sh 193 | ox 194 | ``` 195 | 196 | At first, if you don't have a configuration file in place, Ox will walk you through a set-up wizard. 197 | 198 | When you've completed it, you should be greeted by ox itself, with an empty, unnamed document. 199 | 200 | At the top is your tab line, this shows you files that are open. 201 | 202 | At the bottom is your status line, this shows you the state of the editor. 203 | 204 | At the far bottom is your feedback line, you'll see information, warnings and errors appear there. 205 | 206 | ### Editing 207 | 208 | Toggle the built-in help message using Ctrl + H. You can press Ctrl + H again to hide this message if it gets in the way. This should introduce you to most of the key bindings on offer. 209 | 210 | Ox isn't a modal text editor, so you can begin typing straight away. Give it a go! Type in letters and numbers, delete with backspace, indent with tab, break up lines with the enter key. 211 | 212 | Move your cursor by clicking, or using the arrow keys. You can also click and drag to select text. 213 | 214 | If you modify a file, you may notice a `[+]` symbol, this means the file open in the editor differs from it's state on the disk. Save the file to update it on the disk and this indicator will disappear. 215 | 216 | Because the file we're editing is new and doesn't have a name, you'll need to save as using Alt + S and give it a name. 217 | 218 | Now, if you were to edit it again, because it is on the disk and has a name, you can use the standard Ctrl + S to save it. 219 | 220 | You can open files through Ctrl + O - try opening a file! 221 | 222 | If you modify it you can then use the standard Ctrl + S to update it on the disk, as this file already exists. 223 | 224 | When mutltiple files are open, you can navigate back and forth using Alt + Left and Alt + Right 225 | 226 | Once you're done with a file, you can use Ctrl + Q to quit out of it. 227 | 228 | If all files are closed, Ox will exit. 229 | 230 | If you're interested in finding out all the key bindings on offer, click [here](https://github.com/curlpipe/ox/wiki/General-editing#quick-reference) 231 | 232 | Now you've exited Ox, let's check out some command line options. 233 | 234 | ### CLI 235 | 236 | You can open files straight from the command line like this: 237 | 238 | ```sh 239 | ox /path/to/file1 /path/to/file2 240 | ``` 241 | 242 | If you try to open a file that doesn't actually exist, Ox will open it in memory, and as soon as you save, it will save it will create it for you. 243 | 244 | See more information regarding command line options using the command. 245 | 246 | ```sh 247 | ox --help 248 | ``` 249 | 250 | This provides everything you need to know to do some basic editing, but there is so much more you can take advantage of, from plug-ins to opening multiple files on the same screen, to using the built-in terminal and using the file tree to manage your project. 251 | 252 | If you are curious in learning more, click [here](https://github.com/curlpipe/ox/wiki) to access the wiki where you will be introduced to all the wide range of features and really make your experience smooth like butter 🧈. 253 | 254 | ## License 255 | 256 | Distributed under the GNU GPLv2 License. See `LICENSE` for more information. 257 | 258 | ## Contact 259 | 260 | You can contact me on Discord at my handle `curlpipe`. I'll be happy to answer any questions you may have! 261 | 262 | ## Acknowledgements 263 | 264 | - [Luke (curlpipe)](https://github.com/curlpipe), principal developer 265 | - [HKalbasi](https://github.com/HKalbasi), key contributor 266 | - [Spike (spikecodes)](https://github.com/spikecodes), for the logo 267 | - The community, for the stars, ideas, suggestions and bug reports 268 | 269 | The creators of the following technologies: 270 | 271 | * [Rust language](https://rust-lang.org) 272 | * [Kaolinite](https://github.com/curlpipe/kaolinite) 273 | * [Synoptic](https://github.com/curlpipe/synoptic) 274 | * [Crossterm](https://github.com/crossterm-rs/crossterm) 275 | * [Mlua](https://github.com/mlua-rs/mlua) 276 | * [Jargon-args](https://crates.io/crates/jargon-args) 277 | * [Regex](https://docs.rs/regex/1.3.9/regex/) 278 | * [Unicode-rs](https://unicode-rs.github.io/) 279 | * [Quick-error](https://github.com/tailhook/quick-error) 280 | * [Shellexpand](https://github.com/netvl/shellexpand) 281 | 282 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curlpipe/ox/6cb11e7dbb0817d3590e4db8098b4b42c366b278/assets/logo.png -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curlpipe/ox/6cb11e7dbb0817d3590e4db8098b4b42c366b278/assets/showcase.gif -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | # Initial set-up 2 | mkdir -p target/pkgs 3 | rm target/pkgs/* 4 | 5 | # Build for Linux 6 | ## Binary 7 | cargo build --release 8 | strip -s target/release/ox 9 | cp target/release/ox target/pkgs/ox 10 | ## RPM 11 | rm target/generate-rpm/*.rpm 12 | cargo generate-rpm 13 | cp target/generate-rpm/*.rpm target/pkgs/ 14 | ## DEB 15 | cargo deb 16 | cp target/debian/*.deb target/pkgs/ 17 | 18 | # Build for macOS (binary) 19 | export SDKROOT=/home/luke/dev/make/MacOSX13.3.sdk/ 20 | export PATH=$PATH:~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin/ 21 | export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=rust-lld 22 | cargo zigbuild --release --target x86_64-apple-darwin 23 | cp target/x86_64-apple-darwin/release/ox target/pkgs/ox-macos 24 | 25 | # Build for Windows (binary) 26 | cargo build --release --target x86_64-pc-windows-gnu 27 | strip -s target/x86_64-pc-windows-gnu/release/ox.exe 28 | cp target/x86_64-pc-windows-gnu/release/ox.exe target/pkgs/ox.exe 29 | 30 | # Clean up 31 | rm .intentionally-empty-file.o 32 | -------------------------------------------------------------------------------- /config/minimal.lua: -------------------------------------------------------------------------------- 1 | -- This is a more minimal example to the full ox config 2 | -- This just adds a few tweaks to specific areas while demonstrating the power of ox configuration 3 | 4 | -- Disable cursor wrapping (which stops a cursor moving to the next line when it reaches the end a line) -- 5 | document.wrap_cursor = false 6 | 7 | -- Colour both the status text colour and highlight colour as the colour pink -- 8 | colors.highlight = {150, 70, 200} 9 | colors.status_fg = colors.highlight 10 | 11 | -- Super minimal status line -- 12 | status_line:add_part(" {file_name}{modified} │") -- The left side of the status line 13 | status_line:add_part("│ {cursor_y} / {line_count} ") -- The right side of the status line 14 | 15 | -- Enable bracket / quote pairs and autoindentation for a slick code editing experience! 16 | load_plugin("pairs.lua") 17 | load_plugin("autoindent.lua") 18 | -------------------------------------------------------------------------------- /kaolinite/.further.todo.md: -------------------------------------------------------------------------------- 1 | - [ ] Further ideas 2 | - [ ] Change goto behaviour (go to end of line if out of bounds in X direction) 3 | - [ ] New scroll up/down feature to keep cursor in the middle of the viewport 4 | - [ ] Live reload of config file 5 | - [ ] Configuration assistant to create config if missing 6 | - [ ] Mouse selection support 7 | - [ ] Ox tutor? 8 | - [ ] 16 bit color fallback 9 | - [ ] Package manager 10 | - [ ] Easter eggs 11 | - [ ] More sophisticated command line 12 | - [X] Read only files 13 | - [X] File overwrite prevention 14 | - [X] Lua engine 15 | - [X] warning, info and error line 16 | - [X] Delete word command 17 | - [X] Custom status line 18 | - [X] Help menu to view keybindings 19 | - [ ] Plug ins 20 | - [ ] Save as root? 21 | - [ ] Wayland clipboard support 22 | - [ ] Bracket & Quote pairs 23 | - [ ] Document backup 24 | - [ ] Auto indentation 25 | - [ ] Code prettifier 26 | - [ ] Code linter 27 | - [ ] Auto complete 28 | - [ ] File tree 29 | - [ ] Start page 30 | - [ ] Save and load sessions 31 | - [ ] Discord rich presence 32 | - [ ] Todo lists 33 | - [ ] Live html editor 34 | - [ ] Easy HTML tag pairs 35 | - [ ] Tiling documents on one tab 36 | - [ ] Terminal split 37 | - [ ] Theme builder 38 | - [ ] Cheatsheet downloader 39 | - [ ] Stack overflow searcher 40 | - [ ] Documentation viewer 41 | - [ ] Pomodoro timer 42 | - [ ] Typing speed tester & measurer 43 | - [ ] Integrated unit tests 44 | - [ ] Theme changing depending on time of day 45 | -------------------------------------------------------------------------------- /kaolinite/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | /examples/cactus/target/* 4 | /examples/cactus/Cargo.lock 5 | **.swp 6 | tarpaulin-report.html 7 | -------------------------------------------------------------------------------- /kaolinite/.todo.md: -------------------------------------------------------------------------------- 1 | Code Viewer 2 | - [X] Handle offset 3 | - [X] Handle out of bounds 4 | - [X] Line numbers 5 | - [X] Proper status bar 6 | - [X] Correct error handling 7 | - [X] Cursor wrapping 8 | Kibi rival 9 | - [X] Allow insertion 10 | - [X] Allow deletion 11 | - [X] Allow insertion of lines 12 | - [X] Allow deletion of lines 13 | - [X] Change deletion to use ranges instead 14 | - [X] Change insertion to use strings instead 15 | - [X] Allow splitting down 16 | - [X] Allow splicing up 17 | - [X] Allow inserting on empty line 18 | - [X] Boundary checking & Correct error handling (remove panic and unwrap) 19 | - [X] File type detection 20 | - [X] Allow saving 21 | - [X] Clean up code 22 | - [X] Test suite 23 | Code Editor 24 | - [X] Syntax highlighting (2 weeks) (+20) 25 | - [X] Build Ox 0.3 26 | - [X] Undo & Redo 27 | - [X] Command line interface 28 | - [X] Multiple buffers 29 | - [X] Word jumping 30 | - [X] Advanced movement 31 | - [X] Polishing 32 | - [X] Searching & Replacing 33 | - [X] Publishing 34 | -------------------------------------------------------------------------------- /kaolinite/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kaolinite" 3 | version = "0.10.0" 4 | authors = ["curlpipe <11898833+curlpipe@users.noreply.github.com>"] 5 | edition = "2021" 6 | license = "MIT" 7 | description = "A crate to assist in the creation of TUI text editors." 8 | repository = "https://github.com/curlpipe/kaolinite" 9 | exclude = ["/demos/7.txt"] 10 | readme = "README.md" 11 | keywords = ["unicode", "text-processing"] 12 | categories = ["text-processing"] 13 | 14 | [dependencies] 15 | error_set = "0.7" 16 | regex = "1" 17 | ropey = "1.6.1" 18 | unicode-width = "0.2" 19 | 20 | [dev-dependencies] 21 | rand = "0.8.5" 22 | sugars = "3.0.1" 23 | 24 | [lints.rust] 25 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } 26 | -------------------------------------------------------------------------------- /kaolinite/src/document/disk.rs: -------------------------------------------------------------------------------- 1 | use crate::document::Cursor; 2 | use crate::event::{Error, EventMgmt, Result}; 3 | use crate::map::{form_map, CharMap}; 4 | use crate::utils::get_absolute_path; 5 | use crate::{Document, Loc, Size}; 6 | use ropey::Rope; 7 | use std::fs::File; 8 | use std::io::{BufRead, BufReader, BufWriter, Read}; 9 | 10 | /// A document info struct to store information about the file it represents 11 | #[derive(Clone, PartialEq, Eq, Debug)] 12 | pub struct DocumentInfo { 13 | /// Whether or not the document can be edited 14 | pub read_only: bool, 15 | /// Flag for an EOL 16 | pub eol: bool, 17 | /// Contains the number of lines buffered into the document 18 | pub loaded_to: usize, 19 | } 20 | 21 | impl Document { 22 | /// Creates a new, empty document with no file name. 23 | #[cfg(not(tarpaulin_include))] 24 | #[must_use] 25 | pub fn new(size: Size) -> Self { 26 | Self { 27 | file: Rope::from_str("\n"), 28 | lines: vec![String::new()], 29 | dbl_map: CharMap::default(), 30 | tab_map: CharMap::default(), 31 | file_name: None, 32 | cursor: Cursor::default(), 33 | offset: Loc::default(), 34 | size, 35 | char_ptr: 0, 36 | event_mgmt: EventMgmt::default(), 37 | tab_width: 4, 38 | old_cursor: 0, 39 | in_redo: false, 40 | info: DocumentInfo { 41 | loaded_to: 1, 42 | eol: false, 43 | read_only: false, 44 | }, 45 | secondary_cursors: vec![], 46 | } 47 | } 48 | 49 | /// Open a document from a file name. 50 | /// # Errors 51 | /// Returns an error when file doesn't exist, or has incorrect permissions. 52 | /// Also returns an error if the rope fails to initialise due to character set issues or 53 | /// disk errors. 54 | #[cfg(not(tarpaulin_include))] 55 | pub fn open>(size: Size, file_name: S) -> Result { 56 | // Try to find the absolute path and load it into the reader 57 | let file_name = file_name.into(); 58 | let full_path = std::fs::canonicalize(&file_name)?; 59 | let file = load_rope_from_reader(BufReader::new(File::open(&full_path)?)); 60 | // Find the string representation of the absolute path 61 | let file_name = get_absolute_path(&file_name); 62 | Ok(Self { 63 | info: DocumentInfo { 64 | loaded_to: 0, 65 | eol: !file 66 | .line(file.len_lines().saturating_sub(1)) 67 | .to_string() 68 | .is_empty(), 69 | read_only: false, 70 | }, 71 | file, 72 | lines: vec![], 73 | dbl_map: CharMap::default(), 74 | tab_map: CharMap::default(), 75 | file_name, 76 | cursor: Cursor::default(), 77 | offset: Loc::default(), 78 | size, 79 | char_ptr: 0, 80 | event_mgmt: EventMgmt::default(), 81 | tab_width: 4, 82 | old_cursor: 0, 83 | in_redo: false, 84 | secondary_cursors: vec![], 85 | }) 86 | } 87 | 88 | /// Save back to the file the document was opened from. 89 | /// # Errors 90 | /// Returns an error if the file fails to write, due to permissions 91 | /// or character set issues. 92 | pub fn save(&mut self) -> Result<()> { 93 | if self.info.read_only { 94 | Err(Error::ReadOnlyFile) 95 | } else if let Some(file_name) = &self.file_name { 96 | self.file 97 | .write_to(BufWriter::new(File::create(file_name)?))?; 98 | self.event_mgmt.disk_write(&self.take_snapshot()); 99 | Ok(()) 100 | } else { 101 | Err(Error::NoFileName) 102 | } 103 | } 104 | 105 | /// Save to a specified file. 106 | /// # Errors 107 | /// Returns an error if the file fails to write, due to permissions 108 | /// or character set issues. 109 | pub fn save_as(&self, file_name: &str) -> Result<()> { 110 | if self.info.read_only { 111 | Err(Error::ReadOnlyFile) 112 | } else { 113 | self.file 114 | .write_to(BufWriter::new(File::create(file_name)?))?; 115 | Ok(()) 116 | } 117 | } 118 | 119 | /// Load lines in this document up to a specified index. 120 | /// This must be called before starting to edit the document as 121 | /// this is the function that actually load and processes the text. 122 | pub fn load_to(&mut self, mut to: usize) { 123 | // Make sure to doesn't go over the number of lines in the buffer 124 | let len_lines = self.file.len_lines(); 125 | if to >= len_lines { 126 | to = len_lines; 127 | } 128 | // Only act if there are lines we haven't loaded yet 129 | if to > self.info.loaded_to { 130 | // For each line, run through each character and make note of any double width characters 131 | for i in self.info.loaded_to..to { 132 | let line: String = self.file.line(i).chars().collect(); 133 | // Add to char maps 134 | let (dbl_map, tab_map) = form_map(&line, self.tab_width); 135 | self.dbl_map.insert(i, dbl_map); 136 | self.tab_map.insert(i, tab_map); 137 | // Cache this line 138 | self.lines 139 | .push(line.trim_end_matches(['\n', '\r']).to_string()); 140 | } 141 | // Store new loaded point 142 | self.info.loaded_to = to; 143 | } 144 | } 145 | } 146 | 147 | pub fn load_rope_from_reader(mut reader: T) -> Rope { 148 | let mut buffer = [0u8; 2048]; // Buffer to read chunks 149 | let mut valid_string = String::new(); 150 | let mut incomplete_bytes = Vec::new(); // Buffer to handle partial UTF-8 sequences 151 | 152 | while let Ok(bytes_read) = reader.read(&mut buffer) { 153 | if bytes_read == 0 { 154 | break; // EOF reached 155 | } 156 | 157 | // Combine leftover bytes with current chunk 158 | incomplete_bytes.extend_from_slice(&buffer[..bytes_read]); 159 | 160 | // Attempt to decode as much UTF-8 as possible 161 | match String::from_utf8(incomplete_bytes.clone()) { 162 | Ok(decoded) => { 163 | valid_string.push_str(&decoded); // Append valid data 164 | incomplete_bytes.clear(); // Clear incomplete bytes 165 | } 166 | Err(err) => { 167 | // Handle valid and invalid parts separately 168 | let valid_up_to = err.utf8_error().valid_up_to(); 169 | valid_string.push_str(&String::from_utf8_lossy(&incomplete_bytes[..valid_up_to])); 170 | incomplete_bytes = incomplete_bytes[valid_up_to..].to_vec(); // Retain invalid/partial 171 | } 172 | } 173 | } 174 | 175 | // Append any remaining valid UTF-8 data 176 | if !incomplete_bytes.is_empty() { 177 | valid_string.push_str(&String::from_utf8_lossy(&incomplete_bytes)); 178 | } 179 | 180 | Rope::from_str(&valid_string) 181 | } 182 | -------------------------------------------------------------------------------- /kaolinite/src/document/editing.rs: -------------------------------------------------------------------------------- 1 | use crate::event::{Error, Event, Result}; 2 | use crate::map::form_map; 3 | use crate::utils::{get_range, tab_boundaries_backward}; 4 | use crate::{Document, Loc}; 5 | use std::ops::RangeBounds; 6 | 7 | impl Document { 8 | /// Inserts a string into this document. 9 | /// # Errors 10 | /// Returns an error if location is out of range. 11 | pub fn insert(&mut self, loc: &Loc, st: &str) -> Result<()> { 12 | self.out_of_range(loc.x, loc.y)?; 13 | // Move cursor to location 14 | self.move_to(loc); 15 | // Update rope 16 | let idx = self.loc_to_file_pos(loc); 17 | self.file.insert(idx, st); 18 | // Update cache 19 | let line: String = self.file.line(loc.y).chars().collect(); 20 | self.lines[loc.y] = line.trim_end_matches(['\n', '\r']).to_string(); 21 | // Update unicode map 22 | let dbl_start = self.dbl_map.shift_insertion(loc, st, self.tab_width); 23 | let tab_start = self.tab_map.shift_insertion(loc, st, self.tab_width); 24 | // Register new double widths and tabs 25 | let (mut dbls, mut tabs) = form_map(st, self.tab_width); 26 | // Shift up to match insertion position in the document 27 | let tab_shift = self.tab_width.saturating_sub(1) * tab_start; 28 | for e in &mut dbls { 29 | *e = (e.0 + loc.x + dbl_start + tab_shift, e.1 + loc.x); 30 | } 31 | for e in &mut tabs { 32 | *e = (e.0 + loc.x + tab_shift + dbl_start, e.1 + loc.x); 33 | } 34 | self.dbl_map.splice(loc, dbl_start, dbls); 35 | self.tab_map.splice(loc, tab_start, tabs); 36 | // Go to end x position 37 | self.move_to_x(loc.x + st.chars().count()); 38 | self.old_cursor = self.loc().x; 39 | Ok(()) 40 | } 41 | 42 | /// Deletes a character at a location whilst checking for tab spaces 43 | /// 44 | /// # Errors 45 | /// This code will error if the location is invalid 46 | pub fn delete_with_tab(&mut self, loc: &Loc, st: &str) -> Result<()> { 47 | // Check for tab spaces 48 | let boundaries = 49 | tab_boundaries_backward(&self.line(loc.y).unwrap_or_default(), self.tab_width); 50 | if boundaries.contains(&loc.x.saturating_add(1)) && !self.in_redo { 51 | // Register other delete actions to delete the whole tab 52 | let mut loc_copy = *loc; 53 | self.delete(loc.x..=loc.x + st.chars().count(), loc.y)?; 54 | for _ in 1..self.tab_width { 55 | loc_copy.x = loc_copy.x.saturating_sub(1); 56 | self.exe(Event::Delete(loc_copy, " ".to_string()))?; 57 | } 58 | Ok(()) 59 | } else { 60 | // Normal character delete 61 | self.delete(loc.x..=loc.x + st.chars().count(), loc.y) 62 | } 63 | } 64 | 65 | /// Deletes a range from this document. 66 | /// # Errors 67 | /// Returns an error if location is out of range. 68 | pub fn delete(&mut self, x: R, y: usize) -> Result<()> 69 | where 70 | R: RangeBounds, 71 | { 72 | let line_start = self.file.try_line_to_char(y)?; 73 | let line_end = line_start + self.line(y).ok_or(Error::OutOfRange)?.chars().count(); 74 | // Extract range information 75 | let (mut start, mut end) = get_range(&x, line_start, line_end); 76 | self.valid_range(start, end, y)?; 77 | self.move_to(&Loc::at(start, y)); 78 | start += line_start; 79 | end += line_start; 80 | let removed = self.file.slice(start..end).to_string(); 81 | // Update unicode and tab map 82 | self.dbl_map.shift_deletion( 83 | &Loc::at(line_start, y), 84 | (start, end), 85 | &removed, 86 | self.tab_width, 87 | ); 88 | self.tab_map.shift_deletion( 89 | &Loc::at(line_start, y), 90 | (start, end), 91 | &removed, 92 | self.tab_width, 93 | ); 94 | // Update rope 95 | self.file.remove(start..end); 96 | // Update cache 97 | let line: String = self.file.line(y).chars().collect(); 98 | self.lines[y] = line.trim_end_matches(['\n', '\r']).to_string(); 99 | self.old_cursor = self.loc().x; 100 | Ok(()) 101 | } 102 | 103 | /// Inserts a line into the document. 104 | /// # Errors 105 | /// Returns an error if location is out of range. 106 | pub fn insert_line(&mut self, loc: usize, contents: String) -> Result<()> { 107 | if !(self.lines.is_empty() || self.len_lines() == 0 && loc == 0) { 108 | self.out_of_range(0, loc.saturating_sub(1))?; 109 | } 110 | // Update unicode and tab map 111 | self.dbl_map.shift_down(loc); 112 | self.tab_map.shift_down(loc); 113 | // Calculate the unicode map and tab map of this line 114 | let (dbl_map, tab_map) = form_map(&contents, self.tab_width); 115 | self.dbl_map.insert(loc, dbl_map); 116 | self.tab_map.insert(loc, tab_map); 117 | // Update cache 118 | self.lines.insert(loc, contents.to_string()); 119 | // Update rope 120 | let char_idx = self.file.line_to_char(loc); 121 | self.file.insert(char_idx, &(contents + "\n")); 122 | self.info.loaded_to += 1; 123 | // Goto line 124 | self.move_to_y(loc); 125 | self.old_cursor = self.loc().x; 126 | Ok(()) 127 | } 128 | 129 | /// Deletes a line from the document. 130 | /// # Errors 131 | /// Returns an error if location is out of range. 132 | pub fn delete_line(&mut self, loc: usize) -> Result<()> { 133 | self.out_of_range(0, loc)?; 134 | // Update tab & unicode map 135 | self.dbl_map.delete(loc); 136 | self.tab_map.delete(loc); 137 | // Shift down other line numbers in the hashmap 138 | self.dbl_map.shift_up(loc); 139 | self.tab_map.shift_up(loc); 140 | // Update cache 141 | self.lines.remove(loc); 142 | // Update rope 143 | let idx_start = self.file.line_to_char(loc); 144 | let idx_end = self.file.line_to_char(loc + 1); 145 | self.file.remove(idx_start..idx_end); 146 | self.info.loaded_to = self.info.loaded_to.saturating_sub(1); 147 | // Goto line 148 | self.move_to_y(loc); 149 | self.old_cursor = self.loc().x; 150 | Ok(()) 151 | } 152 | 153 | /// Split a line in half, putting the right hand side below on a new line. 154 | /// For when the return key is pressed. 155 | /// # Errors 156 | /// Returns an error if location is out of range. 157 | pub fn split_down(&mut self, loc: &Loc) -> Result<()> { 158 | self.out_of_range(loc.x, loc.y)?; 159 | // Gather context 160 | let line = self.line(loc.y).ok_or(Error::OutOfRange)?; 161 | let rhs: String = line.chars().skip(loc.x).collect(); 162 | self.delete(loc.x.., loc.y)?; 163 | self.insert_line(loc.y + 1, rhs)?; 164 | self.move_to(&Loc::at(0, loc.y + 1)); 165 | self.old_cursor = self.loc().x; 166 | Ok(()) 167 | } 168 | 169 | /// Remove the line below the specified location and append that to it. 170 | /// For when backspace is pressed on the start of a line. 171 | /// # Errors 172 | /// Returns an error if location is out of range. 173 | pub fn splice_up(&mut self, y: usize) -> Result<()> { 174 | self.out_of_range(0, y + 1)?; 175 | // Gather context 176 | let length = self.line(y).ok_or(Error::OutOfRange)?.chars().count(); 177 | let below = self.line(y + 1).ok_or(Error::OutOfRange)?; 178 | self.delete_line(y + 1)?; 179 | self.insert(&Loc::at(length, y), &below)?; 180 | self.move_to(&Loc::at(length, y)); 181 | self.old_cursor = self.loc().x; 182 | Ok(()) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /kaolinite/src/document/lines.rs: -------------------------------------------------------------------------------- 1 | use crate::event::{Error, Result}; 2 | use crate::utils::trim; 3 | use crate::{Document, Loc}; 4 | 5 | impl Document { 6 | /// Get the line at a specified index 7 | #[must_use] 8 | pub fn line(&self, line: usize) -> Option { 9 | Some(self.lines.get(line)?.to_string()) 10 | } 11 | 12 | /// Get the line at a specified index and trim it 13 | #[must_use] 14 | pub fn line_trim(&self, line: usize, start: usize, length: usize) -> Option { 15 | let line = self.line(line); 16 | Some(trim(&line?, start, length, self.tab_width)) 17 | } 18 | 19 | /// Returns the number of lines in the document 20 | #[must_use] 21 | pub fn len_lines(&self) -> usize { 22 | self.file.len_lines().saturating_sub(1) + usize::from(self.info.eol) 23 | } 24 | 25 | /// Evaluate the line number text for a specific line 26 | #[must_use] 27 | pub fn line_number(&self, request: usize) -> String { 28 | let total = self.len_lines().to_string().len(); 29 | let num = if request + 1 > self.len_lines() { 30 | "~".to_string() 31 | } else { 32 | (request + 1).to_string() 33 | }; 34 | format!("{}{}", " ".repeat(total.saturating_sub(num.len())), num) 35 | } 36 | 37 | /// Swap a line upwards 38 | /// # Errors 39 | /// When out of bounds 40 | pub fn swap_line_up(&mut self) -> Result<()> { 41 | let cursor = self.char_loc(); 42 | let line = self.line(cursor.y).ok_or(Error::OutOfRange)?; 43 | self.insert_line(cursor.y.saturating_sub(1), line)?; 44 | self.delete_line(cursor.y + 1)?; 45 | self.move_to(&Loc { 46 | x: cursor.x, 47 | y: cursor.y.saturating_sub(1), 48 | }); 49 | Ok(()) 50 | } 51 | 52 | /// Swap a line downwards 53 | /// # Errors 54 | /// When out of bounds 55 | pub fn swap_line_down(&mut self) -> Result<()> { 56 | let cursor = self.char_loc(); 57 | let line = self.line(cursor.y).ok_or(Error::OutOfRange)?; 58 | self.insert_line(cursor.y + 2, line)?; 59 | self.delete_line(cursor.y)?; 60 | self.move_to(&Loc { 61 | x: cursor.x, 62 | y: cursor.y + 1, 63 | }); 64 | Ok(()) 65 | } 66 | 67 | /// Select a line at a location 68 | pub fn select_line_at(&mut self, y: usize) { 69 | let len = self.line(y).unwrap_or_default().chars().count(); 70 | self.move_to(&Loc { x: 0, y }); 71 | self.select_to(&Loc { x: len, y }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /kaolinite/src/event.rs: -------------------------------------------------------------------------------- 1 | /// event.rs - manages editing events and provides tools for error handling 2 | use crate::{document::Cursor, utils::Loc, Document}; 3 | use error_set::error_set; 4 | use ropey::Rope; 5 | 6 | /// A snapshot stores the state of a document at a certain time 7 | #[derive(Debug, Clone, PartialEq, Eq)] 8 | pub struct Snapshot { 9 | pub content: Rope, 10 | pub cursor: Cursor, 11 | } 12 | 13 | /// Represents an editing event. 14 | /// All possible editing events can be made up of a combination these events. 15 | #[derive(Debug, Clone, PartialEq, Eq)] 16 | pub enum Event { 17 | Insert(Loc, String), 18 | Delete(Loc, String), 19 | InsertLine(usize, String), 20 | DeleteLine(usize, String), 21 | SplitDown(Loc), 22 | SpliceUp(Loc), 23 | } 24 | 25 | impl Event { 26 | /// Given an event, provide the opposite of that event (for purposes of undoing) 27 | #[must_use] 28 | pub fn reverse(self) -> Event { 29 | match self { 30 | Event::Insert(loc, ch) => Event::Delete(loc, ch), 31 | Event::Delete(loc, ch) => Event::Insert(loc, ch), 32 | Event::InsertLine(loc, st) => Event::DeleteLine(loc, st), 33 | Event::DeleteLine(loc, st) => Event::InsertLine(loc, st), 34 | Event::SplitDown(loc) => Event::SpliceUp(loc), 35 | Event::SpliceUp(loc) => Event::SplitDown(loc), 36 | } 37 | } 38 | 39 | /// Get the location of an event 40 | #[must_use] 41 | pub fn loc(&self) -> Loc { 42 | match self { 43 | Event::Insert(loc, _) 44 | | Event::Delete(loc, _) 45 | | Event::SplitDown(loc) 46 | | Event::SpliceUp(loc) => *loc, 47 | Event::InsertLine(loc, _) | Event::DeleteLine(loc, _) => Loc { x: 0, y: *loc }, 48 | } 49 | } 50 | 51 | /// Work out if the event is of the same type 52 | #[must_use] 53 | pub fn same_type(&self, ev: &Self) -> bool { 54 | matches!( 55 | (self, ev), 56 | (&Event::Insert(_, _), &Event::Insert(_, _)) 57 | | (&Event::Delete(_, _), &Event::Delete(_, _)) 58 | | (&Event::InsertLine(_, _), &Event::InsertLine(_, _)) 59 | | (&Event::DeleteLine(_, _), &Event::DeleteLine(_, _)) 60 | | (&Event::SplitDown(_), &Event::SplitDown(_)) 61 | | (&Event::SpliceUp(_), &Event::SpliceUp(_)) 62 | ) 63 | } 64 | } 65 | 66 | /// Represents various statuses of functions 67 | #[derive(Debug, PartialEq, Eq)] 68 | pub enum Status { 69 | StartOfFile, 70 | EndOfFile, 71 | StartOfLine, 72 | EndOfLine, 73 | None, 74 | } 75 | 76 | /// Easy result type for unified error handling 77 | pub type Result = std::result::Result; 78 | 79 | error_set! { 80 | /// Error enum for handling all possible errors 81 | Error = { 82 | #[display("I/O error: {0}")] 83 | Io(std::io::Error), 84 | #[display("Rope error: {0}")] 85 | Rope(ropey::Error), 86 | NoFileName, 87 | OutOfRange, 88 | ReadOnlyFile 89 | }; 90 | } 91 | 92 | /// For managing events for purposes of undo and redo 93 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 94 | pub struct EventMgmt { 95 | /// Contains all the snapshots in the current timeline 96 | pub history: Vec, 97 | /// Stores where the document currently is 98 | pub ptr: Option, 99 | /// Store where the file on the disk is currently at 100 | pub on_disk: Option, 101 | /// Store the last event to occur (so that we can see if there is a change) 102 | pub last_event: Option, 103 | /// Flag to force the file not to be with disk (i.e. file only exists in memory) 104 | pub force_not_with_disk: bool, 105 | } 106 | 107 | impl Document { 108 | #[must_use] 109 | pub fn take_snapshot(&self) -> Snapshot { 110 | Snapshot { 111 | content: self.file.clone(), 112 | cursor: self.cursor, 113 | } 114 | } 115 | 116 | pub fn apply_snapshot(&mut self, snapshot: Snapshot) { 117 | self.file = snapshot.content; 118 | self.cursor = snapshot.cursor; 119 | self.char_ptr = self.character_idx(&snapshot.cursor.loc); 120 | self.reload_lines(); 121 | self.bring_cursor_in_viewport(); 122 | } 123 | } 124 | 125 | impl EventMgmt { 126 | /// In the event of some changes, redo should be cleared 127 | pub fn clear_redo(&mut self) { 128 | if let Some(ptr) = self.ptr { 129 | self.history.drain(ptr + 1..); 130 | } 131 | } 132 | 133 | /// To be called when a snapshot needs to be registered 134 | pub fn commit(&mut self, snapshot: Snapshot) { 135 | // Only commit when previous snapshot differs 136 | let ptr = self.ptr.unwrap_or(0); 137 | if self.history.get(ptr).map(|s| &s.content) != Some(&snapshot.content) { 138 | self.clear_redo(); 139 | self.history.push(snapshot); 140 | self.ptr = Some(self.history.len().saturating_sub(1)); 141 | } 142 | } 143 | 144 | /// To be called when writing to disk 145 | pub fn disk_write(&mut self, snapshot: &Snapshot) { 146 | self.force_not_with_disk = false; 147 | self.commit(snapshot.clone()); 148 | self.on_disk = self.ptr; 149 | } 150 | 151 | /// A way to query whether we're currently up to date with the disk 152 | #[must_use] 153 | pub fn with_disk(&self, snapshot: &Snapshot) -> bool { 154 | if self.force_not_with_disk { 155 | false 156 | } else if let Some(disk) = self.on_disk { 157 | self.history.get(disk).map(|s| &s.content) == Some(&snapshot.content) 158 | } else if self.history.is_empty() { 159 | true 160 | } else { 161 | self.history.first().map(|s| &s.content) == Some(&snapshot.content) 162 | } 163 | } 164 | 165 | /// Get previous snapshot to restore to 166 | pub fn undo(&mut self, snapshot: Snapshot) -> Option { 167 | // Push cursor back by 1 168 | self.commit(snapshot); 169 | if let Some(ptr) = self.ptr { 170 | if ptr != 0 { 171 | let new_ptr = ptr.saturating_sub(1); 172 | self.ptr = Some(new_ptr); 173 | self.history.get(new_ptr).cloned() 174 | } else { 175 | None 176 | } 177 | } else { 178 | None 179 | } 180 | } 181 | 182 | /// Get snapshot that used to be in place 183 | pub fn redo(&mut self, snapshot: &Snapshot) -> Option { 184 | if let Some(ptr) = self.ptr { 185 | // If the user has edited since the undo, wipe the redo stack 186 | if self.history.get(ptr).map(|s| &s.content) != Some(&snapshot.content) { 187 | self.clear_redo(); 188 | } 189 | // Perform the redo 190 | let new_ptr = if ptr + 1 < self.history.len() { 191 | ptr + 1 192 | } else { 193 | return None; 194 | }; 195 | self.ptr = Some(new_ptr); 196 | self.history.get(new_ptr).cloned() 197 | } else { 198 | None 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /kaolinite/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Kaolinite 2 | //! > Kaolinite is an advanced library that handles the backend of a terminal text editor. You can 3 | //! > feel free to make your own terminal text editor using kaolinite, or see the reference 4 | //! > implementation found under the directory `examples/cactus`. 5 | //! 6 | //! It'll handle things like 7 | //! - Opening and saving files 8 | //! - Handle documents that are too long to be fitted on the whole terminal 9 | //! - Rendering line numbers 10 | //! - Insertion and deletion from the document 11 | //! - File type detection 12 | //! - Undo & Redo 13 | //! - Moving around the document, by word, page, character or other means 14 | //! - Searching & Replacing 15 | //! - Handles tabs, different line endings and double width characters perfectly 16 | //! - File buffering for larger files 17 | //! 18 | //! It removes a lot of complexity from your text editor and allows the creation of an advanced 19 | //! text editor in very few lines of idiomatic code. 20 | //! 21 | //! To get started, check out the [Document] struct, which will allow you to open, edit and save 22 | //! documents. 23 | //! I also highly recommend that you check out `examples/cactus/src/main.rs` which is a full 24 | //! implementation of kaolinite, and can be used as a base for your very own editor. It's well 25 | //! documented and explains what it's doing. 26 | 27 | #![warn(clippy::all, clippy::pedantic)] 28 | #![allow(clippy::module_name_repetitions)] 29 | pub mod document; 30 | pub mod event; 31 | pub mod map; 32 | pub mod searching; 33 | pub mod utils; 34 | 35 | pub use document::Document; 36 | pub use utils::{Loc, Size}; 37 | -------------------------------------------------------------------------------- /kaolinite/src/map.rs: -------------------------------------------------------------------------------- 1 | /// map.rs - provides an easy interface to manage characters with large widths 2 | use crate::utils::{width, Loc}; 3 | use std::collections::HashMap; 4 | use unicode_width::UnicodeWidthChar; 5 | 6 | /// This is a type for making a note of the location of different characters 7 | /// `HashMap`<`y_pos`, Vec<(display, character)>> 8 | type CharHashMap = HashMap>; 9 | 10 | /// Keeps notes of specific characters within a document for the purposes of double width and 11 | /// tab characters, which have display widths different to that of their character width 12 | #[derive(Default, Clone, PartialEq, Eq, Debug)] 13 | pub struct CharMap { 14 | pub map: CharHashMap, 15 | } 16 | 17 | impl CharMap { 18 | /// Create a new character map 19 | #[must_use] 20 | pub fn new(map: CharHashMap) -> Self { 21 | Self { map } 22 | } 23 | 24 | /// Add a value to a line in the map 25 | pub fn add(&mut self, idx: usize, val: (usize, usize)) { 26 | if let Some(map) = self.map.get_mut(&idx) { 27 | map.push(val); 28 | } else { 29 | self.map.insert(idx, vec![val]); 30 | } 31 | } 32 | 33 | /// Add a line to the map 34 | pub fn insert(&mut self, idx: usize, slice: Vec<(usize, usize)>) { 35 | if !slice.is_empty() { 36 | self.map.insert(idx, slice); 37 | } 38 | } 39 | 40 | /// Delete a line from the map 41 | pub fn delete(&mut self, idx: usize) { 42 | self.map.remove(&idx); 43 | } 44 | 45 | /// Get a line from the map 46 | #[must_use] 47 | pub fn get(&self, idx: usize) -> Option<&Vec<(usize, usize)>> { 48 | self.map.get(&idx) 49 | } 50 | 51 | /// Verify whether this line is in the map 52 | #[must_use] 53 | pub fn contains(&self, idx: usize) -> bool { 54 | self.map.contains_key(&idx) 55 | } 56 | 57 | /// Add a slice to the map 58 | pub fn splice(&mut self, loc: &Loc, start: usize, slice: Vec<(usize, usize)>) { 59 | if let Some(map) = self.map.get_mut(&loc.y) { 60 | map.splice(start..start, slice); 61 | } else if !slice.is_empty() { 62 | self.map.insert(loc.y, slice); 63 | } 64 | } 65 | 66 | /// Shift entries up in the character map 67 | #[allow(clippy::missing_panics_doc)] 68 | pub fn shift_insertion(&mut self, loc: &Loc, st: &str, tab_width: usize) -> usize { 69 | if !self.map.contains_key(&loc.y) { 70 | return 0; 71 | } 72 | // Gather context 73 | let char_shift = st.chars().count(); 74 | let disp_shift = width(st, tab_width); 75 | // Find point of insertion 76 | let start = self.count(loc, false).unwrap(); 77 | // Shift subsequent characters up 78 | let line_map = self.map.get_mut(&loc.y).unwrap(); 79 | for (display, ch) in line_map.iter_mut().skip(start) { 80 | *display += disp_shift; 81 | *ch += char_shift; 82 | } 83 | start 84 | } 85 | 86 | /// Shift entries down in the character map 87 | #[allow(clippy::missing_panics_doc)] 88 | pub fn shift_deletion(&mut self, loc: &Loc, x: (usize, usize), st: &str, tab_width: usize) { 89 | if !self.map.contains_key(&loc.y) { 90 | return; 91 | } 92 | // Gather context 93 | let char_shift = st.chars().count(); 94 | let disp_shift = width(st, tab_width); 95 | let (start, end) = x; 96 | let Loc { x: line_start, y } = loc; 97 | // Work out indices of deletion 98 | let start_map = self 99 | .count( 100 | &Loc { 101 | x: start.saturating_sub(*line_start), 102 | y: *y, 103 | }, 104 | false, 105 | ) 106 | .unwrap(); 107 | let map_count = self 108 | .count( 109 | &Loc { 110 | x: end.saturating_sub(*line_start), 111 | y: *y, 112 | }, 113 | false, 114 | ) 115 | .unwrap(); 116 | let line_map = self.map.get_mut(y).unwrap(); 117 | // Update subsequent map characters 118 | for (display, ch) in line_map.iter_mut().skip(map_count) { 119 | *display = display.saturating_sub(disp_shift); 120 | *ch = ch.saturating_sub(char_shift); 121 | } 122 | // Remove entries for the range 123 | line_map.drain(start_map..map_count); 124 | // Remove entry if no map characters exist anymore 125 | if line_map.is_empty() { 126 | self.map.remove(y); 127 | } 128 | } 129 | 130 | /// Shift lines in the character map up one 131 | #[allow(clippy::missing_panics_doc)] 132 | pub fn shift_up(&mut self, loc: usize) { 133 | let mut keys: Vec = self.map.keys().copied().collect(); 134 | keys.sort_unstable(); 135 | for k in keys { 136 | if k >= loc { 137 | let v = self.map.remove(&k).unwrap(); 138 | self.map.insert(k.saturating_sub(1), v); 139 | } 140 | } 141 | } 142 | 143 | /// Shift lines in the character map down one 144 | #[allow(clippy::missing_panics_doc)] 145 | pub fn shift_down(&mut self, loc: usize) { 146 | let mut keys: Vec = self.map.keys().copied().collect(); 147 | keys.sort_unstable(); 148 | keys.reverse(); 149 | for k in keys { 150 | if k >= loc { 151 | let v = self.map.remove(&k).unwrap(); 152 | self.map.insert(k + 1, v); 153 | } 154 | } 155 | } 156 | 157 | /// Count the number of characters before an index, useful for conversion of indices 158 | #[must_use] 159 | pub fn count(&self, loc: &Loc, display: bool) -> Option { 160 | let mut ctr = 0; 161 | for i in self.get(loc.y)? { 162 | let i = if display { i.0 } else { i.1 }; 163 | if i >= loc.x { 164 | break; 165 | } 166 | ctr += 1; 167 | } 168 | Some(ctr) 169 | } 170 | 171 | /// If all character maps are of size n, then determine if x would be within one, 172 | /// and return their index inside the mapped char 173 | #[must_use] 174 | pub fn inside(&self, n: usize, x: usize, y: usize) -> Option { 175 | for (disp, _) in self.get(y)? { 176 | if ((disp + 1)..(disp + n)).contains(&x) { 177 | return Some(x.saturating_sub(*disp)); 178 | } 179 | } 180 | None 181 | } 182 | } 183 | 184 | /// Vector that takes two usize values 185 | pub type DblUsize = Vec<(usize, usize)>; 186 | 187 | /// Work out the map contents from a string 188 | #[must_use] 189 | pub fn form_map(st: &str, tab_width: usize) -> (DblUsize, DblUsize) { 190 | let mut dbl = vec![]; 191 | let mut tab = vec![]; 192 | let mut idx = 0; 193 | for (char_idx, ch) in st.chars().enumerate() { 194 | if ch == '\t' { 195 | tab.push((idx, char_idx)); 196 | idx += tab_width; 197 | } else if ch.width().unwrap_or(1) == 1 { 198 | idx += 1; 199 | } else { 200 | dbl.push((idx, char_idx)); 201 | idx += 2; 202 | } 203 | } 204 | (dbl, tab) 205 | } 206 | -------------------------------------------------------------------------------- /kaolinite/src/searching.rs: -------------------------------------------------------------------------------- 1 | /// searching.rs - utilities to assist with searching a document 2 | use crate::regex; 3 | use crate::utils::Loc; 4 | use regex::Regex; 5 | 6 | /// Stores information about a match in a document 7 | #[derive(Debug, PartialEq, Eq, Clone)] 8 | pub struct Match { 9 | pub loc: Loc, 10 | pub text: String, 11 | } 12 | 13 | /// Struct to abstract searching 14 | pub struct Searcher { 15 | pub re: Regex, 16 | } 17 | 18 | impl Searcher { 19 | /// Create a new searcher 20 | #[must_use] 21 | pub fn new(re: &str) -> Self { 22 | Self { re: regex!(re) } 23 | } 24 | 25 | /// Find the next match, starting from the left hand side of the string 26 | pub fn lfind(&mut self, st: &str) -> Option { 27 | for cap in self.re.captures_iter(st) { 28 | if let Some(c) = cap.get(cap.len().saturating_sub(1)) { 29 | let x = Self::raw_to_char(c.start(), st); 30 | return Some(Match { 31 | loc: Loc::at(x, 0), 32 | text: c.as_str().to_string(), 33 | }); 34 | } 35 | } 36 | None 37 | } 38 | 39 | /// Find the next match, starting from the right hand side of the string 40 | pub fn rfind(&mut self, st: &str) -> Option { 41 | let mut caps: Vec<_> = self.re.captures_iter(st).collect(); 42 | caps.reverse(); 43 | for cap in caps { 44 | if let Some(c) = cap.get(cap.len().saturating_sub(1)) { 45 | let x = Self::raw_to_char(c.start(), st); 46 | return Some(Match { 47 | loc: Loc::at(x, 0), 48 | text: c.as_str().to_string(), 49 | }); 50 | } 51 | } 52 | None 53 | } 54 | 55 | /// Finds all the matches to the left 56 | pub fn lfinds(&mut self, st: &str) -> Vec { 57 | let mut result = vec![]; 58 | for cap in self.re.captures_iter(st) { 59 | if let Some(c) = cap.get(cap.len().saturating_sub(1)) { 60 | let x = Self::raw_to_char(c.start(), st); 61 | result.push(Match { 62 | loc: Loc::at(x, 0), 63 | text: c.as_str().to_string(), 64 | }); 65 | } 66 | } 67 | result 68 | } 69 | 70 | /// Finds all the matches to the left from a certain point onwards 71 | pub fn lfinds_raw(&mut self, st: &str) -> Vec { 72 | let mut result = vec![]; 73 | for cap in self.re.captures_iter(st) { 74 | if let Some(c) = cap.get(cap.len().saturating_sub(1)) { 75 | result.push(Match { 76 | loc: Loc::at(c.start(), 0), 77 | text: c.as_str().to_string(), 78 | }); 79 | } 80 | } 81 | result 82 | } 83 | 84 | /// Finds all the matches to the right 85 | pub fn rfinds(&mut self, st: &str) -> Vec { 86 | let mut result = vec![]; 87 | let mut caps: Vec<_> = self.re.captures_iter(st).collect(); 88 | caps.reverse(); 89 | for cap in caps { 90 | if let Some(c) = cap.get(cap.len().saturating_sub(1)) { 91 | let x = Self::raw_to_char(c.start(), st); 92 | result.push(Match { 93 | loc: Loc::at(x, 0), 94 | text: c.as_str().to_string(), 95 | }); 96 | } 97 | } 98 | result 99 | } 100 | 101 | /// Converts a raw index into a character index, so that matches are in character indices 102 | #[must_use] 103 | pub fn raw_to_char(x: usize, st: &str) -> usize { 104 | for (acc_char, (acc_byte, _)) in st.char_indices().enumerate() { 105 | if acc_byte == x { 106 | return acc_char; 107 | } 108 | } 109 | st.chars().count() 110 | } 111 | 112 | /// Converts a raw index into a character index, so that matches are in character indices 113 | #[must_use] 114 | pub fn char_to_raw(x: usize, st: &str) -> usize { 115 | st.char_indices().nth(x).map_or(st.len(), |(byte, _)| byte) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /kaolinite/tests/data/big.txt: -------------------------------------------------------------------------------- 1 | 5748248337351130204990967092462 2 | 8987322808956231384991667825672 3 | 1980281077075981767509327555254 4 | 4081246106821888240886212802811 5 | 1005615384628994377700125974710 6 | 9313646806346282042453670278725 7 | 7466756277333388939346969874513 8 | 6704325544853287912963199432976 9 | 4479870972635586679473402666255 10 | 8533691274643838896701359753561 11 | 4785236348386650441736073031643 12 | 8138855521055656195826043330511 13 | 4492181311729948750219046887767 14 | 4649587632109395290107058891205 15 | 8718734062089564653862265678124 16 | 3431045720213279208616617085132 17 | 5908960433190582270359093274373 18 | 8816295993759776219063552771716 19 | 8436814867503583663321449064970 20 | 1055342708081980376435311258863 21 | 9092284407345398560506857443658 22 | 3952610312423591608137688059881 23 | 7476369712441891604418744494905 24 | 7530217680381669402724994168454 25 | 2919091211037435496956637262989 26 | 2653273281479752901621867322572 27 | 8390387139882304177995353910791 28 | 6404738498333721488115663528251 29 | 9636143040637047480516502823188 30 | 9679461968764370175691401785263 31 | 6982003446677262213449330374816 32 | 7678603199177763687048558396707 33 | 9249185621272071675750218981173 34 | 7897074654816382317133831833126 35 | 9697933350861440665303954004352 36 | 9411081272438711563143398440503 37 | 4463378769689201676567063830052 38 | 7879792025442899228152856349416 39 | 8428060034195106621110107643961 40 | 7334205968646239342162923267487 41 | 7869023887341695078663386015215 42 | 4262239141426783991629179359545 43 | 9149012617058301612124938978609 44 | 3109091551850763672378778943274 45 | 2970283724167546811853099240436 46 | 5527781889557786218200594438794 47 | 6642128860709722974143610466152 48 | 3565586553088606753694378672568 49 | 8827625671062181688309502382654 50 | 2882883983616559757626162768265 51 | 8235940458905864832419904559064 52 | 3653254630191816745015813515588 53 | 5700153046883352582497217039767 54 | 3189631212089573833065056324297 55 | 5472986476454106275363021561816 56 | 5467295395777835910838157533366 57 | 7484083533664743275938024749283 58 | 3744672872541509450453661243924 59 | 7128785925761944655841801525630 60 | 8837157334876579397202591450453 61 | 8048057241889037250813518176297 62 | 5509142425468598548293920618586 63 | 2875339513893986617661701924029 64 | 9098580290170831132266418214398 65 | 2814082352526337091772742659728 66 | 7385152515501479628305340793152 67 | 7044147124213188651625879583567 68 | 4312802887462961287127829887525 69 | 7293419330390058723602792351153 70 | 3943914087053993223343108441143 71 | 8675359705914776536820361455854 72 | 2404184670534180586077255105884 73 | 1143694253830362419140968205402 74 | 3698853834352875765220724490795 75 | 4924790182440019311265268197320 76 | 5073351967611101925970948053929 77 | 8938400993372455792343625250220 78 | 1355858919355113784216099184249 79 | 7574673247684313749172518067914 80 | 2186387226504473513137942958691 81 | 2602376462028483806860574032177 82 | 9632664107200009047310411524639 83 | 6498109828928645433318704601614 84 | 1420315958337890886564319596850 85 | 8264102759317485404765997615705 86 | 4425917170030228551278315777014 87 | 6443499629982559316589846390507 88 | 6446349691551730284465567245019 89 | 5636361883637676140721991190982 90 | 2204537362392451182558575037780 91 | 3100778219914208098933764699878 92 | 9375974279821924092123750051892 93 | 8996997754482104792153547581308 94 | 4903993683921413418301833949599 95 | 8243983368864500408961811786293 96 | 1065859898731929471703666333752 97 | 3936822987158495796490667177349 98 | 2062364384325953875925153931335 99 | 6472740474564966858046394934294 100 | 6851234826261880974948933157721 101 | -------------------------------------------------------------------------------- /kaolinite/tests/data/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curlpipe/ox/6cb11e7dbb0817d3590e4db8098b4b42c366b278/kaolinite/tests/data/empty.txt -------------------------------------------------------------------------------- /kaolinite/tests/data/no_eol.txt: -------------------------------------------------------------------------------- 1 | hello, world -------------------------------------------------------------------------------- /kaolinite/tests/data/saving.txt: -------------------------------------------------------------------------------- 1 | this document is original 2 | -------------------------------------------------------------------------------- /kaolinite/tests/data/unicode.txt: -------------------------------------------------------------------------------- 1 | 你好 2 | hello 3 | hello 4 | 你好 5 | hello你world好hello 6 | -------------------------------------------------------------------------------- /plugins/ai.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | AI v0.1 3 | 4 | If you need advice or code, this plug-in will help you 5 | 6 | It has two different options: 7 | - Advice, where it will answer questions about the opened code 8 | - Code, where it will look at the comment above the cursor and 9 | insert code based on the comment 10 | 11 | You can select between different models, including 12 | - gemini - Google's Gemini 13 | - chatgpt - OpenAI's ChatGPT 14 | - claude - Anthropic's Claude 15 | ]]-- 16 | 17 | ai = { 18 | model = (ai or { model = "gemini" }).model, -- Gemini is free by default! 19 | key = (ai or { key = nil }).key, -- API key 20 | } 21 | 22 | -- Entry point of the plug-in 23 | function ai:run() 24 | -- Gather context information 25 | local file = editor:get() 26 | local language = editor.document_type 27 | local instruction = self:grab_comment() 28 | -- Find out the method the user would like to use 29 | local method = editor:prompt("Would you like advice or code") 30 | local prompt 31 | if method == "advice" then 32 | prompt = self:advice_prompt(file, language, instruction) 33 | elseif method == "code" then 34 | prompt = self:code_prompt(file, language, instruction) 35 | end 36 | local response 37 | if self.model == "gemini" then 38 | response = self:send_to_gemini(prompt) 39 | elseif self.model == "chatgpt" then 40 | response = self:send_to_chatgpt(prompt) 41 | elseif self.model == "claude" then 42 | response = self:send_to_claude(prompt) 43 | end 44 | for i = 1, #response do 45 | local char = response:sub(i, i) -- Extract the character at position 'i' 46 | if char == "\n" then 47 | editor:insert_line() 48 | else 49 | editor:insert(char) 50 | end 51 | end 52 | editor:rerender() 53 | end 54 | 55 | event_mapping["alt_space"] = function() 56 | ai:run() 57 | end 58 | 59 | -- Grab any comments above the cursor 60 | function ai:grab_comment() 61 | -- Move upwards from the cursor y position 62 | local lines = {} 63 | local y = editor.cursor.y 64 | 65 | -- While y is greater than 0 66 | while y > 0 do 67 | -- Get the current line 68 | local line = editor:get_line_at(y) 69 | -- Check if the line is empty or full of whitespace 70 | if line:match("^%s*$") and y ~= editor.cursor.y then 71 | break -- Stop processing if an empty line is encountered 72 | else 73 | table.insert(lines, line) 74 | end 75 | -- Move to the previous line 76 | y = y - 1 77 | end 78 | 79 | -- Reverse order 80 | local reversed = {} 81 | for i = #lines, 1, -1 do 82 | table.insert(reversed, lines[i]) 83 | end 84 | local lines = reversed 85 | 86 | return table.concat(lines, "\n") 87 | end 88 | 89 | -- Create a prompt for advice on a code base 90 | function ai:advice_prompt(file, language, instruction) 91 | return string.format( 92 | "Take the following code as context (language is %s):\n```\n%s\n```\n\n\ 93 | Answer the following question: %s\nYour response should ONLY include the answer \ 94 | for this question in comment format in the same language, use the above context if helpful\n\ 95 | Start the code with the marker `(OX START)` and end the code with the marker `(OX END)`, \ 96 | both uncommented but included in the code block, the rest of the answer should be commented in the language we're using", 97 | language, 98 | file, 99 | instruction 100 | ) 101 | end 102 | 103 | -- Create a prompt for code 104 | function ai:code_prompt(file, language, instruction) 105 | return string.format( 106 | "Take the following code as context (language is %s):\n```\n%s\n```\n\n\ 107 | Can you complete the code as the comment suggests, taking into account the above code if required?\n\ 108 | ```\n%s\n```\nYour response should ONLY include the section of code you've written excluding the above comment\ 109 | Start the code with the marker `(OX START)` and end the code with the marker `(OX END)`, both inside the code", 110 | language, 111 | file, 112 | instruction 113 | ) 114 | end 115 | 116 | -- Send prompt to Google Gemini 117 | function ai:send_to_gemini(prompt) 118 | if self.key ~= nil then 119 | editor:display_info("Please wait while your request is processed...") 120 | editor:rerender() 121 | else 122 | editor:display_error("Please specify an API key in your configuration file") 123 | editor:rerender() 124 | return 125 | end 126 | prompt = prompt:gsub("\\", "\\\\") 127 | :gsub('"', '\\"') 128 | :gsub("'", "\\'") 129 | :gsub("\n", "\\n") 130 | :gsub("([$`!])", "\\%1") 131 | local url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=" .. self.key 132 | local cmd = 'curl -s -H "Content-Type: application/json" -X POST -d "{\'contents\':[{\'parts\':[{\'text\': \'' .. prompt .. '\'}]}]}" "' .. url .. '"' 133 | local json = shell:output(cmd) 134 | 135 | -- Find the `text` field within the JSON string 136 | local text_start, text_end = json:find('"text"%s*:%s*"') 137 | if not text_start then 138 | return nil, "Could not find 'text' field" 139 | end 140 | 141 | -- Extract the substring containing the text value 142 | local text_value_start = text_end + 1 143 | local text_value_end = json:find('"', text_value_start) 144 | while text_value_end do 145 | -- Check if the quote is escaped 146 | if json:sub(text_value_end - 1, text_value_end - 1) ~= "\\" then 147 | break 148 | end 149 | -- Continue searching for the ending quote 150 | text_value_end = json:find('"', text_value_end + 1) 151 | end 152 | 153 | if not text_value_end then 154 | return nil, "Unterminated 'text' field" 155 | end 156 | 157 | -- Extract the raw text value and unescape escaped quotes 158 | local text = json:sub(text_value_start, text_value_end - 1) 159 | text = text:gsub('\\"', '"'):gsub('\\\\', '\\') 160 | text = text:gsub("\\n", "\n") 161 | 162 | text = text:match("%(OX START%)(.-)%(OX END%)") 163 | text = text:gsub("\n+$", "\n") 164 | text = text:gsub("^\n+", "\n") 165 | 166 | -- Convert any weird unicode stuff into their actual characters 167 | text = text:gsub("\\u(%x%x%x%x)", function(hex) 168 | local codepoint = tonumber(hex, 16) -- Convert hex to a number 169 | return utf8.char(codepoint) -- Convert number to a UTF-8 character 170 | end) 171 | 172 | editor:display_info("Request processed!") 173 | return text 174 | end 175 | 176 | -- Send prompt to OpenAI ChatGPT 177 | function ai:send_to_chatgpt(prompt) 178 | if self.key ~= nil then 179 | editor:display_info("Please wait while your request is processed...") 180 | editor:rerender() 181 | else 182 | editor:display_error("Please specify an API key in your configuration file") 183 | editor:rerender() 184 | return 185 | end 186 | prompt = prompt:gsub("\\", "\\\\") 187 | :gsub('"', '\\"') 188 | :gsub("'", "\\'") 189 | :gsub("\n", "\\n") 190 | :gsub("([$`!])", "\\%1") 191 | local url = "https://api.openai.com/v1/chat/completions" 192 | local headers = '-H "Content-Type: application/json" -H "Authorization: Bearer ' .. self.key .. '"' 193 | local cmd = 'curl -s ' .. headers .. ' -d "{\'model\': \'gpt-4\', \'messages\':[{\'role\':\'user\', \'content\':\'' .. prompt .. '\'}], \'temprature\':0.7}" "' .. url .. '"' 194 | local json = shell:output(cmd) 195 | 196 | -- Find the `content` field within the JSON string 197 | local text_start, text_end = json:find('"content"%s*:%s*"') 198 | if not text_start then 199 | return nil, "Could not find 'content' field" 200 | end 201 | 202 | -- Extract the substring containing the text value 203 | local text_value_start = text_end + 1 204 | local text_value_end = json:find('"', text_value_start) 205 | while text_value_end do 206 | -- Check if the quote is escaped 207 | if json:sub(text_value_end - 1, text_value_end - 1) ~= "\\" then 208 | break 209 | end 210 | -- Continue searching for the ending quote 211 | text_value_end = json:find('"', text_value_end + 1) 212 | end 213 | 214 | if not text_value_end then 215 | return nil, "Unterminated 'content' field" 216 | end 217 | 218 | -- Extract the raw text value and unescape escaped quotes 219 | local text = json:sub(text_value_start, text_value_end - 1) 220 | text = text:gsub('\\"', '"'):gsub('\\\\', '\\') 221 | text = text:gsub("\\n", "\n") 222 | 223 | text = text:match("%(OX START%)(.-)%(OX END%)") 224 | text = text:gsub("\n+$", "\n") 225 | text = text:gsub("^\n+", "\n") 226 | 227 | -- Convert any weird unicode stuff into their actual characters 228 | text = text:gsub("\\u(%x%x%x%x)", function(hex) 229 | local codepoint = tonumber(hex, 16) -- Convert hex to a number 230 | return utf8.char(codepoint) -- Convert number to a UTF-8 character 231 | end) 232 | 233 | editor:display_info("Request processed!") 234 | return text 235 | end 236 | 237 | -- Send prompt to Anthropic Claude 238 | function ai:send_to_claude(prompt) 239 | if self.key ~= nil then 240 | editor:display_info("Please wait while your request is processed...") 241 | editor:rerender() 242 | else 243 | editor:display_error("Please specify an API key in your configuration file") 244 | editor:rerender() 245 | return 246 | end 247 | prompt = prompt:gsub("\\", "\\\\") 248 | :gsub('"', '\\"') 249 | :gsub("'", "\\'") 250 | :gsub("\n", "\\n") 251 | :gsub("([$`!])", "\\%1") 252 | local url = "https://api.anthropic.com/v1/messages" 253 | local headers = '-H "Content-Type: application/json" -H "x-api-key: ' .. self.key .. '"' 254 | local cmd = 'curl -s ' .. headers .. ' -d "{\'model\': \'claude-3-5-sonnet-20241022\', \'messages\':[{\'role\':\'user\', \'content\':\'' .. prompt .. '\'}]}" "' .. url .. '"' 255 | local json = shell:output(cmd) 256 | 257 | -- Find the `text` field within the JSON string 258 | local text_start, text_end = json:find('"text"%s*:%s*"') 259 | if not text_start then 260 | return nil, "Could not find 'text' field" 261 | end 262 | 263 | -- Extract the substring containing the text value 264 | local text_value_start = text_end + 1 265 | local text_value_end = json:find('"', text_value_start) 266 | while text_value_end do 267 | -- Check if the quote is escaped 268 | if json:sub(text_value_end - 1, text_value_end - 1) ~= "\\" then 269 | break 270 | end 271 | -- Continue searching for the ending quote 272 | text_value_end = json:find('"', text_value_end + 1) 273 | end 274 | 275 | if not text_value_end then 276 | return nil, "Unterminated 'text' field" 277 | end 278 | 279 | -- Extract the raw text value and unescape escaped quotes 280 | local text = json:sub(text_value_start, text_value_end - 1) 281 | text = text:gsub('\\"', '"'):gsub('\\\\', '\\') 282 | text = text:gsub("\\n", "\n") 283 | 284 | text = text:match("%(OX START%)(.-)%(OX END%)") 285 | text = text:gsub("\n+$", "\n") 286 | text = text:gsub("^\n+", "\n") 287 | 288 | -- Convert any weird unicode stuff into their actual characters 289 | text = text:gsub("\\u(%x%x%x%x)", function(hex) 290 | local codepoint = tonumber(hex, 16) -- Convert hex to a number 291 | return utf8.char(codepoint) -- Convert number to a UTF-8 character 292 | end) 293 | 294 | editor:display_info("Request processed!") 295 | return text 296 | end 297 | -------------------------------------------------------------------------------- /plugins/autoindent.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Auto Indent v0.13 3 | 4 | Helps you when programming by guessing where indentation should go 5 | and then automatically applying these guesses as you program 6 | ]]-- 7 | 8 | autoindent = {} 9 | 10 | -- Determine if a line starts with a certain string 11 | function autoindent:starts(y, starting) 12 | local line = editor:get_line_at(y) 13 | return line:match("^" .. starting) 14 | end 15 | 16 | -- Determine if a line ends with a certain string 17 | function autoindent:ends(y, ending) 18 | local line = editor:get_line_at(y) 19 | return line:match(ending .. "$") 20 | end 21 | 22 | -- Determine if the line causes an indent when return is pressed on the end 23 | function autoindent:causes_indent(y) 24 | -- Always indent on open brackets 25 | local is_bracket = self:ends(y, "%{") or self:ends(y, "%[") or self:ends(y, "%(") 26 | if is_bracket then return true end 27 | -- Language specific additions 28 | if editor.document_type == "Python" then 29 | if self:ends(y, ":") then return true end 30 | elseif editor.document_type == "Ruby" then 31 | if self:ends(y, "do") then return true end 32 | elseif editor.document_type == "Lua" then 33 | local func = self:ends(y, "%)") and (self:starts(y, "function") or self:starts(y, "local function")) 34 | local func = func or self:ends(y, "function%([^)]*%)") 35 | if self:ends(y, "else") or self:ends(y, "do") or self:ends(y, "then") or func then return true end 36 | elseif editor.document_type == "Haskell" then 37 | if self:ends(y, "where") or self:ends(y, "let") or self:ends(y, "do") then return true end 38 | elseif editor.document_type == "Shell" then 39 | if self:ends(y, "then") or self:ends(y, "do") then return true end 40 | end 41 | return false 42 | end 43 | 44 | -- Determine if the line causes a dedent as soon as the pattern matches 45 | function autoindent:causes_dedent(y) 46 | -- Always dedent after closing brackets 47 | local is_bracket = self:starts(y, "%s*%}") or self:starts(y, "%s*%]") or self:starts(y, "%s*%)") 48 | if is_bracket then return true end 49 | -- Check the line for token giveaways 50 | if editor.document_type == "Shell" then 51 | local end1 = self:starts(y, "%s*fi") or self:starts(y, "%s*done") or self:starts(y, "%s*esac") 52 | local end2 = self:starts(y, "%s*elif") or self:starts(y, "%s*else") or self:starts(y, "%s*;;") 53 | if end1 or end2 then return true end 54 | elseif editor.document_type == "Python" then 55 | local end1 = self:starts(y, "%s*else") or self:starts(y, "%s*elif") 56 | local end2 = self:starts(y, "%s*except") or self:starts(y, "%s*finally") 57 | if end1 or end2 then return true end 58 | elseif editor.document_type == "Ruby" then 59 | local end1 = self:starts(y, "%s*end") or self:starts(y, "%s*else") or self:starts(y, "%s*elseif") 60 | local end2 = self:starts(y, "%s*ensure") or self:starts(y, "%s*rescue") or self:starts(y, "%s*when") 61 | if end1 or end2 or self:starts(y, "%s*;;") then return true end 62 | elseif editor.document_type == "Lua" then 63 | local end1 = self:starts(y, "%s*end") or self:starts(y, "%s*else") 64 | local end2 = self:starts(y, "%s*elseif") or self:starts(y, "%s*until") 65 | if end1 or end2 then return true end 66 | elseif editor.document_type == "Haskell" then 67 | if self:starts(y, "%s*else") or self:starts(y, "%s*in") then return true end 68 | end 69 | return false 70 | end 71 | 72 | -- Set an indent at a certain y index 73 | function autoindent:set_indent(y, new_indent) 74 | -- Handle awkward scenarios 75 | if new_indent < 0 then return end 76 | -- Find the indent of the line at the moment 77 | local line = editor:get_line_at(y) 78 | local current = autoindent:get_indent(y) 79 | -- Work out how much to change and what to change 80 | local indent_change = new_indent - current 81 | local tabs = line:match("^\t") ~= nil 82 | -- Prepare to form the new line contents 83 | local new_line = nil 84 | -- Work out if adding or removing 85 | local x = editor.cursor.x 86 | if indent_change > 0 then 87 | -- Insert indentation 88 | if tabs then 89 | -- Insert Tabs 90 | x = x + indent_change 91 | new_line = string.rep("\t", indent_change) .. line 92 | else 93 | -- Insert Spaces 94 | x = x + indent_change * document.tab_width 95 | new_line = string.rep(" ", indent_change * document.tab_width) .. line 96 | end 97 | elseif indent_change < 0 then 98 | -- Remove indentation 99 | if tabs then 100 | -- Remove Tabs 101 | x = x - -indent_change 102 | new_line = line:gsub("\t", "", -indent_change) 103 | else 104 | -- Remove Spaces 105 | x = x - -indent_change * document.tab_width 106 | new_line = line:gsub(string.rep(" ", document.tab_width), "", -indent_change) 107 | end 108 | else 109 | return 110 | end 111 | -- Perform the substitution with the new line 112 | editor:insert_line_at(new_line, y) 113 | editor:remove_line_at(y + 1) 114 | -- Place the cursor at a sensible position 115 | if x < 0 then x = 0 end 116 | editor:move_to(x, y) 117 | editor:cursor_snap() 118 | end 119 | 120 | -- Get how indented a line is at a certain y index 121 | function autoindent:get_indent(y) 122 | if y == nil then return nil end 123 | local line = editor:get_line_at(y) 124 | return #(line:match("^\t+") or "") + #(line:match("^ +") or "") / document.tab_width 125 | end 126 | 127 | -- Utilties for when moving lines around 128 | function autoindent:fix_indent() 129 | -- Check the indentation of the line above this one (and match the line the cursor is currently on) 130 | local indents_above = autoindent:get_indent(editor.cursor.y - 1) 131 | local indents_below = autoindent:get_indent(editor.cursor.y + 1) 132 | local new_indent = nil 133 | if editor.cursor.y == 1 then 134 | -- Always remove all indent when on the first line 135 | new_indent = 0 136 | elseif indents_below >= indents_above then 137 | new_indent = indents_below 138 | else 139 | new_indent = indents_above 140 | end 141 | -- Give a boost when entering an empty block 142 | local indenting_above = autoindent:causes_indent(editor.cursor.y - 1) 143 | local dedenting_below = autoindent:causes_dedent(editor.cursor.y + 1) 144 | if indenting_above and dedenting_below then 145 | new_indent = new_indent + 1 146 | end 147 | -- Set the indent 148 | autoindent:set_indent(editor.cursor.y, new_indent) 149 | end 150 | 151 | -- Handle the case where the enter key is pressed between two brackets 152 | function autoindent:disperse_block() 153 | local indenting_above = autoindent:causes_indent(editor.cursor.y - 1) 154 | local current_dedenting = autoindent:causes_dedent(editor.cursor.y) 155 | if indenting_above and current_dedenting then 156 | local starting_indent = autoindent:get_indent(editor.cursor.y - 1) 157 | local old_cursor = editor.cursor 158 | editor:insert_line() 159 | autoindent:set_indent(editor.cursor.y, starting_indent) 160 | editor:move_to(old_cursor.x, old_cursor.y) 161 | end 162 | end 163 | 164 | event_mapping["enter"] = function() 165 | if editor.cursor ~= nil then 166 | -- Indent where appropriate 167 | if autoindent:causes_indent(editor.cursor.y - 1) then 168 | local new_level = autoindent:get_indent(editor.cursor.y) + 1 169 | autoindent:set_indent(editor.cursor.y, new_level) 170 | end 171 | -- Give newly created line a boost to match it up relatively with the line before it 172 | local added_level = autoindent:get_indent(editor.cursor.y) + autoindent:get_indent(editor.cursor.y - 1) 173 | autoindent:set_indent(editor.cursor.y, added_level) 174 | -- Handle the case where enter is pressed, creating a multi-line block that requires neatening up 175 | autoindent:disperse_block() 176 | end 177 | end 178 | 179 | -- For each ascii characters and punctuation 180 | was_dedenting = false 181 | for i = 32, 126 do 182 | local char = string.char(i) 183 | -- ... excluding the global event binding 184 | if char ~= "*" then 185 | -- Keep track of whether the line was previously dedenting beforehand 186 | event_mapping["before:" .. char] = function() 187 | if editor.cursor ~= nil then 188 | was_dedenting = autoindent:causes_dedent(editor.cursor.y) 189 | end 190 | end 191 | -- Trigger dedent checking 192 | event_mapping[char] = function() 193 | -- Dedent where appropriate 194 | if editor.cursor ~= nil then 195 | if autoindent:causes_dedent(editor.cursor.y) and not was_dedenting then 196 | local new_level = autoindent:get_indent(editor.cursor.y) - 1 197 | autoindent:set_indent(editor.cursor.y, new_level) 198 | end 199 | end 200 | end 201 | end 202 | end 203 | 204 | function dedent_amount(y) 205 | local tabs = editor:get_line_at(y):match("^\t") ~= nil 206 | if tabs then 207 | return 1 208 | else 209 | return document.tab_width 210 | end 211 | end 212 | 213 | -- Shortcut to indent a selection 214 | event_mapping["ctrl_tab"] = function() 215 | if editor.cursor ~= nil then 216 | local cursor = editor.cursor 217 | local select = editor.selection 218 | if cursor.y == select.y then 219 | -- Single line is selected 220 | local level = autoindent:get_indent(cursor.y) 221 | autoindent:set_indent(cursor.y, level + 1) 222 | else 223 | -- Multiple lines selected 224 | if cursor.y > select.y then 225 | for line = select.y, cursor.y do 226 | editor:move_to(0, line) 227 | local indent = autoindent:get_indent(line) 228 | autoindent:set_indent(line, indent + 1) 229 | end 230 | else 231 | for line = cursor.y, select.y do 232 | editor:move_to(0, line) 233 | local indent = autoindent:get_indent(line) 234 | autoindent:set_indent(line, indent + 1) 235 | end 236 | end 237 | local cursor_tabs = dedent_amount(cursor.y) 238 | local select_tabs = dedent_amount(select.y) 239 | editor:move_to(cursor.x + cursor_tabs, cursor.y) 240 | editor:select_to(select.x + select_tabs, select.y) 241 | end 242 | editor:cursor_snap() 243 | end 244 | end 245 | 246 | -- Shortcut to dedent a line 247 | event_mapping["shift_tab"] = function() 248 | if editor.cursor ~= nil then 249 | local cursor = editor.cursor 250 | local select = editor.selection 251 | if cursor.x == select.x and cursor.y == select.y then 252 | -- Dedent a single line 253 | local level = autoindent:get_indent(editor.cursor.y) 254 | autoindent:set_indent(editor.cursor.y, level - 1) 255 | else 256 | -- Dedent a group of lines 257 | if cursor.y > select.y then 258 | for line = select.y, cursor.y do 259 | editor:move_to(0, line) 260 | local indent = autoindent:get_indent(line) 261 | autoindent:set_indent(line, indent - 1) 262 | end 263 | else 264 | for line = cursor.y, select.y do 265 | editor:move_to(0, line) 266 | local indent = autoindent:get_indent(line) 267 | autoindent:set_indent(line, indent - 1) 268 | end 269 | end 270 | local cursor_tabs = dedent_amount(cursor.y) 271 | local select_tabs = dedent_amount(select.y) 272 | editor:move_to(cursor.x - cursor_tabs, cursor.y) 273 | editor:select_to(select.x - select_tabs, select.y) 274 | end 275 | editor:cursor_snap() 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /plugins/discord_rpc.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Discord RPC v0.2 3 | 4 | For showing your use of the Ox editor to other users on Discord 5 | ]]-- 6 | 7 | -- Verify whether the dependencies are installed 8 | discord_rpc = { 9 | has_python = python_interop:installation() ~= nil, 10 | has_discord_rpc_module = python_interop:has_module("discordrpc"), 11 | pid = nil, 12 | doc = "", 13 | } 14 | 15 | function discord_rpc:ready() 16 | return self.has_python and self.has_discord_rpc_module 17 | end 18 | 19 | function discord_rpc:show_rpc() 20 | if not self:ready() then 21 | editor:display_error("Discord RPC: missing python or discord-rpc python module") 22 | editor:rerender_feedback_line() 23 | else 24 | -- Spawn an rpc process 25 | local name = editor.file_name or "Untitled" 26 | local kind = string.lower(editor.document_type:gsub("%+", "p"):gsub("#", "s"):gsub(" ", "_")) 27 | local code = drpc:gsub("\n", "; ") 28 | local command = string.format("python -c \"%s\" 'Ox' 'Editing %s' '%s'", code, name, kind) 29 | self.pid = shell:spawn(command) 30 | end 31 | end 32 | 33 | function run_discord_rpc() 34 | discord_rpc:show_rpc() 35 | end 36 | 37 | function kill_discord_rpc() 38 | shell:kill(discord_rpc.pid) 39 | end 40 | 41 | function check_discord_rpc() 42 | -- Detect change in document 43 | if discord_rpc.doc ~= editor.file_path then 44 | -- Reload the rpc 45 | kill_discord_rpc() 46 | discord_rpc.doc = editor.file_path 47 | after(0, "run_discord_rpc") 48 | end 49 | end 50 | 51 | every(5, "check_discord_rpc") 52 | after(0, "check_discord_rpc") 53 | 54 | event_mapping["exit"] = function() 55 | -- Kill the rpc process 56 | kill_discord_rpc() 57 | end 58 | 59 | drpc = [[ 60 | import discordrpc 61 | import sys 62 | args = sys.argv[1:] 63 | rpc = discordrpc.RPC(app_id=1294981983146868807, output=False) 64 | rpc.set_activity(state=args[0], details=args[1], small_image=args[2]) 65 | rpc.run() 66 | ]] 67 | -------------------------------------------------------------------------------- /plugins/emmet.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Emmet v0.4 3 | 4 | Implementation of Emmet for Ox for rapid web development 5 | ]]-- 6 | 7 | -- Verify whether the dependencies are installed 8 | emmet = { 9 | has_python = python_interop:installation() ~= nil, 10 | has_emmet_module = python_interop:has_module("emmet"), 11 | } 12 | 13 | function emmet:ready() 14 | return self.has_python and self.has_emmet_module 15 | end 16 | 17 | function emmet:expand() 18 | -- Get the emmet code 19 | local unexpanded = editor:get_line() 20 | unexpanded = unexpanded:gsub("^%s+", "") 21 | unexpanded = unexpanded:gsub("%s+$", "") 22 | -- Request the expanded equivalent 23 | local command = string.format("python %s/oxemmet.py \"%s\"", plugin_path, unexpanded) 24 | local expanded = shell:output(command) 25 | expanded = expanded:gsub("\n$", "") 26 | -- Keep track of the level of indentation 27 | local indent_level = autoindent:get_indent(editor.cursor.y) 28 | -- Delete the existing line 29 | editor:remove_line_at(editor.cursor.y) 30 | editor:insert_line_at("", editor.cursor.y) 31 | local old_cursor = editor.cursor 32 | -- Insert the expanded equivalent 33 | local lines = {} 34 | for line in expanded:gmatch("[^\r\n]+") do 35 | table.insert(lines, line) 36 | end 37 | for i, line in ipairs(lines) do 38 | -- Ensure correct indentation 39 | autoindent:set_indent(editor.cursor.y, indent_level) 40 | old_cursor.x = editor.cursor.x 41 | -- Insert rest of line 42 | editor:insert(line) 43 | -- Press return 44 | if i ~= #lines then 45 | editor:insert_line() 46 | end 47 | end 48 | -- Move to suggested cursor position 49 | editor:move_to(old_cursor.x, old_cursor.y) 50 | editor:move_next_match("\\|") 51 | editor:remove_at(editor.cursor.x, editor.cursor.y) 52 | end 53 | 54 | event_mapping["ctrl_m"] = function() 55 | if emmet:ready() then 56 | emmet:expand() 57 | else 58 | editor:display_error("Emmet: can't find python or py-emmet module") 59 | end 60 | end 61 | 62 | emmet_expand = [[ 63 | import emmet 64 | import sys 65 | import re 66 | 67 | def place_cursor(expansion): 68 | def find_cursor_index(pattern, attribute): 69 | try: 70 | match = re.search(pattern, expansion) 71 | if match: 72 | attr_start = match.start() + expansion[match.start():].index(attribute) + len(attribute) + 1 73 | return attr_start + len(match.group(1)) + 1 74 | except IndexError: 75 | pass 76 | return None 77 | if expansion.split('\n')[0].lower().startswith(""): 78 | match = re.search(r"]*>(.*?)", expansion, re.DOTALL) 79 | if match: 80 | before_body = match.start(1) 81 | after_body = match.end(1) 82 | mid_point = (before_body + after_body) // 2 83 | return mid_point 84 | return None 85 | a_match = find_cursor_index(r']*href="()">', 'href') 86 | img_match = find_cursor_index(r']*src="()"[^>]*>', 'src') 87 | input_match = find_cursor_index(r']*type="()"[^>]*>', 'type') 88 | label_match = find_cursor_index(r']*for="()"[^>]*>', 'for') 89 | form_match = find_cursor_index(r']*action="()"[^>]*>', 'action') 90 | empty_tag_match = re.search(r"<([a-zA-Z0-9]+)([^>]*)>", expansion) 91 | if empty_tag_match is not None: 92 | empty_tag_match = empty_tag_match.end(2) + 1 93 | alone_tags = [a_match, img_match, input_match, label_match, form_match, empty_tag_match] 94 | try: 95 | best_alone = min(filter(lambda x: x is not None, alone_tags)) 96 | return best_alone 97 | except ValueError: 98 | return 0 99 | contents = sys.argv[1] 100 | expansion = emmet.expand(contents) 101 | cursor_loc = place_cursor(expansion) 102 | expansion = expansion[:cursor_loc] + "|" + expansion[cursor_loc:] 103 | print(expansion) 104 | ]] 105 | 106 | -- Write the emmet script if not already there 107 | if not file_exists(plugin_path .. "/oxemmet.py") then 108 | local file = io.open(plugin_path .. "/oxemmet.py", "w") 109 | file:write(emmet_expand) 110 | file:close() 111 | end 112 | -------------------------------------------------------------------------------- /plugins/git.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Git v0.5 3 | 4 | A plug-in for git integration that provides features to: 5 | - Choose which files to add to a commit 6 | - Do a commit 7 | - Push local commits 8 | - View diffs 9 | - See which branch you are on and checkout other branches 10 | - Pull any changes upstream 11 | ]]-- 12 | 13 | git = { 14 | status = {}, 15 | branch = "", 16 | icons = (git or { icons = false }).icons, 17 | has_git = shell:output("git --version"):find("git version"), 18 | last_update = nil, 19 | } 20 | 21 | function git:ready() 22 | return self.has_git 23 | end 24 | 25 | function git:repo_path() 26 | local repo_path_output = shell:output("git rev-parse --show-toplevel") 27 | return repo_path_output:gsub("[\r\n]+", "") 28 | end 29 | 30 | function git:refresh_status() 31 | local duration_since_update = os.time(os.date("*t")) - os.time(self.last_update) 32 | -- Only do a refresh every 10 seconds maximum 33 | if self.last_update == nil or duration_since_update > 10 then 34 | self.branch = shell:output("git rev-parse --abbrev-ref HEAD") 35 | local repo_path = self:repo_path() 36 | local status_output = shell:output("git status --porcelain") 37 | local status = {} 38 | for line in status_output:gmatch("[^\r\n]+") do 39 | local staged_status = line:sub(1, 1) 40 | local unstaged_status = line:sub(2, 2) 41 | local file_name = repo_path .. "/" .. line:sub(4) 42 | local staged 43 | local modified 44 | if self.icons then 45 | staged = "󰸩 " 46 | modified = "󱇨 " 47 | else 48 | staged = "S" 49 | modified = "M" 50 | end 51 | -- M = modified, S = staged 52 | if staged_status ~= " " and staged_status ~= "?" then 53 | status[file_name] = staged 54 | elseif unstaged_status ~= " " or unstaged_status == "?" then 55 | status[file_name] = modified 56 | end 57 | end 58 | self.status = status 59 | self.last_update = os.date("*t") 60 | end 61 | end 62 | 63 | function git:get_stats() 64 | local result = shell:output("git diff --stat") 65 | 66 | local files = {} 67 | local total_insertions = 0 68 | local total_deletions = 0 69 | 70 | for line in result:gmatch("[^\r\n]+") do 71 | local file, changes = line:match("(%S+)%s+|%s+(%d+)") 72 | if file ~= nil then 73 | local insertions = select(2, line:gsub("%+", "")) 74 | local deletions = select(2, line:gsub("%-", "")) 75 | table.insert(files, { file = file, insertions = insertions, deletions = deletions }) 76 | total_insertions = total_insertions + insertions 77 | total_deletions = total_deletions + deletions 78 | end 79 | end 80 | 81 | return { 82 | files = files, 83 | total_insertions = total_insertions, 84 | total_deletions = total_deletions 85 | } 86 | end 87 | 88 | function git:diff(file) 89 | return shell:output("git diff " .. file) 90 | end 91 | 92 | function git:diff_all() 93 | local repo_path = git:repo_path() 94 | return shell:output("git diff " .. repo_path) 95 | end 96 | 97 | function git_branch() 98 | git:refresh_status() 99 | if git.branch == "" or git.branch:match("fatal") then 100 | return "N/A" 101 | else 102 | return git.branch:gsub("[\r\n]+", "") 103 | end 104 | end 105 | 106 | function git_status(tab) 107 | for file, state in pairs(git.status) do 108 | if file == tab then 109 | if state ~= nil then 110 | return state 111 | end 112 | end 113 | end 114 | if git.icons then 115 | return "󰈤 " 116 | else 117 | return "U" 118 | end 119 | end 120 | 121 | function git_init() 122 | git:refresh_status() 123 | editor:rerender() 124 | end 125 | 126 | -- Initial status grab 127 | after(0, "git_init") 128 | 129 | -- When the user saves a document, force a refresh 130 | event_mapping["ctrl_s"] = function() 131 | git.last_update = nil 132 | git:refresh_status() 133 | end 134 | 135 | -- Export the git command 136 | commands["git"] = function(args) 137 | -- Check if git is installed 138 | if not git:ready() then 139 | editor:display_error("Git: git installation not found") 140 | else 141 | local repo_path = git:repo_path() 142 | if args[1] == "commit" then 143 | local message = editor:prompt("Message") 144 | editor:display_info("Committing with message: " .. message) 145 | if shell:run('git commit -S -m "' .. message .. '"') ~= 0 then 146 | editor:display_error("Failed to commit") 147 | end 148 | editor:reset_terminal() 149 | elseif args[1] == "push" then 150 | if shell:run('git push') ~= 0 then 151 | editor:display_error("Failed to push") 152 | end 153 | elseif args[1] == "pull" then 154 | if shell:run('git pull') ~= 0 then 155 | editor:display_error("Failed to pull") 156 | end 157 | elseif args[1] == "add" and args[2] == "all" then 158 | if shell:run('git add ' .. repo_path) ~= 0 then 159 | editor:display_error("Failed to add all files") 160 | end 161 | elseif args[1] == "add" then 162 | if shell:run('git add ' .. editor.file_path) ~= 0 then 163 | editor:display_error("Failed to add file") 164 | end 165 | elseif args[1] == "reset" and args[2] == "all" then 166 | if shell:run('git reset ' .. repo_path) ~= 0 then 167 | editor:display_error("Failed to unstage all files") 168 | end 169 | elseif args[1] == "reset" then 170 | if shell:run('git reset ' .. editor.file_path) ~= 0 then 171 | editor:display_error("Failed to unstage file") 172 | end 173 | elseif args[1] == "stat" and args[2] == "all" then 174 | local stats = git:get_stats() 175 | editor:display_info(string.format( 176 | "%d files changed: %s insertions, %s deletions", 177 | #stats.files, stats.total_insertions, stats.total_deletions 178 | )) 179 | elseif args[1] == "stat" then 180 | local stats = git:get_stats() 181 | for _, t in ipairs(stats.files) do 182 | if repo_path .. "/" .. t.file == editor.file_path then 183 | editor:display_info(string.format( 184 | "%s: %s insertions, %s deletions", 185 | t.file, t.insertions, t.deletions 186 | )) 187 | end 188 | end 189 | elseif args[1] == "diff" and args[2] == "all" then 190 | local diff = git:diff_all() 191 | editor:new() 192 | editor:insert(diff) 193 | editor:set_file_type("Diff") 194 | editor:set_read_only(true) 195 | editor:move_top() 196 | elseif args[1] == "diff" then 197 | local diff = git:diff(editor.file_path) 198 | editor:new() 199 | editor:insert(diff) 200 | editor:set_file_type("Diff") 201 | editor:set_read_only(true) 202 | editor:move_top() 203 | elseif args[1] == "checkout" then 204 | local branch = args[2] 205 | if shell:run("git checkout " .. branch) ~= 0 then 206 | editor:display_error("Failed to checkout branch '" .. branch .. "'") 207 | end 208 | end 209 | -- Refresh state after a git command 210 | git.last_update = nil 211 | git:refresh_status() 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /plugins/live_html.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Live HTML v0.2 3 | 4 | As you develop a website, you can view it in your browser without needing to refresh with every change 5 | ]]-- 6 | 7 | live_html = { 8 | has_python = python_interop:installation() ~= nil, 9 | has_flask_module = python_interop:has_module("flask"), 10 | entry_point = nil, 11 | tracking = {}, 12 | pid = nil, 13 | last_request = "", 14 | refresh_when = (live_html or { refresh_when = "save" }).refresh_when, 15 | } 16 | 17 | function live_html:ready() 18 | return self.has_python and self.has_flask_module 19 | end 20 | 21 | function live_html:start() 22 | -- Start up flask server 23 | live_html.entry_point = editor.file_path 24 | local command = string.format("python %s/livehtml.py '%s'", plugin_path, editor.file_path) 25 | self.pid = shell:spawn(command) 26 | -- Notify user of location 27 | editor:display_info("Running server on http://localhost:5000") 28 | end 29 | 30 | function live_html:stop() 31 | shell:kill(self.pid) 32 | self.entry_point = nil 33 | self.pid = nil 34 | end 35 | 36 | function live_html_refresh() 37 | local tracked_file_changed = false 38 | for _, v in ipairs(live_html.tracking) do 39 | if v == editor.file_name then 40 | tracked_file_changed = true 41 | break 42 | end 43 | end 44 | if editor.file_path == live_html.entry_point then 45 | local contents = editor:get():gsub('"', '\\"'):gsub("\n", ""):gsub("`", "\\`") 46 | live_html.last_request = contents 47 | http.post("localhost:5000/update", contents) 48 | elseif tracked_file_changed then 49 | http.post("localhost:5000/forceupdate", live_html.last_request) 50 | end 51 | end 52 | 53 | commands["html"] = function(args) 54 | -- Check dependencies 55 | if live_html:ready() then 56 | if args[1] == "start" then 57 | -- Prevent duplicate server 58 | live_html:stop() 59 | -- Run the server 60 | live_html:start() 61 | after(5, "live_html_refresh") 62 | elseif args[1] == "stop" then 63 | live_html:stop() 64 | elseif args[1] == "track" then 65 | local file = args[2] 66 | table.insert(live_html.tracking, file) 67 | editor:display_info("Now tracking file " .. file) 68 | end 69 | else 70 | editor:display_error("Live HTML: python or flask module not found") 71 | end 72 | end 73 | 74 | event_mapping["*"] = function() 75 | if live_html.pid ~= nil and live_html.refresh_when == "keypress" then 76 | after(1, "live_html_refresh") 77 | end 78 | end 79 | 80 | event_mapping["ctrl_s"] = function() 81 | if live_html.pid ~= nil and live_html.refresh_when == "save" then 82 | after(1, "live_html_refresh") 83 | end 84 | end 85 | 86 | event_mapping["exit"] = function() 87 | live_html:stop() 88 | end 89 | 90 | -- Code for creating a server to load code 91 | live_html_start = [[ 92 | from flask import Flask, request, render_template_string, redirect, url_for, Response, send_from_directory 93 | import logging 94 | import queue 95 | import time 96 | import sys 97 | import os 98 | 99 | try: 100 | os.chdir(os.path.dirname(sys.argv[1])) 101 | except: 102 | pass 103 | 104 | app = Flask(__name__) 105 | 106 | log = logging.getLogger('werkzeug') 107 | log.disabled = True 108 | 109 | # HTML code stored in a variable 110 | reload_script = """ 111 | 120 | """ 121 | 122 | html_content = """ 123 | 155 | 156 | 157 |

Welcome to Ox Live HTML Edit

158 |

Please wait whilst we load your website...

159 |
160 | 161 | """ 162 | 163 | # A list to keep track of clients that are connected 164 | clients = [] 165 | 166 | # Function to notify all clients to reload 167 | def notify_clients(): 168 | for client in clients: 169 | print("Reloading a client...") 170 | try: 171 | client.put("reload") 172 | except: 173 | clients.remove(client) # Remove any disconnected clients 174 | 175 | @app.route('/') 176 | def serve_html(): 177 | # Render the HTML stored in the variable 178 | return render_template_string(reload_script + html_content) 179 | 180 | @app.route('/update', methods=['POST']) 181 | def update_html(): 182 | global html_content 183 | # Get the new HTML content from the POST request 184 | new_code = request.get_data().decode('utf-8') 185 | if new_code and new_code != html_content: 186 | # Update the HTML content with the new code 187 | html_content = new_code 188 | notify_clients() # Notify all clients to reload 189 | # Return a 200 status on successful update 190 | return "Update successful", 200 191 | 192 | @app.route('/forceupdate', methods=['POST']) 193 | def force_update_html(): 194 | global html_content 195 | # Get the new HTML content from the POST request 196 | new_code = request.get_data().decode('utf-8') 197 | # Update the HTML content with the new code 198 | html_content = new_code 199 | notify_clients() # Notify all clients to reload 200 | # Return a 200 status on successful update 201 | return "Update successful", 200 202 | 203 | @app.route('/reload') 204 | def reload(): 205 | def stream(): 206 | client = queue.Queue() 207 | clients.append(client) 208 | try: 209 | while True: 210 | msg = client.get() 211 | yield f"data: {msg}\n\n" 212 | except GeneratorExit: # Disconnected client 213 | clients.remove(client) 214 | 215 | return Response(stream(), content_type='text/event-stream') 216 | 217 | @app.route('/', methods=['GET']) 218 | def serve_file(filename): 219 | # Serve a specific file from the current working directory 220 | return send_from_directory(os.getcwd(), filename) 221 | 222 | if __name__ == "__main__": 223 | app.run(debug=False, threaded=True) 224 | ]] 225 | 226 | -- Write the livehtml script if not already there 227 | if not file_exists(plugin_path .. "/livehtml.py") then 228 | local file = io.open(plugin_path .. "/livehtml.py", "w") 229 | file:write(live_html_start) 230 | file:close() 231 | end 232 | -------------------------------------------------------------------------------- /plugins/pairs.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Bracket Pairs v0.7 3 | 4 | Automatically insert and delete brackets and quotes where appropriate 5 | Also helps when you want to pad out brackets and quotes with whitespace 6 | ]]-- 7 | 8 | autopairs = {} 9 | 10 | -- The following pairs are in the form [start of pair][end of pair] 11 | autopairs.pairings = { 12 | -- Bracket pairs 13 | "()", "[]", "{}", 14 | -- Quote pairs 15 | '""', "''", "``", 16 | } 17 | 18 | autopairs.just_paired = { x = nil, y = nil } 19 | 20 | -- Determine whether we are currently inside a pair 21 | function autopairs:in_pair() 22 | if editor.cursor == nil then return false end 23 | -- Get first candidate for a pair 24 | local first 25 | if editor.cursor.x == 0 then 26 | first = "" 27 | else 28 | first = editor:get_character_at(editor.cursor.x - 1, editor.cursor.y) 29 | end 30 | -- Get second candidate for a pair 31 | local second = editor:get_character_at(editor.cursor.x, editor.cursor.y) 32 | -- See if there are any matches 33 | local potential_pair = first .. second 34 | for _, v in ipairs(autopairs.pairings) do 35 | if v == potential_pair then 36 | return true 37 | end 38 | end 39 | return false 40 | end 41 | 42 | -- Automatically delete end pair if user deletes corresponding start pair 43 | event_mapping["before:backspace"] = function() 44 | if autopairs:in_pair() then 45 | editor:remove_at(editor.cursor.x, editor.cursor.y) 46 | end 47 | end 48 | 49 | -- Automatically insert an extra space if the user presses space between pairs 50 | event_mapping["before:space"] = function() 51 | if autopairs:in_pair() then 52 | editor:insert(" ") 53 | editor:move_left() 54 | end 55 | end 56 | 57 | -- Link up pairs to event mapping 58 | for i, str in ipairs(autopairs.pairings) do 59 | local start_pair = string.sub(str, 1, 1) 60 | local end_pair = string.sub(str, 2, 2) 61 | -- Determine which implementation to use 62 | if start_pair == end_pair then 63 | -- Handle hybrid start_pair and end_pair 64 | event_mapping[start_pair] = function() 65 | if editor.cursor == nil then return end 66 | -- Check if there is a matching start pair 67 | local at_char = ' ' 68 | if editor.cursor.x > 1 then 69 | at_char = editor:get_character_at(editor.cursor.x - 2, editor.cursor.y) 70 | end 71 | local potential_dupe = at_char == start_pair 72 | -- Check if we're at the site of the last pairing 73 | local at_immediate_pair_x = autopairs.just_paired.x == editor.cursor.x - 1 74 | local at_immediate_pair_y = autopairs.just_paired.y == editor.cursor.y 75 | local at_immediate_pair = at_immediate_pair_x and at_immediate_pair_y 76 | if potential_dupe and at_immediate_pair then 77 | -- User just tried to add a closing pair despite us doing it for them! 78 | -- Undo it for them 79 | editor:remove_at(editor.cursor.x - 1, editor.cursor.y) 80 | autopairs.just_paired = { x = nil, y = nil } 81 | else 82 | autopairs.just_paired = editor.cursor 83 | editor:insert(end_pair) 84 | editor:move_left() 85 | end 86 | end 87 | else 88 | -- Handle traditional pairs 89 | event_mapping[end_pair] = function() 90 | if editor.cursor == nil then return end 91 | -- Check if there is a matching start pair 92 | local at_char = editor:get_character_at(editor.cursor.x - 2, editor.cursor.y) 93 | local potential_dupe = at_char == start_pair 94 | -- Check if we're at the site of the last pairing 95 | local at_immediate_pair_x = autopairs.just_paired.x == editor.cursor.x - 1 96 | local at_immediate_pair_y = autopairs.just_paired.y == editor.cursor.y 97 | local at_immediate_pair = at_immediate_pair_x and at_immediate_pair_y 98 | if potential_dupe and at_immediate_pair then 99 | -- User just tried to add a closing pair despite us doing it for them! 100 | -- Undo it for them 101 | editor:remove_at(editor.cursor.x - 1, editor.cursor.y) 102 | autopairs.just_paired = { x = nil, y = nil } 103 | end 104 | end 105 | event_mapping[start_pair] = function() 106 | if editor.cursor == nil then return end 107 | autopairs.just_paired = editor.cursor 108 | editor:insert(end_pair) 109 | editor:move_left() 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /plugins/pomodoro.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Pomodoro Timer v0.1 3 | 4 | A simple timer to help you space out your work with breaks 5 | This technique is also said to help increase memory retention during study 6 | ]]-- 7 | 8 | -- Define our pomodoro state 9 | pomodoro = { 10 | -- Configuration values 11 | work_time = 25, 12 | rest_time = 5, 13 | -- Plug-in state 14 | current = "none", 15 | started = os.date("*t"), 16 | } 17 | 18 | -- Utility function to show a user-friendly time 19 | function dec2mmss(decimal_seconds) 20 | local minutes = math.floor(decimal_seconds / 60) 21 | local seconds = decimal_seconds % 60 22 | 23 | -- Format seconds to always have two digits 24 | return string.format("%02d:%02d", minutes, seconds) 25 | end 26 | 27 | -- Helper function to work out how long the timer has left 28 | function pomodoro_left() 29 | local current = os.date("*t") 30 | local elapsed = os.time(current) - os.time(pomodoro.started) 31 | local minutes = 0 32 | if pomodoro.current == "work" then 33 | minutes = pomodoro.work_time * 60 - elapsed 34 | elseif pomodoro.current == "rest" then 35 | minutes = pomodoro.rest_time * 60 - elapsed 36 | end 37 | return minutes 38 | end 39 | 40 | -- Define a function to display the countdown in the status line 41 | function pomodoro_show() 42 | local minutes = pomodoro_left() 43 | if minutes < 0 then 44 | if pomodoro.current == "work" then 45 | pomodoro.current = "rest" 46 | elseif pomodoro.current == "rest" then 47 | pomodoro.current = "work" 48 | end 49 | pomodoro.started = os.date("*t") 50 | return "Time is up!" 51 | elseif pomodoro.current == "none" then 52 | return "No Pomodoro Active" 53 | else 54 | return pomodoro.current .. " for " .. dec2mmss(minutes) 55 | end 56 | end 57 | 58 | -- Add the pomodoro command to interface with the user 59 | commands["pomodoro"] = function(arguments) 60 | subcmd = arguments[1] 61 | if subcmd == "start" then 62 | if pomodoro.current ~= "none" then 63 | editor:display_error("Pomodoro timer is already active") 64 | else 65 | pomodoro.current = "work" 66 | pomodoro.started = os.date("*t") 67 | end 68 | elseif subcmd == "stop" then 69 | pomodoro.current = "none" 70 | editor:display_info("Stopped pomodoro timer") 71 | end 72 | end 73 | 74 | -- Force rerender of the status line every second whilst the timer is active 75 | function pomodoro_refresh() 76 | if pomodoro.current ~= "none" then 77 | editor:rerender_status_line() 78 | end 79 | end 80 | every(1, "pomodoro_refresh") 81 | -------------------------------------------------------------------------------- /plugins/quickcomment.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Quickcomment v0.3 3 | 4 | A plug-in to help you comment and uncomment lines quickly 5 | ]]-- 6 | 7 | quickcomment = {} 8 | 9 | function quickcomment:comment(y) 10 | local line = editor:get_line_at(y) 11 | -- Find start of line 12 | local _, index = line:find("%S") 13 | index = index or 0 14 | -- Select a comment depending on the language 15 | local comment_start = self:comment_start() .. " " 16 | -- Insert the character 17 | local old_x = editor.cursor.x 18 | if index - 1 <= 0 then 19 | editor:move_to(0, y) 20 | else 21 | editor:move_to(index - 1, y) 22 | end 23 | editor:insert(comment_start) 24 | if old_x + #comment_start <= 0 then 25 | editor:move_to(0, y) 26 | else 27 | editor:move_to(old_x + #comment_start, y) 28 | end 29 | end 30 | 31 | function quickcomment:uncomment(y) 32 | local comment_start = self:comment_start() .. " " 33 | local line = editor:get_line_at(y) 34 | local old_x = editor.cursor.x 35 | if self:is_commented(y) then 36 | local index = line:find(comment_start, 1, true) 37 | if index ~= nil then 38 | -- Existing comment has a space after it 39 | for i = 0, #comment_start - 1 do 40 | editor:remove_at(index - 1, y) 41 | end 42 | else 43 | -- Existing comment doesn't have a space after it 44 | comment_start = self:comment_start() 45 | local index = line:find(comment_start, 1, true) 46 | for i = 0, #comment_start - 1 do 47 | editor:remove_at(index - 1, y) 48 | end 49 | end 50 | if old_x - #comment_start <= 0 then 51 | editor:move_to(0, y) 52 | else 53 | editor:move_to(old_x - #comment_start, y) 54 | end 55 | end 56 | end 57 | 58 | function quickcomment:is_commented(y) 59 | local comment_start = self:comment_start() 60 | local line = editor:get_line_at(y) 61 | local _, index = line:find("%S") 62 | index = index or 0 63 | return string.sub(line, index, index + #comment_start - 1) == comment_start 64 | end 65 | 66 | function quickcomment:comment_start() 67 | if editor.document_type == "Shell" then 68 | comment_start = "#" 69 | elseif editor.document_type == "Python" then 70 | comment_start = "#" 71 | elseif editor.document_type == "Ruby" then 72 | comment_start = "#" 73 | elseif editor.document_type == "TOML" then 74 | comment_start = "#" 75 | elseif editor.document_type == "Lua" then 76 | comment_start = "--" 77 | elseif editor.document_type == "Haskell" then 78 | comment_start = "--" 79 | elseif editor.document_type == "Assembly" then 80 | comment_start = ";" 81 | elseif editor.document_type == "Ada" then 82 | comment_start = "--" 83 | elseif editor.document_type == "Crystal" then 84 | comment_start = "#" 85 | elseif editor.document_type == "Makefile" then 86 | comment_start = "#" 87 | elseif editor.document_type == "Julia" then 88 | comment_start = "#" 89 | elseif editor.document_type == "Lisp" then 90 | comment_start = ";" 91 | elseif editor.document_type == "Perl" then 92 | comment_start = "#" 93 | elseif editor.document_type == "R" then 94 | comment_start = "#" 95 | elseif editor.document_type == "Racket" then 96 | comment_start = ";" 97 | elseif editor.document_type == "SQL" then 98 | comment_start = "--" 99 | elseif editor.document_type == "Zsh" then 100 | comment_start = "#" 101 | elseif editor.document_type == "Yaml" then 102 | comment_start = "#" 103 | elseif editor.document_type == "Clojure" then 104 | comment_start = ";" 105 | elseif editor.document_type == "Zsh" then 106 | comment_start = "#" 107 | else 108 | comment_start = "//" 109 | end 110 | return comment_start 111 | end 112 | 113 | function quickcomment:toggle_comment(y) 114 | if self:is_commented(y) then 115 | self:uncomment(y) 116 | else 117 | self:comment(y) 118 | end 119 | end 120 | 121 | event_mapping["alt_c"] = function() 122 | editor:commit() 123 | local cursor = editor.cursor 124 | local select = editor.selection 125 | local no_select = select.x == cursor.x and select.y == cursor.y 126 | if no_select then 127 | quickcomment:toggle_comment(editor.cursor.y) 128 | else 129 | -- toggle comments on a group of lines 130 | if cursor.y > select.y then 131 | for line = select.y, cursor.y do 132 | editor:move_to(0, line) 133 | quickcomment:toggle_comment(editor.cursor.y) 134 | end 135 | else 136 | for line = cursor.y, select.y do 137 | editor:move_to(0, line) 138 | quickcomment:toggle_comment(editor.cursor.y) 139 | end 140 | end 141 | editor:move_to(cursor.x, cursor.y) 142 | editor:select_to(select.x, select.y) 143 | end 144 | -- Avoid weird behaviour with cursor moving up and down 145 | editor:cursor_snap() 146 | end 147 | -------------------------------------------------------------------------------- /plugins/themes/default16.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Pallette -- 3 | black = 'black' 4 | darkgrey = 'darkgrey' 5 | red = 'red' 6 | darkred = 'darkred' 7 | green = 'green' 8 | darkgreen = 'darkgreen' 9 | yellow = 'yellow' 10 | darkyellow = 'darkyellow' 11 | blue = 'blue' 12 | darkblue = 'darkblue' 13 | magenta = 'magenta' 14 | darkmagenta = 'darkmagenta' 15 | cyan = 'cyan' 16 | darkcyan = 'darkcyan' 17 | white = 'white' 18 | grey = 'grey' 19 | transparent = 'transparent' 20 | 21 | 22 | -- Configure Colours -- 23 | colors.editor_bg = black 24 | colors.editor_fg = white 25 | colors.line_number_fg = grey 26 | colors.line_number_bg = black 27 | 28 | colors.status_bg = darkblue 29 | colors.status_fg = white 30 | 31 | colors.highlight = cyan 32 | 33 | colors.tab_inactive_bg = black 34 | colors.tab_inactive_fg = white 35 | colors.tab_active_bg = darkblue 36 | colors.tab_active_fg = white 37 | 38 | colors.split_bg = black 39 | colors.split_fg = darkblue 40 | 41 | colors.info_bg = black 42 | colors.info_fg = cyan 43 | colors.warning_bg = black 44 | colors.warning_fg = yellow 45 | colors.error_bg = black 46 | colors.error_fg = red 47 | 48 | colors.selection_bg = darkgrey 49 | colors.selection_fg = cyan 50 | 51 | colors.file_tree_bg = black 52 | colors.file_tree_fg = white 53 | colors.file_tree_selection_bg = darkgrey 54 | colors.file_tree_selection_fg = cyan 55 | 56 | colors.file_tree_red = red 57 | colors.file_tree_orange = darkyellow 58 | colors.file_tree_yellow = yellow 59 | colors.file_tree_green = green 60 | colors.file_tree_lightblue = blue 61 | colors.file_tree_darkblue = darkblue 62 | colors.file_tree_purple = darkmagenta 63 | colors.file_tree_pink = magenta 64 | colors.file_tree_brown = darkred 65 | colors.file_tree_grey = grey 66 | 67 | -- Configure Syntax Highlighting Colours -- 68 | syntax:set("string", green) -- Strings, bright green 69 | syntax:set("comment", darkgrey) -- Comments, light purple/gray 70 | syntax:set("digit", red) -- Digits, cyan 71 | syntax:set("keyword", darkmagenta) -- Keywords, vibrant pink 72 | syntax:set("attribute", blue) -- Attributes, cyan 73 | syntax:set("character", darkblue) -- Characters, cyan 74 | syntax:set("type", yellow) -- Types, light purple 75 | syntax:set("function", darkblue) -- Function names, light purple 76 | syntax:set("header", blue) -- Headers, cyan 77 | syntax:set("macro", red) -- Macros, red 78 | syntax:set("namespace", darkblue) -- Namespaces, light purple 79 | syntax:set("struct", magenta) -- Structs, classes, and enums, light purple 80 | syntax:set("operator", grey) -- Operators, light purple/gray 81 | syntax:set("boolean", green) -- Booleans, bright green 82 | syntax:set("table", darkmagenta) -- Tables, light purple 83 | syntax:set("reference", magenta) -- References, vibrant pink 84 | syntax:set("tag", darkblue) -- Tags (e.g. HTML tags), cyan 85 | syntax:set("heading", darkmagenta) -- Headings, light purple 86 | syntax:set("link", magenta) -- Links, vibrant pink 87 | syntax:set("key", magenta) -- Keys, vibrant pink 88 | syntax:set("quote", grey) -- Quotes, light purple/gray 89 | syntax:set("bold", red) -- Bold text, cyan 90 | syntax:set("italic", darkyellow) -- Italic text, cyan 91 | syntax:set("block", blue) -- Code blocks, cyan 92 | syntax:set("image", blue) -- Images in markup languages, cyan 93 | syntax:set("list", green) -- Lists, bright green 94 | syntax:set("insertion", green) -- Insertions (e.g. diff highlight), bright green 95 | syntax:set("deletion", red) -- Deletions (e.g. diff highlight), red 96 | -------------------------------------------------------------------------------- /plugins/themes/galaxy.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Pallette -- 3 | black = '#1e1e2e' 4 | grey1 = '#24273a' 5 | grey2 = '#303446' 6 | grey3 = '#7f849c' 7 | white = '#cdd6f4' 8 | brown = '#f2cdcd' 9 | red = '#f38ba8' 10 | orange = '#fab387' 11 | yellow = '#f9e2af' 12 | green = '#a6e3a1' 13 | lightblue = '#89dceb' 14 | darkblue = '#89b4fa' 15 | purple = '#cba6f7' 16 | pink = '#f5c2e7' 17 | 18 | -- Configure Colours -- 19 | colors.editor_bg = black 20 | colors.editor_fg = white 21 | colors.line_number_fg = grey2 22 | colors.line_number_bg = black 23 | 24 | colors.status_bg = grey1 25 | colors.status_fg = purple 26 | 27 | colors.highlight = purple 28 | 29 | colors.tab_inactive_bg = grey1 30 | colors.tab_inactive_fg = white 31 | colors.tab_active_bg = grey2 32 | colors.tab_active_fg = purple 33 | 34 | colors.split_bg = black 35 | colors.split_fg = purple 36 | 37 | colors.info_bg = black 38 | colors.info_fg = darkblue 39 | colors.warning_bg = black 40 | colors.warning_fg = yellow 41 | colors.error_bg = black 42 | colors.error_fg = red 43 | 44 | colors.selection_bg = grey1 45 | colors.selection_fg = lightblue 46 | 47 | colors.file_tree_bg = black 48 | colors.file_tree_fg = white 49 | colors.file_tree_selection_bg = purple 50 | colors.file_tree_selection_fg = black 51 | 52 | colors.file_tree_red = {247, 156, 156} 53 | colors.file_tree_orange = {247, 165, 156} 54 | colors.file_tree_yellow = {247, 226, 156} 55 | colors.file_tree_green = {191, 247, 156} 56 | colors.file_tree_lightblue = {156, 214, 247} 57 | colors.file_tree_darkblue = {156, 163, 247} 58 | colors.file_tree_purple = {197, 156, 247} 59 | colors.file_tree_pink = {246, 156, 247} 60 | colors.file_tree_brown = {163, 118, 118} 61 | colors.file_tree_grey = {160, 157, 191} 62 | 63 | -- Configure Syntax Highlighting Colours -- 64 | syntax:set("string", green) -- Strings, bright green 65 | syntax:set("comment", grey3) -- Comments, light purple/gray 66 | syntax:set("digit", red) -- Digits, cyan 67 | syntax:set("keyword", purple) -- Keywords, vibrant pink 68 | syntax:set("attribute", lightblue) -- Attributes, cyan 69 | syntax:set("character", darkblue) -- Characters, cyan 70 | syntax:set("type", yellow) -- Types, light purple 71 | syntax:set("function", darkblue) -- Function names, light purple 72 | syntax:set("header", lightblue) -- Headers, cyan 73 | syntax:set("macro", red) -- Macros, red 74 | syntax:set("namespace", darkblue) -- Namespaces, light purple 75 | syntax:set("struct", pink) -- Structs, classes, and enums, light purple 76 | syntax:set("operator", grey3) -- Operators, light purple/gray 77 | syntax:set("boolean", green) -- Booleans, bright green 78 | syntax:set("table", purple) -- Tables, light purple 79 | syntax:set("reference", pink) -- References, vibrant pink 80 | syntax:set("tag", darkblue) -- Tags (e.g. HTML tags), cyan 81 | syntax:set("heading", purple) -- Headings, light purple 82 | syntax:set("link", pink) -- Links, vibrant pink 83 | syntax:set("key", pink) -- Keys, vibrant pink 84 | syntax:set("quote", grey3) -- Quotes, light purple/gray 85 | syntax:set("bold", red) -- Bold text, cyan 86 | syntax:set("italic", orange) -- Italic text, cyan 87 | syntax:set("block", lightblue) -- Code blocks, cyan 88 | syntax:set("image", lightblue) -- Images in markup languages, cyan 89 | syntax:set("list", green) -- Lists, bright green 90 | syntax:set("insertion", green) -- Insertions (e.g. diff highlight), bright green 91 | syntax:set("deletion", red) -- Deletions (e.g. diff highlight), red 92 | -------------------------------------------------------------------------------- /plugins/themes/omni.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Palette -- 3 | background = '#191622' -- Background Color 4 | background2 = '#201B2D' -- Secondary background 5 | background3 = '#15121E' -- Tertiary background 6 | selection = '#41414D' -- Selection Color 7 | foreground = '#E1E1E6' -- Foreground Color 8 | comment = '#483C67' -- Comment Color 9 | cyan = '#78D1E1' -- Cyan Color 10 | green = '#67E480' -- Green Color 11 | orange = '#E89E64' -- Orange Color 12 | pink = '#FF79C6' -- Pink Color 13 | purple = '#988BC7' -- Purple Color 14 | red = '#E96379' -- Red Color 15 | yellow = '#E7DE79' -- Yellow Color 16 | white = '#FFFFFF' 17 | 18 | -- Configure Colours -- 19 | colors.editor_bg = background 20 | colors.editor_fg = foreground 21 | colors.line_number_fg = comment 22 | colors.line_number_bg = background 23 | 24 | colors.status_bg = background3 25 | colors.status_fg = white 26 | 27 | colors.highlight = pink 28 | 29 | colors.tab_inactive_bg = background3 30 | colors.tab_inactive_fg = foreground 31 | colors.tab_active_bg = background 32 | colors.tab_active_fg = pink 33 | 34 | colors.split_bg = background 35 | colors.split_fg = white 36 | 37 | colors.info_bg = background 38 | colors.info_fg = cyan 39 | colors.warning_bg = background 40 | colors.warning_fg = yellow 41 | colors.error_bg = background 42 | colors.error_fg = red 43 | 44 | colors.selection_bg = selection 45 | colors.selection_fg = foreground 46 | 47 | colors.file_tree_bg = background 48 | colors.file_tree_fg = foreground 49 | colors.file_tree_selection_bg = pink 50 | colors.file_tree_selection_fg = background 51 | 52 | colors.file_tree_red = {255, 128, 128} 53 | colors.file_tree_orange = {255, 155, 128} 54 | colors.file_tree_yellow = {255, 204, 128} 55 | colors.file_tree_green = {196, 255, 128} 56 | colors.file_tree_lightblue = {128, 236, 255} 57 | colors.file_tree_darkblue = {128, 147, 255} 58 | colors.file_tree_purple = {204, 128, 255} 59 | colors.file_tree_pink = {255, 128, 200} 60 | colors.file_tree_brown = {163, 108, 108} 61 | colors.file_tree_grey = {155, 153, 176} 62 | 63 | -- Configure Syntax Highlighting Colours -- 64 | syntax:set("string", yellow) -- Strings, fresh green 65 | syntax:set("comment", comment) -- Comments, muted and subtle 66 | syntax:set("digit", cyan) -- Digits, cool cyan 67 | syntax:set("keyword", pink) -- Keywords, vibrant pink 68 | syntax:set("attribute", orange) -- Attributes, warm orange 69 | syntax:set("character", yellow) -- Characters, cheerful yellow 70 | syntax:set("type", purple) -- Types, elegant purple 71 | syntax:set("function", green) -- Function names, clean cyan 72 | syntax:set("header", yellow) -- Headers, bright yellow 73 | syntax:set("macro", red) -- Macros, bold red 74 | syntax:set("namespace", purple) -- Namespaces, subtle purple 75 | syntax:set("struct", orange) -- Structs, warm orange 76 | syntax:set("operator", pink) -- Operators, striking pink 77 | syntax:set("boolean", green) -- Booleans, fresh green 78 | syntax:set("table", purple) -- Tables, structured purple 79 | syntax:set("reference", pink) -- References, vibrant orange 80 | syntax:set("tag", cyan) -- Tags (e.g., HTML tags), calming cyan 81 | syntax:set("heading", pink) -- Headings, vibrant pink 82 | syntax:set("link", cyan) -- Links, attention-grabbing cyan 83 | syntax:set("key", green) -- Keys, fresh green 84 | syntax:set("quote", comment) -- Quotes, subtle comment color 85 | syntax:set("bold", yellow) -- Bold text, cheerful yellow 86 | syntax:set("italic", purple) -- Italic text, elegant purple 87 | syntax:set("block", cyan) -- Code blocks, cool cyan 88 | syntax:set("image", orange) -- Images in markup languages, warm orange 89 | syntax:set("list", green) -- Lists, structured green 90 | syntax:set("insertion", green) -- Insertions (e.g., diff highlight), vibrant green 91 | syntax:set("deletion", red) -- Deletions (e.g., diff highlight), bold red 92 | -------------------------------------------------------------------------------- /plugins/themes/transparent.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Configure Colours -- 3 | colors.editor_bg = 'transparent' 4 | colors.line_number_bg = 'transparent' 5 | 6 | colors.split_bg = 'transparent' 7 | colors.split_fg = 15 8 | 9 | colors.info_bg = 'transparent' 10 | colors.warning_bg = 'transparent' 11 | colors.error_bg = 'transparent' 12 | 13 | colors.file_tree_bg = 'transparent' 14 | colors.file_tree_selection_fg = {35, 240, 144} 15 | colors.file_tree_selection_bg = 'transparent' 16 | -------------------------------------------------------------------------------- /plugins/themes/tropical.lua: -------------------------------------------------------------------------------- 1 | 2 | -- Pallette -- 3 | black = '#232336' 4 | grey1 = '#353552' 5 | grey2 = '#484863' 6 | grey3 = '#A1A7C7' 7 | white = '#cdd6f4' 8 | brown = '#dd7878' 9 | red = '#ed8796' 10 | orange = '#f5a97f' 11 | yellow = '#eed49f' 12 | green = '#a6da95' 13 | lightblue = '#7dc4e4' 14 | darkblue = '#8aadf4' 15 | purple = '#c6a0f6' 16 | pink = '#f5bde6' 17 | 18 | -- Configure Colours -- 19 | colors.editor_bg = black 20 | colors.editor_fg = white 21 | colors.line_number_fg = grey2 22 | colors.line_number_bg = black 23 | 24 | colors.status_bg = grey1 25 | colors.status_fg = orange 26 | 27 | colors.highlight = orange 28 | 29 | colors.tab_inactive_bg = grey1 30 | colors.tab_inactive_fg = white 31 | colors.tab_active_bg = grey2 32 | colors.tab_active_fg = orange 33 | 34 | colors.split_bg = black 35 | colors.split_fg = orange 36 | 37 | colors.info_bg = black 38 | colors.info_fg = darkblue 39 | colors.warning_bg = black 40 | colors.warning_fg = yellow 41 | colors.error_bg = black 42 | colors.error_fg = red 43 | 44 | colors.selection_bg = grey1 45 | colors.selection_fg = lightblue 46 | 47 | colors.file_tree_bg = black 48 | colors.file_tree_fg = white 49 | colors.file_tree_selection_bg = lightblue 50 | colors.file_tree_selection_fg = black 51 | 52 | colors.file_tree_red = {245, 127, 127} 53 | colors.file_tree_orange = {245, 169, 127} 54 | colors.file_tree_yellow = {245, 217, 127} 55 | colors.file_tree_green = {165, 245, 127} 56 | colors.file_tree_lightblue = {127, 227, 245} 57 | colors.file_tree_darkblue = {127, 145, 245} 58 | colors.file_tree_purple = {190, 127, 245} 59 | colors.file_tree_pink = {245, 127, 217} 60 | colors.file_tree_brown = {163, 116, 116} 61 | colors.file_tree_grey = {191, 190, 196} 62 | 63 | -- Configure Syntax Highlighting Colours -- 64 | syntax:set("string", lightblue) -- Strings, bright green 65 | syntax:set("comment", grey3) -- Comments, light purple/gray 66 | syntax:set("digit", lightblue) -- Digits, cyan 67 | syntax:set("keyword", orange) -- Keywords, vibrant pink 68 | syntax:set("attribute", darkblue) -- Attributes, cyan 69 | syntax:set("character", orange) -- Characters, cyan 70 | syntax:set("type", pink) -- Types, light purple 71 | syntax:set("function", red) -- Function names, light purple 72 | syntax:set("header", darkblue) -- Headers, cyan 73 | syntax:set("macro", darkblue) -- Macros, red 74 | syntax:set("namespace", pink) -- Namespaces, light purple 75 | syntax:set("struct", yellow) -- Structs, classes, and enums, light purple 76 | syntax:set("operator", darkblue) -- Operators, light purple/gray 77 | syntax:set("boolean", pink) -- Booleans, bright green 78 | syntax:set("table", yellow) -- Tables, light purple 79 | syntax:set("reference", yellow) -- References, vibrant pink 80 | syntax:set("tag", orange) -- Tags (e.g. HTML tags), cyan 81 | syntax:set("heading", red) -- Headings, light purple 82 | syntax:set("link", darkblue) -- Links, vibrant pink 83 | syntax:set("key", yellow) -- Keys, vibrant pink 84 | syntax:set("quote", grey3) -- Quotes, light purple/gray 85 | syntax:set("bold", red) -- Bold text, cyan 86 | syntax:set("italic", orange) -- Italic text, cyan 87 | syntax:set("block", red) -- Code blocks, cyan 88 | syntax:set("image", red) -- Images in markup languages, cyan 89 | syntax:set("list", red) -- Lists, bright green 90 | syntax:set("insertion", green) -- Insertions (e.g. diff highlight), bright green 91 | syntax:set("deletion", red) -- Deletions (e.g. diff highlight), red 92 | -------------------------------------------------------------------------------- /plugins/todo.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Todo Lists v0.3 3 | 4 | This plug-in will provide todo list functionality on files with the extension .todo 5 | You can mark todos as done / not done by using the Ctrl + Enter key combination 6 | Todos are in the format "- [ ] Your Todo Name" 7 | ]]-- 8 | 9 | -- Add language specific information for .todo files 10 | file_types["Todo"] = { 11 | icon = " ", 12 | extensions = {"todo"}, 13 | files = {".todo.md", ".todo"}, 14 | modelines = {}, 15 | color = "grey", 16 | } 17 | 18 | -- Add syntax highlighting to .todo files (done todos are comments) 19 | syntax:new( 20 | "Todo", 21 | {syntax:keyword("comment", "\\s*(-\\s*\\[(?:X|x)\\].*)")} 22 | ) 23 | 24 | -- Create the structure and behaviour for todo list files 25 | todo_lists = {} 26 | 27 | function todo_lists:how_complete() 28 | -- Work out how many todos are done vs not done 29 | local total = 0 30 | local complete = 0 31 | for y = 1, editor.document_length do 32 | local line = editor:get_line_at(y) 33 | if string.match(line, "^%s*%-%s*%[([Xx])%].*") then 34 | complete = complete + 1 35 | total = total + 1 36 | elseif string.match(line, "^%s*%-%s*%[ %].*") then 37 | total = total + 1 38 | end 39 | end 40 | local percentage = math.floor(complete / total * 100 + 0.5) 41 | -- Return a really nice message 42 | return complete .. "/" .. total .. " done" .. " - " .. percentage .. "% complete" 43 | end 44 | 45 | -- Shortcut to flip todos from not done to done and vice versa 46 | event_mapping["ctrl_enter"] = function() 47 | if editor.document_type == "Todo" then 48 | -- Determine what kind of line we're dealing with 49 | local line = editor:get_line() 50 | if string.match(line, "^%s*%-%s*%[([Xx])%].*") then 51 | -- Mark this line as not done 52 | line = string.gsub(line, "^(%s*%-%s*)%[([Xx])%]", "%1[ ]") 53 | editor:insert_line_at(line, editor.cursor.y) 54 | editor:remove_line_at(editor.cursor.y + 1) 55 | -- Print handy statistics 56 | local stats = todo_lists:how_complete() 57 | editor:display_info(stats) 58 | elseif string.match(line, "^%s*%-%s*%[ %].*") then 59 | -- Mark this line as done 60 | line = string.gsub(line, "^(%s*%-%s*)%[ %]", "%1[X]") 61 | editor:insert_line_at(line, editor.cursor.y) 62 | editor:remove_line_at(editor.cursor.y + 1) 63 | -- Print handy statistics 64 | local stats = todo_lists:how_complete() 65 | editor:display_info(stats) 66 | else 67 | editor:display_error("This todo is incorrectly formatted") 68 | end 69 | end 70 | end 71 | 72 | -- Autoadd empty task when the user presses enter onto a new line 73 | event_mapping["enter"] = function() 74 | if editor.document_type == "Todo" then 75 | editor:insert("- [ ] ") 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /plugins/typing_speed.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Typing Speed v0.1 3 | 4 | Lets you know of your typing speed as you type 5 | --]] 6 | 7 | -- Program state 8 | typing_speed = { 9 | time_between_words = {}, 10 | last_capture = os.date("*t"), 11 | } 12 | 13 | event_mapping["space"] = function() 14 | if #typing_speed.time_between_words > 10 then 15 | typing_speed:pop() 16 | end 17 | local current = os.date("*t") 18 | local elapsed = os.time(current) - os.time(typing_speed.last_capture) 19 | typing_speed.last_capture = current 20 | typing_speed:push(elapsed) 21 | end 22 | 23 | function typing_speed:push(value) 24 | table.insert(self.time_between_words, value) 25 | end 26 | 27 | function typing_speed:pop() 28 | local result = table[1] 29 | table.remove(self.time_between_words, 1) 30 | return result 31 | end 32 | 33 | function typing_speed_show() 34 | -- Work out the average seconds taken to type each word 35 | local sum = 0 36 | local count = #typing_speed.time_between_words 37 | for i = 1, count do 38 | sum = sum + typing_speed.time_between_words[i] 39 | end 40 | local avg = 0 41 | if count > 0 then 42 | avg = sum / count 43 | end 44 | local wpm = 60 / avg 45 | if count <= 0 then 46 | wpm = 0 47 | end 48 | return tostring(math.floor(wpm + 0.5)) .. " wpm" 49 | end 50 | -------------------------------------------------------------------------------- /plugins/update_notification.lua: -------------------------------------------------------------------------------- 1 | -- Get the contents of the latest Cargo.toml 2 | local cargo_latest = http.get("https://raw.githubusercontent.com/curlpipe/ox/refs/heads/master/Cargo.toml") 3 | -- Extract the version from the build file 4 | local version = cargo_latest:match("version%s*=%s*\"(%d+.%d+.%d+)\"") 5 | -- Display it to the user 6 | if version ~= editor.version and version ~= nil then 7 | editor:display_warning("Update to " .. version .. " is available (you have " .. editor.version .. ")") 8 | end 9 | 10 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | /// Utilities for dealing with the command line interface of Ox 2 | use jargon_args::{Jargon, Key}; 3 | use std::io; 4 | use std::io::BufRead; 5 | 6 | /// Holds the version number of the crate 7 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 8 | 9 | /// Holds the help dialog 10 | pub const HELP: &str = "\ 11 | Ox: A lightweight and flexible text editor 12 | 13 | USAGE: ox [options] [files] 14 | 15 | OPTIONS: 16 | --help, -h : Show this help message 17 | --version, -v : Show the version number 18 | --config [path], -c [path] : Specify the configuration file 19 | --readonly, -r : Prevent opened files from writing 20 | --filetype [name], -f [name] : Set the file type of files opened 21 | --stdin : Reads file from the stdin 22 | --config-assist : Activate the configuration assistant 23 | 24 | EXAMPLES: 25 | ox 26 | ox test.txt 27 | ox test.txt test2.txt 28 | ox /home/user/docs/test.txt 29 | ox -c config.lua test.txt 30 | ox -r -c ~/.config/.oxrc -f Lua my_file.lua 31 | tree | ox -r --stdin 32 | ox --config-assist\ 33 | "; 34 | 35 | /// Read from the standard input 36 | pub fn get_stdin() -> String { 37 | io::stdin().lock().lines().fold(String::new(), |acc, line| { 38 | acc + &line.unwrap_or_else(|_| String::new()) + "\n" 39 | }) 40 | } 41 | 42 | /// Flags for command line interface 43 | #[allow(clippy::struct_excessive_bools)] 44 | pub struct CommandLineInterfaceFlags { 45 | pub help: bool, 46 | pub version: bool, 47 | pub read_only: bool, 48 | pub stdin: bool, 49 | pub config_assist: bool, 50 | } 51 | 52 | /// Struct to help with starting ox 53 | pub struct CommandLineInterface { 54 | pub flags: CommandLineInterfaceFlags, 55 | pub file_type: Option, 56 | pub config_path: String, 57 | pub to_open: Vec, 58 | } 59 | 60 | impl CommandLineInterface { 61 | /// Create a new command line interface helper 62 | pub fn new() -> Self { 63 | // Start parsing 64 | let mut j = Jargon::from_env(); 65 | 66 | // Define keys 67 | let filetype: Key = ["-f", "--filetype"].into(); 68 | let config: Key = ["-c", "--config"].into(); 69 | 70 | Self { 71 | flags: CommandLineInterfaceFlags { 72 | help: j.contains(["-h", "--help"]), 73 | version: j.contains(["-v", "--version"]), 74 | read_only: j.contains(["-r", "--readonly"]), 75 | stdin: j.contains("--stdin"), 76 | config_assist: j.contains("--config-assist"), 77 | }, 78 | file_type: j.option_arg::(filetype.clone()), 79 | config_path: j 80 | .option_arg::(config.clone()) 81 | .unwrap_or_else(|| "~/.oxrc".to_string()), 82 | to_open: j.finish().into_iter().filter(|o| o != "--").collect(), 83 | } 84 | } 85 | 86 | /// Handle options that won't need to start the editor 87 | pub fn basic_options(&self) { 88 | if self.flags.help { 89 | println!("{HELP}"); 90 | std::process::exit(0); 91 | } else if self.flags.version { 92 | println!("{VERSION}"); 93 | std::process::exit(0); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/config/filetree.rs: -------------------------------------------------------------------------------- 1 | /// Related to file tree configuration options 2 | use mlua::prelude::*; 3 | 4 | #[derive(Debug)] 5 | pub struct FileTree { 6 | pub width: usize, 7 | pub move_focus_to_file: bool, 8 | pub icons: bool, 9 | pub language_icons: bool, 10 | } 11 | 12 | impl Default for FileTree { 13 | fn default() -> Self { 14 | Self { 15 | width: 30, 16 | move_focus_to_file: true, 17 | icons: false, 18 | language_icons: true, 19 | } 20 | } 21 | } 22 | 23 | impl LuaUserData for FileTree { 24 | fn add_fields>(fields: &mut F) { 25 | fields.add_field_method_get("width", |_, this| Ok(this.width)); 26 | fields.add_field_method_set("width", |_, this, value| { 27 | this.width = value; 28 | Ok(()) 29 | }); 30 | fields.add_field_method_get("move_focus_to_file", |_, this| Ok(this.move_focus_to_file)); 31 | fields.add_field_method_set("move_focus_to_file", |_, this, value| { 32 | this.move_focus_to_file = value; 33 | Ok(()) 34 | }); 35 | fields.add_field_method_get("icons", |_, this| Ok(this.icons)); 36 | fields.add_field_method_set("icons", |_, this, value| { 37 | this.icons = value; 38 | Ok(()) 39 | }); 40 | fields.add_field_method_get("language_icons", |_, this| Ok(this.language_icons)); 41 | fields.add_field_method_set("language_icons", |_, this, value| { 42 | this.language_icons = value; 43 | Ok(()) 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/config/highlighting.rs: -------------------------------------------------------------------------------- 1 | /// For changing around syntax highlighting in the config file 2 | use crate::error::{OxError, Result}; 3 | use crossterm::style::Color as CColor; 4 | use mlua::prelude::*; 5 | use std::collections::HashMap; 6 | use synoptic::Highlighter; 7 | 8 | use super::Color; 9 | 10 | type BoundedInterpArgs = (String, String, String, String, String, bool); 11 | 12 | /// For storing configuration information related to syntax highlighting 13 | #[derive(Debug)] 14 | #[allow(clippy::module_name_repetitions)] 15 | pub struct SyntaxHighlighting { 16 | pub theme: HashMap, 17 | pub user_rules: HashMap, 18 | } 19 | 20 | impl Default for SyntaxHighlighting { 21 | fn default() -> Self { 22 | let mut theme = HashMap::default(); 23 | theme.insert("string".to_string(), Color::Rgb(39, 222, 145)); 24 | theme.insert("comment".to_string(), Color::Rgb(113, 113, 169)); 25 | theme.insert("digit".to_string(), Color::Rgb(40, 198, 232)); 26 | theme.insert("keyword".to_string(), Color::Rgb(134, 76, 232)); 27 | theme.insert("attribute".to_string(), Color::Rgb(40, 198, 232)); 28 | theme.insert("character".to_string(), Color::Rgb(40, 198, 232)); 29 | theme.insert("type".to_string(), Color::Rgb(47, 141, 252)); 30 | theme.insert("function".to_string(), Color::Rgb(47, 141, 252)); 31 | theme.insert("header".to_string(), Color::Rgb(40, 198, 232)); 32 | theme.insert("macro".to_string(), Color::Rgb(223, 52, 249)); 33 | theme.insert("namespace".to_string(), Color::Rgb(47, 141, 252)); 34 | theme.insert("struct".to_string(), Color::Rgb(47, 141, 252)); 35 | theme.insert("operator".to_string(), Color::Rgb(113, 113, 169)); 36 | theme.insert("boolean".to_string(), Color::Rgb(86, 217, 178)); 37 | theme.insert("table".to_string(), Color::Rgb(47, 141, 252)); 38 | theme.insert("reference".to_string(), Color::Rgb(134, 76, 232)); 39 | theme.insert("tag".to_string(), Color::Rgb(40, 198, 232)); 40 | theme.insert("heading".to_string(), Color::Rgb(47, 141, 252)); 41 | theme.insert("link".to_string(), Color::Rgb(223, 52, 249)); 42 | theme.insert("key".to_string(), Color::Rgb(223, 52, 249)); 43 | theme.insert("quote".to_string(), Color::Rgb(113, 113, 169)); 44 | theme.insert("bold".to_string(), Color::Rgb(40, 198, 232)); 45 | theme.insert("italic".to_string(), Color::Rgb(40, 198, 232)); 46 | theme.insert("block".to_string(), Color::Rgb(40, 198, 232)); 47 | theme.insert("image".to_string(), Color::Rgb(40, 198, 232)); 48 | theme.insert("list".to_string(), Color::Rgb(86, 217, 178)); 49 | theme.insert("insertion".to_string(), Color::Rgb(39, 222, 145)); 50 | theme.insert("deletion".to_string(), Color::Rgb(255, 100, 100)); 51 | Self { 52 | theme, 53 | user_rules: HashMap::default(), 54 | } 55 | } 56 | } 57 | 58 | impl SyntaxHighlighting { 59 | /// Get a colour from the theme 60 | pub fn get_theme(&self, name: &str) -> Result { 61 | if let Some(col) = self.theme.get(name) { 62 | col.to_color() 63 | } else { 64 | let msg = format!("{name} has not been given a colour in the theme"); 65 | Err(OxError::Config { msg }) 66 | } 67 | } 68 | } 69 | 70 | impl LuaUserData for SyntaxHighlighting { 71 | fn add_methods>(methods: &mut M) { 72 | methods.add_method_mut( 73 | "keywords", 74 | |lua, _, (name, pattern): (String, Vec)| { 75 | let table = lua.create_table()?; 76 | table.set("kind", "keyword")?; 77 | table.set("name", name)?; 78 | table.set("pattern", format!("({})", pattern.join("|")))?; 79 | Ok(table) 80 | }, 81 | ); 82 | methods.add_method_mut("keyword", |lua, _, (name, pattern): (String, String)| { 83 | let table = lua.create_table()?; 84 | table.set("kind", "keyword")?; 85 | table.set("name", name)?; 86 | table.set("pattern", pattern)?; 87 | Ok(table) 88 | }); 89 | methods.add_method_mut( 90 | "bounded", 91 | |lua, _, (name, start, end, escape): (String, String, String, bool)| { 92 | let table = lua.create_table()?; 93 | table.set("kind", "bounded")?; 94 | table.set("name", name)?; 95 | table.set("start", start)?; 96 | table.set("end", end)?; 97 | table.set("escape", escape.to_string())?; 98 | Ok(table) 99 | }, 100 | ); 101 | methods.add_method_mut( 102 | "bounded_interpolation", 103 | |lua, _, (name, start, end, i_start, i_end, escape): BoundedInterpArgs| { 104 | let table = lua.create_table()?; 105 | table.set("kind", "bounded_interpolation")?; 106 | table.set("name", name)?; 107 | table.set("start", start)?; 108 | table.set("end", end)?; 109 | table.set("i_start", i_start)?; 110 | table.set("i_end", i_end)?; 111 | table.set("escape", escape.to_string())?; 112 | Ok(table) 113 | }, 114 | ); 115 | methods.add_method_mut( 116 | "new", 117 | |_, syntax_highlighting, (name, rules): (String, LuaTable)| { 118 | // Create highlighter 119 | let mut highlighter = Highlighter::new(4); 120 | // Add rules one by one 121 | for rule_idx in 1..=(rules.len()?) { 122 | // Get rule 123 | let rule = rules.get::>(rule_idx)?; 124 | // Find type of rule and attatch it to the highlighter 125 | match rule["kind"].as_str() { 126 | "keyword" => { 127 | highlighter.keyword(rule["name"].clone(), &rule["pattern"]); 128 | } 129 | "bounded" => highlighter.bounded( 130 | rule["name"].clone(), 131 | rule["start"].clone(), 132 | rule["end"].clone(), 133 | rule["escape"] == "true", 134 | ), 135 | "bounded_interpolation" => highlighter.bounded_interp( 136 | rule["name"].clone(), 137 | rule["start"].clone(), 138 | rule["end"].clone(), 139 | rule["i_start"].clone(), 140 | rule["i_end"].clone(), 141 | rule["escape"] == "true", 142 | ), 143 | _ => unreachable!(), 144 | } 145 | } 146 | syntax_highlighting.user_rules.insert(name, highlighter); 147 | Ok(()) 148 | }, 149 | ); 150 | methods.add_method_mut("set", |_, syntax_highlighting, (name, value)| { 151 | syntax_highlighting 152 | .theme 153 | .insert(name, Color::from_lua(value)); 154 | Ok(()) 155 | }); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/config/keys.rs: -------------------------------------------------------------------------------- 1 | use crate::error::OxError; 2 | /// For dealing with keys in the configuration file 3 | use crossterm::event::{KeyCode as KCode, KeyModifiers as KMod, MediaKeyCode, ModifierKeyCode}; 4 | use mlua::prelude::*; 5 | 6 | /// This contains the code for running code after a key binding is pressed 7 | pub fn run_key(mut key: &str) -> String { 8 | if key == "\"" { 9 | key = "\\\""; 10 | } 11 | format!( 12 | " 13 | globalevent = (global_event_mapping[\"*\"] or {{}}) 14 | for _, f in ipairs(globalevent) do 15 | f() 16 | end 17 | key = (global_event_mapping[\"{key}\"] or error(\"key not bound\")) 18 | for _, f in ipairs(key) do 19 | f() 20 | end 21 | " 22 | ) 23 | } 24 | 25 | /// This contains the code for running code before a key binding is fully processed 26 | pub fn run_key_before(mut key: &str) -> String { 27 | if key == "\"" { 28 | key = "\\\""; 29 | } 30 | format!( 31 | " 32 | globalevent = (global_event_mapping[\"before:*\"] or {{}}) 33 | for _, f in ipairs(globalevent) do 34 | f() 35 | end 36 | key = (global_event_mapping[\"before:{key}\"] or {{}}) 37 | for _, f in ipairs(key) do 38 | f() 39 | end 40 | " 41 | ) 42 | } 43 | 44 | /// This contains code for getting event listeners 45 | pub fn get_listeners(name: &str, lua: &Lua) -> Result, OxError> { 46 | let mut result = vec![]; 47 | let listeners: LuaTable = lua 48 | .load(format!("(global_event_mapping[\"{name}\"] or {{}})")) 49 | .eval()?; 50 | for listener in listeners.pairs::() { 51 | let (_, lua_func) = listener?; 52 | result.push(lua_func); 53 | } 54 | Ok(result) 55 | } 56 | 57 | /// Normalises key presses (across windows and unix based systems) 58 | pub fn key_normalise(code: &mut String) { 59 | let punctuation: Vec = "!\"£$%^&*(){}:@~<>?~|¬".chars().collect(); 60 | for c in punctuation { 61 | if c == '"' { 62 | *code = code.replace("shift_\\\"", &c.to_string()); 63 | } else { 64 | *code = code.replace(&format!("shift_{c}"), &c.to_string()); 65 | } 66 | } 67 | } 68 | 69 | /// Converts a key taken from a crossterm event into string format 70 | pub fn key_to_string(modifiers: KMod, key: KCode) -> String { 71 | let mut result = String::new(); 72 | // Deal with modifiers 73 | if modifiers.contains(KMod::CONTROL) { 74 | result += "ctrl_"; 75 | } 76 | if modifiers.contains(KMod::ALT) { 77 | result += "alt_"; 78 | } 79 | if modifiers.contains(KMod::SHIFT) { 80 | result += "shift_"; 81 | } 82 | result += &match key { 83 | KCode::Char('\\') => "\\\\".to_string(), 84 | KCode::Char('"') => "\\\"".to_string(), 85 | KCode::Backspace => "backspace".to_string(), 86 | KCode::Enter => "enter".to_string(), 87 | KCode::Left => "left".to_string(), 88 | KCode::Right => "right".to_string(), 89 | KCode::Up => "up".to_string(), 90 | KCode::Down => "down".to_string(), 91 | KCode::Home => "home".to_string(), 92 | KCode::End => "end".to_string(), 93 | KCode::PageUp => "pageup".to_string(), 94 | KCode::PageDown => "pagedown".to_string(), 95 | KCode::Tab => "tab".to_string(), 96 | KCode::BackTab => "backtab".to_string(), 97 | KCode::Delete => "delete".to_string(), 98 | KCode::Insert => "insert".to_string(), 99 | KCode::F(num) => format!("f{num}"), 100 | KCode::Char(ch) => format!("{}", ch.to_lowercase()), 101 | KCode::Null => "null".to_string(), 102 | KCode::Esc => "esc".to_string(), 103 | KCode::CapsLock => "capslock".to_string(), 104 | KCode::ScrollLock => "scrolllock".to_string(), 105 | KCode::NumLock => "numlock".to_string(), 106 | KCode::PrintScreen => "printscreen".to_string(), 107 | KCode::Pause => "pause".to_string(), 108 | KCode::Menu => "menu".to_string(), 109 | KCode::KeypadBegin => "keypadbegin".to_string(), 110 | KCode::Media(key) => match key { 111 | MediaKeyCode::Play => "play", 112 | MediaKeyCode::Pause => "pause", 113 | MediaKeyCode::PlayPause => "playpause", 114 | MediaKeyCode::Reverse => "reverse", 115 | MediaKeyCode::Stop => "stop", 116 | MediaKeyCode::FastForward => "fastforward", 117 | MediaKeyCode::TrackNext => "next", 118 | MediaKeyCode::TrackPrevious => "previous", 119 | MediaKeyCode::Record => "record", 120 | MediaKeyCode::Rewind => "rewind", 121 | MediaKeyCode::LowerVolume => "lowervolume", 122 | MediaKeyCode::RaiseVolume => "raisevolume", 123 | MediaKeyCode::MuteVolume => "mutevolume", 124 | } 125 | .to_string(), 126 | KCode::Modifier(key) => match key { 127 | ModifierKeyCode::LeftShift => "lshift", 128 | ModifierKeyCode::LeftControl => "lctrl", 129 | ModifierKeyCode::LeftAlt => "lalt", 130 | ModifierKeyCode::LeftSuper => "lsuper", 131 | ModifierKeyCode::LeftHyper => "lhyper", 132 | ModifierKeyCode::LeftMeta => "lmeta", 133 | ModifierKeyCode::RightControl => "rctrl", 134 | ModifierKeyCode::RightAlt => "ralt", 135 | ModifierKeyCode::RightSuper => "rsuper", 136 | ModifierKeyCode::RightHyper => "rhyper", 137 | ModifierKeyCode::RightMeta => "rmeta", 138 | ModifierKeyCode::RightShift => "rshift", 139 | ModifierKeyCode::IsoLevel3Shift => "iso3shift", 140 | ModifierKeyCode::IsoLevel5Shift => "iso5shift", 141 | } 142 | .to_string(), 143 | }; 144 | // Ensure consistent key codes across platforms 145 | key_normalise(&mut result); 146 | result 147 | } 148 | -------------------------------------------------------------------------------- /src/config/runner.rs: -------------------------------------------------------------------------------- 1 | //! Configuration for defining how programs should be compiled and run 2 | 3 | use mlua::prelude::*; 4 | 5 | /// Main struct to determine how a language should be compiled / run 6 | #[derive(Debug, Default)] 7 | #[allow(dead_code)] 8 | pub struct RunCommand { 9 | pub compile: Option, 10 | pub run: Option, 11 | } 12 | 13 | impl FromLua for RunCommand { 14 | fn from_lua(val: LuaValue, _: &Lua) -> LuaResult { 15 | if let LuaValue::Table(table) = val { 16 | Ok(Self { 17 | compile: table.get("compile")?, 18 | run: table.get("run")?, 19 | }) 20 | } else { 21 | Ok(Self::default()) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/config/tasks.rs: -------------------------------------------------------------------------------- 1 | /// For dealing with tasks (part of the plug-in concurrency API) 2 | 3 | #[derive(Default, Debug)] 4 | pub struct Task { 5 | repeat: bool, 6 | delay: isize, 7 | remaining: isize, 8 | target: String, 9 | } 10 | 11 | /// A struct in charge of executing functions concurrently 12 | #[derive(Default, Debug)] 13 | pub struct TaskManager { 14 | pub tasks: Vec, 15 | pub to_execute: Vec, 16 | } 17 | 18 | impl TaskManager { 19 | /// Thread to run and keep track of which tasks to execute 20 | pub fn cycle(&mut self) { 21 | for task in &mut self.tasks { 22 | // Decrement remaining time 23 | if task.remaining > 0 { 24 | task.remaining = task.remaining.saturating_sub(1); 25 | } 26 | // Check if activation is required 27 | if task.remaining == 0 { 28 | self.to_execute.push(task.target.clone()); 29 | // Check whether to repeat or not 30 | if task.repeat { 31 | // Re-load the task 32 | task.remaining = task.delay; 33 | } else { 34 | // Condemn the task to decrementing forever 35 | task.remaining = -1; 36 | } 37 | } 38 | } 39 | } 40 | 41 | /// Obtain a list of functions to execute (and remove them from the execution list) 42 | pub fn execution_list(&mut self) -> Vec { 43 | let mut new = vec![]; 44 | std::mem::swap(&mut self.to_execute, &mut new); 45 | new 46 | } 47 | 48 | /// Define a new task 49 | pub fn attach(&mut self, delay: isize, target: String, repeat: bool) { 50 | self.tasks.push(Task { 51 | remaining: delay, 52 | delay, 53 | target, 54 | repeat, 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/editor/cursor.rs: -------------------------------------------------------------------------------- 1 | /// Functions for moving the cursor around 2 | use crate::{config, ged, handle_event, CEvent, Loc, Result}; 3 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 4 | use kaolinite::event::Status; 5 | use mlua::{AnyUserData, Lua}; 6 | 7 | use super::Editor; 8 | 9 | impl Editor { 10 | /// Move the cursor up 11 | pub fn select_up(&mut self) { 12 | if let Some(doc) = self.try_doc_mut() { 13 | doc.select_up(); 14 | } 15 | } 16 | 17 | /// Move the cursor down 18 | pub fn select_down(&mut self) { 19 | if let Some(doc) = self.try_doc_mut() { 20 | doc.select_down(); 21 | } 22 | } 23 | 24 | /// Move the cursor left 25 | pub fn select_left(&mut self) { 26 | let wrapping = config!(self.config, document).wrap_cursor; 27 | if let Some(doc) = self.try_doc_mut() { 28 | let status = doc.select_left(); 29 | // Cursor wrapping if cursor hits the start of the line 30 | if status == Status::StartOfLine && doc.loc().y != 0 && wrapping { 31 | doc.select_up(); 32 | doc.select_end(); 33 | } 34 | } 35 | } 36 | 37 | /// Move the cursor right 38 | pub fn select_right(&mut self) { 39 | let wrapping = config!(self.config, document).wrap_cursor; 40 | if let Some(doc) = self.try_doc_mut() { 41 | let status = doc.select_right(); 42 | // Cursor wrapping if cursor hits the end of a line 43 | if status == Status::EndOfLine && wrapping { 44 | doc.select_down(); 45 | doc.select_home(); 46 | } 47 | } 48 | } 49 | 50 | /// Select the whole document 51 | pub fn select_all(&mut self) { 52 | if let Some(doc) = self.try_doc_mut() { 53 | doc.move_top(); 54 | doc.select_bottom(); 55 | } 56 | } 57 | 58 | /// Move the cursor up 59 | pub fn up(&mut self) { 60 | if let Some(doc) = self.try_doc_mut() { 61 | doc.move_up(); 62 | } 63 | } 64 | 65 | /// Move the cursor down 66 | pub fn down(&mut self) { 67 | if let Some(doc) = self.try_doc_mut() { 68 | doc.move_down(); 69 | } 70 | } 71 | 72 | /// Move the cursor left 73 | pub fn left(&mut self) { 74 | let wrapping = config!(self.config, document).wrap_cursor; 75 | if let Some(doc) = self.try_doc_mut() { 76 | let status = doc.move_left(); 77 | // Cursor wrapping if cursor hits the start of the line 78 | if status == Status::StartOfLine && doc.loc().y != 0 && wrapping { 79 | doc.move_up(); 80 | doc.move_end(); 81 | } 82 | } 83 | } 84 | 85 | /// Move the cursor right 86 | pub fn right(&mut self) { 87 | let wrapping = config!(self.config, document).wrap_cursor; 88 | if let Some(doc) = self.try_doc_mut() { 89 | let status = doc.move_right(); 90 | // Cursor wrapping if cursor hits the end of a line 91 | if status == Status::EndOfLine && wrapping { 92 | doc.move_down(); 93 | doc.move_home(); 94 | } 95 | } 96 | } 97 | 98 | /// Move the cursor to the previous word in the line 99 | pub fn prev_word(&mut self) { 100 | let wrapping = config!(self.config, document).wrap_cursor; 101 | if let Some(doc) = self.try_doc_mut() { 102 | let status = doc.move_prev_word(); 103 | if status == Status::StartOfLine && wrapping { 104 | doc.move_up(); 105 | doc.move_end(); 106 | } 107 | } 108 | } 109 | 110 | /// Move the cursor to the next word in the line 111 | pub fn next_word(&mut self) { 112 | let wrapping = config!(self.config, document).wrap_cursor; 113 | if let Some(doc) = self.try_doc_mut() { 114 | let status = doc.move_next_word(); 115 | if status == Status::EndOfLine && wrapping { 116 | doc.move_down(); 117 | doc.move_home(); 118 | } 119 | } 120 | } 121 | } 122 | 123 | /// Handle multiple cursors (replay a key event for each of them) 124 | pub fn handle_multiple_cursors( 125 | editor: &AnyUserData, 126 | event: &CEvent, 127 | lua: &Lua, 128 | original_loc: &Loc, 129 | ) -> Result<()> { 130 | if ged!(&editor).try_doc().is_none() { 131 | return Ok(()); 132 | } 133 | let mut original_loc = *original_loc; 134 | // Cache the state of the document 135 | let mut cursor = ged!(&editor).try_doc().unwrap().cursor; 136 | let mut secondary_cursors = ged!(&editor).try_doc().unwrap().secondary_cursors.clone(); 137 | ged!(mut &editor).macro_man.playing = true; 138 | // Prevent interference 139 | adjust_other_cursors( 140 | &mut secondary_cursors, 141 | &original_loc.clone(), 142 | &cursor.loc, 143 | event, 144 | &mut original_loc, 145 | ); 146 | // Update each secondary cursor 147 | let mut ptr = 0; 148 | while ptr < secondary_cursors.len() { 149 | // Move to the secondary cursor position 150 | let sec_cursor = secondary_cursors[ptr]; 151 | ged!(mut &editor) 152 | .try_doc_mut() 153 | .unwrap() 154 | .move_to(&sec_cursor); 155 | // Replay the event 156 | let old_loc = ged!(&editor).try_doc().unwrap().char_loc(); 157 | handle_event(editor, event, lua)?; 158 | // Prevent any interference 159 | let char_loc = ged!(&editor).try_doc().unwrap().char_loc(); 160 | cursor.loc = adjust_other_cursors( 161 | &mut secondary_cursors, 162 | &old_loc, 163 | &char_loc, 164 | event, 165 | &mut cursor.loc, 166 | ); 167 | // Update the secondary cursor 168 | *secondary_cursors.get_mut(ptr).unwrap() = char_loc; 169 | // Move to the next secondary cursor 170 | ptr += 1; 171 | } 172 | ged!(mut &editor).try_doc_mut().unwrap().secondary_cursors = secondary_cursors; 173 | ged!(mut &editor).macro_man.playing = false; 174 | // Restore back to the state of the document beforehand 175 | // TODO: calculate char_ptr and old_cursor too 176 | ged!(mut &editor).try_doc_mut().unwrap().cursor = cursor; 177 | let char_ptr = ged!(&editor).try_doc().unwrap().character_idx(&cursor.loc); 178 | ged!(mut &editor).try_doc_mut().unwrap().char_ptr = char_ptr; 179 | ged!(mut &editor).try_doc_mut().unwrap().old_cursor = cursor.loc.x; 180 | ged!(mut &editor).try_doc_mut().unwrap().cancel_selection(); 181 | Ok(()) 182 | } 183 | 184 | /// Adjust other secondary cursors based of a change in one 185 | fn adjust_other_cursors( 186 | cursors: &mut Vec, 187 | old_pos: &Loc, 188 | new_pos: &Loc, 189 | event: &CEvent, 190 | primary: &mut Loc, 191 | ) -> Loc { 192 | cursors.push(*primary); 193 | match event { 194 | CEvent::Key(KeyEvent { 195 | code: KeyCode::Enter, 196 | .. 197 | }) => { 198 | // Enter key, push all cursors below this line downwards 199 | for c in cursors.iter_mut() { 200 | if c == old_pos { 201 | continue; 202 | } 203 | let mut new_loc = *c; 204 | // Adjust x position 205 | if old_pos.y == c.y && old_pos.x < c.x { 206 | new_loc.x -= old_pos.x; 207 | } 208 | // If this cursor is after the currently moved cursor, shift down 209 | if c.y > old_pos.y || (c.y == old_pos.y && c.x > old_pos.x) { 210 | new_loc.y += 1; 211 | } 212 | // Update the secondary cursor 213 | *c = new_loc; 214 | } 215 | } 216 | CEvent::Key(KeyEvent { 217 | code: KeyCode::Backspace, 218 | .. 219 | }) => { 220 | // Backspace key, push all cursors below this line upwards 221 | for c in cursors.iter_mut() { 222 | if c == old_pos { 223 | continue; 224 | } 225 | let mut new_loc = *c; 226 | let at_line_start = old_pos.x == 0 && old_pos.y != 0; 227 | // Adjust x position 228 | if old_pos.y == c.y && old_pos.x < c.x && at_line_start { 229 | new_loc.x += new_pos.x; 230 | } 231 | // If this cursor is after the currently moved cursor, shift up 232 | if (c.y > old_pos.y || (c.y == old_pos.y && c.x > old_pos.x)) && at_line_start { 233 | new_loc.y -= 1; 234 | } 235 | // Update the secondary cursor 236 | *c = new_loc; 237 | } 238 | } 239 | _ => (), 240 | } 241 | cursors.pop().unwrap() 242 | } 243 | 244 | // Determine whether an event should be acted on by the multi cursor 245 | #[allow(clippy::module_name_repetitions)] 246 | pub fn allowed_by_multi_cursor(event: &CEvent) -> bool { 247 | matches!( 248 | event, 249 | CEvent::Key( 250 | KeyEvent { 251 | code: KeyCode::Tab 252 | | KeyCode::Backspace 253 | | KeyCode::Enter 254 | | KeyCode::Up 255 | | KeyCode::Down 256 | | KeyCode::Left 257 | | KeyCode::Right, 258 | modifiers: KeyModifiers::NONE, 259 | .. 260 | } | KeyEvent { 261 | code: KeyCode::Char(_), 262 | modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, 263 | .. 264 | } | KeyEvent { 265 | code: KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right, 266 | modifiers: KeyModifiers::CONTROL, 267 | .. 268 | } 269 | ) 270 | ) 271 | } 272 | -------------------------------------------------------------------------------- /src/editor/editing.rs: -------------------------------------------------------------------------------- 1 | /// General functions for editing a document 2 | use crate::error::Result; 3 | use kaolinite::event::Event; 4 | use kaolinite::utils::Loc; 5 | 6 | use super::Editor; 7 | 8 | impl Editor { 9 | /// Execute an edit event 10 | pub fn exe(&mut self, ev: Event) -> Result<()> { 11 | if self.try_doc().is_some() { 12 | let multi_cursors = !self.try_doc().unwrap().secondary_cursors.is_empty(); 13 | if !(self.plugin_active || self.pasting || self.macro_man.playing || multi_cursors) { 14 | let last_ev = self.try_doc().unwrap().event_mgmt.last_event.as_ref(); 15 | // If last event is present and the same as this one, commit 16 | let event_type_differs = last_ev.map(|e1| e1.same_type(&ev)) != Some(true); 17 | // If last event is present and on a different line from the previous, commit 18 | let event_on_different_line = 19 | last_ev.map(|e| e.loc().y == ev.loc().y) != Some(true); 20 | // Commit if necessary 21 | if event_type_differs || event_on_different_line { 22 | self.try_doc_mut().unwrap().commit(); 23 | } 24 | } else if self.try_doc().unwrap().event_mgmt.history.is_empty() { 25 | // If there is no initial commit and a plug-in changes things without commiting 26 | // It can cause the initial state of the document to be lost 27 | // This condition makes sure there is a copy to go back to if this is the case 28 | self.try_doc_mut().unwrap().commit(); 29 | } 30 | self.try_doc_mut().unwrap().exe(ev)?; 31 | } 32 | Ok(()) 33 | } 34 | 35 | /// Insert a character into the document, creating a new row if editing 36 | /// on the last line of the document 37 | pub fn character(&mut self, ch: char) -> Result<()> { 38 | if self.try_doc().is_some() { 39 | let doc = self.try_doc().unwrap(); 40 | let selection_overwrite = !doc.is_selection_empty() && !doc.info.read_only; 41 | if selection_overwrite { 42 | self.try_doc_mut().unwrap().commit(); 43 | self.try_doc_mut().unwrap().remove_selection(); 44 | } 45 | self.new_row()?; 46 | // Handle the character insertion 47 | if ch == '\n' { 48 | self.enter()?; 49 | } else { 50 | let doc = self.try_doc().unwrap(); 51 | let loc = doc.char_loc(); 52 | self.exe(Event::Insert(loc, ch.to_string()))?; 53 | if let Some(file) = self.files.get_mut(self.ptr.clone()) { 54 | if !file.doc.info.read_only { 55 | file.highlighter.edit(loc.y, &file.doc.lines[loc.y]); 56 | } 57 | } 58 | } 59 | if selection_overwrite { 60 | self.reload_highlight(); 61 | } 62 | } 63 | Ok(()) 64 | } 65 | 66 | /// Handle the return key 67 | pub fn enter(&mut self) -> Result<()> { 68 | if let Some(doc) = self.try_doc_mut() { 69 | // Perform the changes 70 | if doc.loc().y == doc.len_lines() { 71 | // Enter pressed on the empty line at the bottom of the document 72 | self.new_row()?; 73 | } else { 74 | // Enter pressed in the start, middle or end of the line 75 | let loc = doc.char_loc(); 76 | self.exe(Event::SplitDown(loc))?; 77 | if let Some(file) = self.files.get_mut(self.ptr.clone()) { 78 | if !file.doc.info.read_only { 79 | let line = &file.doc.lines[loc.y + 1]; 80 | file.highlighter.insert_line(loc.y + 1, line); 81 | let line = &file.doc.lines[loc.y]; 82 | file.highlighter.edit(loc.y, line); 83 | } 84 | } 85 | } 86 | } 87 | Ok(()) 88 | } 89 | 90 | /// Handle the backspace key 91 | pub fn backspace(&mut self) -> Result<()> { 92 | if self.try_doc().is_some() { 93 | let doc = self.try_doc().unwrap(); 94 | if !doc.is_selection_empty() && !doc.info.read_only { 95 | // Removing a selection is significant and worth an undo commit 96 | let doc = self.try_doc_mut().unwrap(); 97 | doc.commit(); 98 | doc.remove_selection(); 99 | self.reload_highlight(); 100 | return Ok(()); 101 | } 102 | let doc = self.try_doc().unwrap(); 103 | let mut c = doc.char_ptr; 104 | let on_first_line = doc.loc().y == 0; 105 | let out_of_range = doc.out_of_range(0, doc.loc().y).is_err(); 106 | if c == 0 && !on_first_line && !out_of_range { 107 | // Backspace was pressed on the start of the line, move line to the top 108 | self.new_row()?; 109 | let mut loc = self.try_doc().unwrap().char_loc(); 110 | let file = self.files.get_mut(self.ptr.clone()).unwrap(); 111 | if !file.doc.info.read_only { 112 | self.highlighter().remove_line(loc.y); 113 | } 114 | loc.y = loc.y.saturating_sub(1); 115 | let file = self.files.get_mut(self.ptr.clone()).unwrap(); 116 | loc.x = file.doc.line(loc.y).unwrap().chars().count(); 117 | self.exe(Event::SpliceUp(loc))?; 118 | let file = self.files.get_mut(self.ptr.clone()).unwrap(); 119 | let line = &file.doc.lines[loc.y]; 120 | if !file.doc.info.read_only { 121 | file.highlighter.edit(loc.y, line); 122 | } 123 | } else if !(c == 0 && on_first_line) { 124 | // Backspace was pressed in the middle of the line, delete the character 125 | c = c.saturating_sub(1); 126 | if let Some(line) = doc.line(doc.loc().y) { 127 | if let Some(ch) = line.chars().nth(c) { 128 | let loc = Loc { 129 | x: c, 130 | y: doc.loc().y, 131 | }; 132 | self.exe(Event::Delete(loc, ch.to_string()))?; 133 | let file = self.files.get_mut(self.ptr.clone()).unwrap(); 134 | if !file.doc.info.read_only { 135 | file.highlighter.edit(loc.y, &file.doc.lines[loc.y]); 136 | } 137 | } 138 | } 139 | } 140 | } 141 | Ok(()) 142 | } 143 | 144 | /// Delete the character in place 145 | pub fn delete(&mut self) -> Result<()> { 146 | if let Some(doc) = self.try_doc() { 147 | let c = doc.char_ptr; 148 | if let Some(line) = doc.line(doc.loc().y) { 149 | if let Some(ch) = line.chars().nth(c) { 150 | let loc = Loc { 151 | x: c, 152 | y: doc.loc().y, 153 | }; 154 | self.exe(Event::Delete(loc, ch.to_string()))?; 155 | if let Some(file) = self.files.get_mut(self.ptr.clone()) { 156 | if !file.doc.info.read_only { 157 | file.highlighter.edit(loc.y, &file.doc.lines[loc.y]); 158 | } 159 | } 160 | } 161 | } 162 | } 163 | Ok(()) 164 | } 165 | 166 | /// Insert a new row at the end of the document if the cursor is on it 167 | fn new_row(&mut self) -> Result<()> { 168 | if self.try_doc().is_some() { 169 | let doc = self.try_doc().unwrap(); 170 | if doc.loc().y == doc.len_lines() { 171 | self.exe(Event::InsertLine(doc.loc().y, String::new()))?; 172 | let doc = self.try_doc().unwrap(); 173 | if !doc.info.read_only { 174 | self.highlighter().append(""); 175 | } 176 | } 177 | } 178 | Ok(()) 179 | } 180 | 181 | /// Delete the current line 182 | pub fn delete_line(&mut self) -> Result<()> { 183 | if self.try_doc().is_some() { 184 | // Delete the line 185 | let doc = self.try_doc().unwrap(); 186 | if doc.loc().y < doc.len_lines() { 187 | let y = doc.loc().y; 188 | let line = doc.line(y).unwrap(); 189 | self.exe(Event::DeleteLine(y, line))?; 190 | let doc = self.try_doc().unwrap(); 191 | if !doc.info.read_only { 192 | self.highlighter().remove_line(y); 193 | } 194 | } 195 | } 196 | Ok(()) 197 | } 198 | 199 | /// Perform redo action 200 | pub fn redo(&mut self) -> Result<()> { 201 | if let Some(doc) = self.try_doc_mut() { 202 | doc.redo()?; 203 | self.reload_highlight(); 204 | } 205 | Ok(()) 206 | } 207 | 208 | /// Perform undo action 209 | pub fn undo(&mut self) -> Result<()> { 210 | if let Some(doc) = self.try_doc_mut() { 211 | doc.undo()?; 212 | self.reload_highlight(); 213 | } 214 | Ok(()) 215 | } 216 | 217 | /// Copy the selected text 218 | pub fn copy(&mut self) -> Result<()> { 219 | if let Some(doc) = self.try_doc() { 220 | let selected_text = doc.selection_text(); 221 | self.terminal.copy(&selected_text) 222 | } else { 223 | Ok(()) 224 | } 225 | } 226 | 227 | /// Cut the selected text 228 | pub fn cut(&mut self) -> Result<()> { 229 | if self.try_doc().is_some() { 230 | self.copy()?; 231 | self.try_doc_mut().unwrap().remove_selection(); 232 | self.reload_highlight(); 233 | } 234 | Ok(()) 235 | } 236 | 237 | /// Shortcut to help rehighlight a line 238 | pub fn hl_edit(&mut self, y: usize) { 239 | if let Some(doc) = self.try_doc() { 240 | let line = doc.line(y).unwrap_or_default(); 241 | self.highlighter().edit(y, &line); 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/editor/filetypes.rs: -------------------------------------------------------------------------------- 1 | /// Tools for managing and identifying file types 2 | use crate::config; 3 | use crate::editor::Config; 4 | use kaolinite::utils::get_file_name; 5 | use kaolinite::Document; 6 | use std::path::Path; 7 | use synoptic::{from_extension, Highlighter, Regex}; 8 | 9 | /// A struct to store different file types and provide utilities for finding the correct one 10 | #[derive(Default, Debug, Clone)] 11 | pub struct FileTypes { 12 | /// The file types available 13 | pub types: Vec, 14 | } 15 | 16 | impl FileTypes { 17 | pub fn identify(&self, doc: &mut Document) -> Option { 18 | for t in &self.types { 19 | let mut extension = String::new(); 20 | let mut file_name = String::new(); 21 | if let Some(f) = &doc.file_name { 22 | file_name = get_file_name(f).unwrap_or_default(); 23 | if let Some(e) = Path::new(&f).extension() { 24 | extension = e.to_str().unwrap_or_default().to_string(); 25 | } 26 | } 27 | doc.load_to(1); 28 | let first_line = doc.line(0).unwrap_or_default(); 29 | if t.fits(&extension, &file_name, &first_line) { 30 | return Some(t.clone()); 31 | } 32 | } 33 | None 34 | } 35 | 36 | pub fn identify_from_path(&self, path: &str) -> Option { 37 | if let Some(e) = Path::new(&path).extension() { 38 | let file_name = get_file_name(path).unwrap_or_default(); 39 | let extension = e.to_str().unwrap_or_default().to_string(); 40 | for t in &self.types { 41 | if t.fits(&extension, &file_name, "") { 42 | return Some(t.clone()); 43 | } 44 | } 45 | } 46 | None 47 | } 48 | 49 | pub fn get_name(&self, name: &str) -> Option { 50 | self.types.iter().find(|t| t.name == name).cloned() 51 | } 52 | } 53 | 54 | /// An struct to represent the characteristics of a file type 55 | #[derive(Debug, Clone)] 56 | pub struct FileType { 57 | /// The name of the file type 58 | pub name: String, 59 | /// The icon representing the file type 60 | pub icon: String, 61 | /// The file names that files of this type exhibit 62 | pub files: Vec, 63 | /// The extensions that files of this type have 64 | pub extensions: Vec, 65 | /// The modelines that files of this type have 66 | pub modelines: Vec, 67 | /// The colour associated with this file type 68 | pub color: String, 69 | } 70 | 71 | impl Default for FileType { 72 | fn default() -> Self { 73 | FileType { 74 | name: "Unknown".to_string(), 75 | icon: "󰈙 ".to_string(), 76 | files: vec![], 77 | extensions: vec![], 78 | modelines: vec![], 79 | color: "grey".to_string(), 80 | } 81 | } 82 | } 83 | 84 | impl FileType { 85 | /// Determine whether a file fits with this file type 86 | pub fn fits(&self, extension: &String, file_name: &String, first_line: &str) -> bool { 87 | let mut modelines = false; 88 | for modeline in &self.modelines { 89 | if let Ok(re) = Regex::new(&format!("^{modeline}\\s*$")) { 90 | if re.is_match(first_line) { 91 | modelines = true; 92 | break; 93 | } 94 | } 95 | } 96 | self.extensions.contains(extension) || self.files.contains(file_name) || modelines 97 | } 98 | 99 | /// Identify the correct highlighter to use 100 | pub fn get_highlighter(&self, config: &Config, tab_width: usize) -> Highlighter { 101 | if let Some(highlighter) = config!(config, syntax).user_rules.get(&self.name) { 102 | // The user has defined their own syntax highlighter for this file type 103 | highlighter.clone() 104 | } else { 105 | // The user hasn't defined their own syntax highlighter, use synoptic builtins 106 | for ext in &self.extensions { 107 | if let Some(h) = from_extension(ext, tab_width) { 108 | return h; 109 | } 110 | } 111 | Highlighter::new(tab_width) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/editor/macros.rs: -------------------------------------------------------------------------------- 1 | /// Tools for recording and playing back macros for bulk editing 2 | use crossterm::event::{Event as CEvent, KeyCode, KeyEvent, KeyModifiers}; 3 | 4 | /// Macro manager struct 5 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 6 | pub struct MacroMan { 7 | pub sequence: Vec, 8 | pub recording: bool, 9 | pub playing: bool, 10 | pub ptr: usize, 11 | pub just_completed: bool, 12 | pub reps: usize, 13 | } 14 | 15 | impl MacroMan { 16 | /// Register an event 17 | pub fn register(&mut self, ev: CEvent) { 18 | self.just_completed = false; 19 | let valid_event = matches!(ev, CEvent::Key(_) | CEvent::Mouse(_) | CEvent::Paste(_)); 20 | if self.recording && valid_event { 21 | self.sequence.push(ev); 22 | } 23 | } 24 | 25 | /// Activate recording 26 | pub fn record(&mut self) { 27 | self.just_completed = false; 28 | self.sequence.clear(); 29 | self.recording = true; 30 | } 31 | 32 | /// Stop recording 33 | pub fn finish(&mut self) { 34 | self.just_completed = false; 35 | self.recording = false; 36 | self.remove_macro_calls(); 37 | } 38 | 39 | /// Activate macro 40 | pub fn play(&mut self, reps: usize) { 41 | self.reps = reps; 42 | self.just_completed = false; 43 | self.playing = true; 44 | self.ptr = 0; 45 | } 46 | 47 | /// Get next event from macro man 48 | pub fn next(&mut self) -> Option { 49 | if self.playing { 50 | let result = self.sequence.get(self.ptr).cloned(); 51 | self.ptr += 1; 52 | if self.ptr >= self.sequence.len() { 53 | self.reps = self.reps.saturating_sub(1); 54 | self.playing = self.reps != 0; 55 | self.ptr = 0; 56 | self.just_completed = true; 57 | } 58 | result 59 | } else { 60 | self.just_completed = false; 61 | None 62 | } 63 | } 64 | 65 | /// Remove the stop key binding from being included 66 | pub fn remove_macro_calls(&mut self) { 67 | if let Some(CEvent::Key(KeyEvent { 68 | modifiers: KeyModifiers::CONTROL, 69 | code: KeyCode::Esc, 70 | .. 71 | })) = self.sequence.last() 72 | { 73 | self.sequence.pop(); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | /// Error handling utilities 2 | use error_set::error_set; 3 | use kaolinite::event::Error as KError; 4 | 5 | error_set! { 6 | OxError = { 7 | #[display("Error in I/O: {0}")] 8 | Render(std::io::Error), 9 | #[display("{}", 10 | match source { 11 | KError::NoFileName => "This document has no file name, please use 'save as' instead".to_string(), 12 | KError::OutOfRange => "Requested operation is out of range".to_string(), 13 | KError::ReadOnlyFile => "This file is read only and can't be saved or edited".to_string(), 14 | KError::Rope(rerr) => format!("Backend had an issue processing text: {rerr}"), 15 | KError::Io(ioerr) => format!("I/O Error: {ioerr}"), 16 | } 17 | )] 18 | Kaolinite(KError), 19 | #[display("Error in config file: {}", msg)] 20 | Config { 21 | msg: String 22 | }, 23 | #[display("Error in lua: {0}")] 24 | Lua(mlua::prelude::LuaError), 25 | #[display("Operation Cancelled")] 26 | Cancelled, 27 | #[display("File '{}' is already open", file)] 28 | AlreadyOpen { 29 | file: String, 30 | }, 31 | InvalidPath, 32 | // None, <--- Needed??? 33 | }; 34 | } 35 | 36 | /// Easy syntax sugar to have functions return the custom error type 37 | pub type Result = std::result::Result; 38 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use crate::{ged, handle_lua_error, CEvent, Editor, Feedback, KeyEvent, KeyEventKind, Result}; 2 | use crossterm::event::{poll, read}; 3 | use mlua::{AnyUserData, Lua}; 4 | use std::time::Duration; 5 | 6 | #[allow(unused_variables)] 7 | pub fn term_force(editor: &AnyUserData) -> bool { 8 | #[cfg(not(target_os = "windows"))] 9 | return ged!(mut &editor).files.terminal_rerender(); 10 | #[cfg(target_os = "windows")] 11 | return false; 12 | } 13 | 14 | pub fn mm_active(editor: &AnyUserData) -> bool { 15 | ged!(mut &editor).macro_man.playing 16 | } 17 | 18 | /// (should hold event, triggered by term force?) 19 | pub fn hold_event(editor: &AnyUserData) -> (bool, bool) { 20 | let tf = term_force(editor); 21 | ( 22 | matches!( 23 | (mm_active(editor), tf, poll(Duration::from_millis(50))), 24 | (false, false, Ok(false)) 25 | ), 26 | !tf, 27 | ) 28 | } 29 | 30 | #[allow(unused_variables)] 31 | pub fn wait_for_event(editor: &AnyUserData, lua: &Lua) -> Result { 32 | loop { 33 | // While waiting for an event to come along, service the task manager 34 | if !mm_active(editor) { 35 | while let (true, was_term) = hold_event(editor) { 36 | let exec = ged!(mut &editor) 37 | .config 38 | .task_manager 39 | .lock() 40 | .unwrap() 41 | .execution_list(); 42 | for task in exec { 43 | if let Ok(target) = lua.globals().get::(task.clone()) { 44 | // Run the code 45 | handle_lua_error("task", target.call(()), &mut ged!(mut &editor).feedback); 46 | } else { 47 | ged!(mut &editor).feedback = 48 | Feedback::Warning(format!("Function '{task}' was not found")); 49 | } 50 | } 51 | // If a terminal dictates, force a rerender 52 | #[cfg(not(target_os = "windows"))] 53 | if was_term { 54 | ged!(mut &editor).needs_rerender = true; 55 | ged!(mut &editor).render(lua)?; 56 | } 57 | } 58 | } 59 | 60 | // Attempt to get an event 61 | let Some(event) = get_event(&mut ged!(mut &editor)) else { 62 | // No event available, back to the beginning 63 | continue; 64 | }; 65 | 66 | // Block certain events from passing through 67 | if !matches!( 68 | event, 69 | CEvent::Key(KeyEvent { 70 | kind: KeyEventKind::Release, 71 | .. 72 | }) 73 | ) { 74 | return Ok(event); 75 | } 76 | } 77 | } 78 | 79 | /// Wait for event, but without the task manager (and it hogs editor) 80 | pub fn wait_for_event_hog(editor: &mut Editor) -> CEvent { 81 | loop { 82 | // Attempt to get an event 83 | let Some(event) = get_event(editor) else { 84 | // No event available, back to the beginning 85 | continue; 86 | }; 87 | 88 | // Block certain events from passing through 89 | if !matches!( 90 | event, 91 | CEvent::Key(KeyEvent { 92 | kind: KeyEventKind::Release, 93 | .. 94 | }) 95 | ) { 96 | return event; 97 | } 98 | } 99 | } 100 | 101 | // Find out where to source an event from and source it 102 | pub fn get_event(editor: &mut Editor) -> Option { 103 | if let Some(ev) = editor.macro_man.next() { 104 | // Take from macro man 105 | Some(ev) 106 | } else if let Ok(true) = poll(Duration::from_millis(50)) { 107 | if let Ok(ev) = read() { 108 | // Use standard crossterm event 109 | Some(ev) 110 | } else { 111 | None 112 | } 113 | } else { 114 | None 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/plugin/networking.lua: -------------------------------------------------------------------------------- 1 | -- Networking library (for plug-ins to use) 2 | -- Uses curl 3 | 4 | http = { 5 | backend = "curl", 6 | } 7 | 8 | local function execute(cmd) 9 | local handle = io.popen(cmd) 10 | local result = handle:read("*a") 11 | handle:close() 12 | return result 13 | end 14 | 15 | function http.get(url) 16 | local cmd = 'curl -s -X GET "' .. url .. '"' 17 | return execute(cmd) 18 | end 19 | 20 | function http.post(url, data) 21 | local cmd = 'curl -s -X POST -d "' .. data .. '" "' .. url .. '"' 22 | return execute(cmd) 23 | end 24 | 25 | function http.put(url, data) 26 | local cmd = 'curl -s -X PUT -d "' .. data .. '" "' .. url .. '"' 27 | return execute(cmd) 28 | end 29 | 30 | function http.delete(url) 31 | local cmd = 'curl -s -X DELETE "' .. url .. '"' 32 | return execute(cmd) 33 | end 34 | -------------------------------------------------------------------------------- /src/plugin/plugin_manager.lua: -------------------------------------------------------------------------------- 1 | -- Plug-in management system code 2 | 3 | plugin_manager = {} 4 | 5 | -- Install a plug-in 6 | function plugin_manager:install(plugin) 7 | -- Check if downloaded / in config 8 | local downloaded = self:plugin_downloaded(plugin) 9 | local in_config = self:plugin_in_config(plugin) 10 | local do_download = false 11 | local do_enabling = false 12 | if downloaded and in_config then 13 | -- Already installed 14 | local resp = editor:prompt("Plug-in is already installed, would you like to update it? (y/n)") 15 | if resp == "y" then 16 | do_download = true 17 | else 18 | return false 19 | end 20 | elseif not downloaded and not in_config then 21 | -- No evidence of plug-in on system, get installing 22 | do_download = true 23 | do_enabling = true 24 | elseif not downloaded and in_config then 25 | -- Somehow, the user has it enabled, but it isn't downloaded 26 | local resp = editor:prompt("Plugin already enabled, start download? (y/n)") 27 | if resp == "y" then 28 | do_download = true 29 | else 30 | return false 31 | end 32 | elseif downloaded and not in_config then 33 | -- The user has managed to download it, but they haven't enabled it 34 | local resp = editor:prompt("Plugin already downloaded, enable plug-in? (y/n)") 35 | if resp == "y" then 36 | do_enabling = true 37 | else 38 | return false 39 | end 40 | end 41 | -- Do the installing 42 | if do_download then 43 | local result = plugin_manager:download_plugin(plugin) 44 | if result ~= nil then 45 | editor:display_error(result) 46 | return true 47 | end 48 | end 49 | if do_enabling then 50 | local result = plugin_manager:append_to_config(plugin) 51 | if result ~= nil then 52 | editor:display_error(result) 53 | return true 54 | end 55 | end 56 | -- Reload configuration file and plugins just to be safe 57 | editor:reload_plugins() 58 | editor:reset_terminal() 59 | editor:display_info("Plugin was installed successfully") 60 | return true 61 | end 62 | 63 | -- Uninstall a plug-in 64 | function plugin_manager:uninstall(plugin) 65 | -- Check if downloaded / in config 66 | local downloaded = self:plugin_downloaded(plugin) 67 | local in_config = self:plugin_in_config(plugin) 68 | local is_builtin = self:plugin_is_builtin(plugin) 69 | if not downloaded and not in_config then 70 | editor:display_error("Plugin is not installed") 71 | return 72 | end 73 | if downloaded and not is_builtin then 74 | local result = plugin_manager:remove_plugin(plugin) 75 | if result ~= nil then 76 | editor:display_error(result) 77 | return 78 | end 79 | end 80 | if in_config then 81 | local result = plugin_manager:remove_from_config(plugin) 82 | if result ~= nil then 83 | editor:display_error(result) 84 | return 85 | end 86 | end 87 | -- Reload configuration file and plugins just to be safe 88 | editor:reload_plugins() 89 | editor:reset_terminal() 90 | editor:display_info("Plugin was uninstalled successfully") 91 | end 92 | 93 | -- Get the status of the plug-ins including how many are installed and which ones 94 | function plugin_manager:status() 95 | local count = 0 96 | local list = "" 97 | for _, v in ipairs(builtins) do 98 | count = count + 1 99 | list = list .. v:match("(.+).lua$") .. " " 100 | end 101 | for _, v in ipairs(plugins) do 102 | count = count + 1 103 | list = list .. v:match("^.+[\\/](.+).lua$") .. " " 104 | end 105 | editor:display_info(tostring(count) .. " plug-ins installed: " .. list) 106 | end 107 | 108 | -- Verify whether or not a plug-in is built-in 109 | function plugin_manager:plugin_is_builtin(plugin) 110 | local base = plugin .. ".lua" 111 | local is_autoindent = base == "autoindent.lua" 112 | local is_pairs = base == "pairs.lua" 113 | local is_quickcomment = base == "quickcomment.lua" 114 | return is_autoindent or is_pairs or is_quickcomment 115 | end 116 | 117 | -- Verify whether or not a plug-in is downloaded 118 | function plugin_manager:plugin_downloaded(plugin) 119 | local base = plugin .. ".lua" 120 | local path_cross = base 121 | local path_unix = home .. "/.config/ox/" .. base 122 | local path_win = home .. "/ox/" .. base 123 | local installed = file_exists(path_cross) or file_exists(path_unix) or file_exists(path_win) 124 | -- Return true if plug-ins are built in 125 | local builtin = self:plugin_is_builtin(plugin) 126 | return installed or builtin 127 | end 128 | 129 | -- Download a plug-in from the ox repository 130 | function plugin_manager:download_plugin(plugin) 131 | -- Download the plug-in code 132 | local url = "https://raw.githubusercontent.com/curlpipe/ox/refs/heads/master/plugins/" .. plugin .. ".lua" 133 | local resp = http.get(url) 134 | if resp == "404: Not Found" then 135 | return "Plug-in not found in repository" 136 | end 137 | -- Find the path to download it to 138 | local path = plugin_path .. "/" .. plugin .. ".lua" 139 | -- Create the plug-in directory if it doesn't already exist 140 | if not dir_exists(plugin_path) then 141 | local command 142 | if package.config.sub(1,1) == '\\' then 143 | command = "mkdir " .. plugin_path 144 | else 145 | command = "mkdir -p " .. plugin_path 146 | end 147 | if shell:run(command) ~= 0 then 148 | return "Failed to make directory at " .. plugin_path 149 | end 150 | end 151 | -- Write it to a file 152 | file = io.open(path, "w") 153 | if not file then 154 | return "Failed to write to " .. path 155 | end 156 | file:write(resp) 157 | file:close() 158 | return nil 159 | end 160 | 161 | -- Remove a plug-in from the configuration directory 162 | function plugin_manager:remove_plugin(plugin) 163 | -- Obtain the path 164 | local path = package.config:sub(1,1) == '\\' and home .. "/ox" or home .. "/.config/ox" 165 | path = path .. "/" .. plugin .. ".lua" 166 | -- Remove the file 167 | local success, err = os.remove(path) 168 | if not success then 169 | return "Failed to delete the plug-in: " .. err 170 | else 171 | return nil 172 | end 173 | end 174 | 175 | -- Verify whether the plug-in is being imported in the configuration file 176 | function plugin_manager:plugin_in_config(plugin) 177 | -- Find the configuration file path 178 | local path = home .. "/.oxrc" 179 | -- Open the document 180 | local file = io.open(path, "r") 181 | if not file then return false end 182 | -- Check each line to see whether it is being loaded 183 | for line in file:lines() do 184 | local pattern1 = '^load_plugin%("' .. plugin .. '.lua"%)' 185 | local pattern2 = "^load_plugin%('" .. plugin .. ".lua'%)" 186 | if line:match(pattern1) or line:match(pattern2) then 187 | file:close() 188 | return true 189 | end 190 | end 191 | file:close() 192 | return false 193 | end 194 | 195 | -- Append the plug-in import code to the configuration file so it is loaded 196 | function plugin_manager:append_to_config(plugin) 197 | local path = home .. "/.oxrc" 198 | local file = io.open(path, "a") 199 | if not file then 200 | return "Failed to open configuration file" 201 | end 202 | file:write('load_plugin("' .. plugin .. '.lua")\n') 203 | file:close() 204 | return nil 205 | end 206 | 207 | -- Remove plug-in import code from the configuration file 208 | function plugin_manager:remove_from_config(plugin) 209 | -- Find the configuration file path 210 | local path = home .. "/.oxrc" 211 | -- Open the configuration file 212 | local file = io.open(path, "r") 213 | if not file then 214 | return "Failed to open configuration file" 215 | end 216 | local lines = {} 217 | for line in file:lines() do 218 | table.insert(lines, line) 219 | end 220 | file:close() 221 | -- Run through each line and only write back the non-offending lines 222 | local file = io.open(path, "w") 223 | for _, line in ipairs(lines) do 224 | local pattern1 = '^load_plugin%("' .. plugin .. '.lua"%)' 225 | local pattern2 = "^load_plugin%('" .. plugin .. ".lua'%)" 226 | if not line:match(pattern1) and not line:match(pattern2) then 227 | file:write(line .. "\n") 228 | end 229 | end 230 | file:close() 231 | return nil 232 | end 233 | 234 | -- Find the local version of a plug-in that is installed 235 | function plugin_manager:local_version(plugin) 236 | -- Open the file 237 | local file = io.open(plugin_path .. path_sep .. plugin .. ".lua", "r") 238 | if not file then return nil end 239 | -- Attempt to find a version indicator in the first 10 lines of the file 240 | local version = nil 241 | for i = 1, 10 do 242 | -- Read the line 243 | local line = file:read("*line") 244 | if not line then break end 245 | -- See if there is a match 246 | local match = line:match("(v%d+%.%d+)") 247 | if match then 248 | version = match 249 | break 250 | end 251 | end 252 | file:close() 253 | return version 254 | end 255 | 256 | -- Find the latest online version of a plug-in 257 | function plugin_manager:latest_version(plugin) 258 | -- Download the plug-in's source 259 | local url = "https://raw.githubusercontent.com/curlpipe/ox/refs/heads/master/plugins/" .. plugin .. ".lua" 260 | local resp = http.get(url) 261 | if resp == "404: Not Found" then return nil end 262 | -- Attempt to find a version indicator in the first 10 lines of the file 263 | local version = nil 264 | for line in resp:gmatch("[^\r\n]+") do 265 | -- See if there is a match 266 | local match = line:match("(v%d+%.%d+)") 267 | if match then 268 | version = match 269 | break 270 | end 271 | end 272 | return version 273 | end 274 | 275 | commands["plugin"] = function(arguments) 276 | if arguments[1] == "install" then 277 | local result = plugin_manager:install(arguments[2]) 278 | if not result then 279 | editor:display_info("Plug-in installation cancelled") 280 | end 281 | elseif arguments[1] == "uninstall" then 282 | plugin_manager:uninstall(arguments[2]) 283 | elseif arguments[1] == "status" then 284 | plugin_manager:status() 285 | elseif arguments[1] == "update" then 286 | -- editor:display_info(tostring(local_copy) .. " locally vs " .. tostring(latest_copy) .. " latest") 287 | editor:display_info("Please wait whilst versions are checked...") 288 | editor:rerender_feedback_line() 289 | local outdated = {} 290 | for _, plugin in ipairs(plugins) do 291 | local name = plugin:match("([^/\\]+)%.lua$") 292 | local local_copy = plugin_manager:local_version(name) 293 | local latest_copy = plugin_manager:latest_version(name) 294 | if local_copy ~= latest_copy then 295 | table.insert(outdated, {name, local_copy, latest_copy}) 296 | end 297 | end 298 | for _, data in ipairs(outdated) do 299 | local name = data[1] 300 | local local_copy = data[2] 301 | local latest_copy = data[3] 302 | local response = editor:prompt( 303 | string.format( 304 | "%s needs an update: you have %s, latest is %s, update plugin? (y/n)", 305 | name, 306 | local_copy, 307 | latest_copy 308 | ) 309 | ) 310 | if response == "y" then 311 | editor:display_info("Updating " .. name .. ", please wait...") 312 | editor:rerender_feedback_line() 313 | local result = plugin_manager:download_plugin(name) 314 | if result ~= nil then 315 | editor:display_error("Failed to download plug-in: " .. result) 316 | return 317 | end 318 | end 319 | end 320 | editor:display_info("Update check-up completed, you're all set") 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /src/plugin/run.lua: -------------------------------------------------------------------------------- 1 | -- Code for running and processing plug-ins 2 | 3 | global_event_mapping = {} 4 | 5 | function merge_event_mapping() 6 | for key, f in pairs(event_mapping) do 7 | if global_event_mapping[key] ~= nil then 8 | table.insert(global_event_mapping[key], f) 9 | else 10 | global_event_mapping[key] = {f,} 11 | end 12 | end 13 | event_mapping = {} 14 | end 15 | 16 | for c, path in ipairs(plugins) do 17 | merge_event_mapping() 18 | dofile(path) 19 | end 20 | merge_event_mapping() 21 | 22 | -- Function to remap keys if necessary 23 | function remap_keys(from, to) 24 | local has_name = global_event_mapping[from] ~= nil 25 | local has_char = global_event_mapping[to] ~= nil 26 | if has_name then 27 | if has_char then 28 | -- Append name to char 29 | for i = 1, #global_event_mapping[from] do 30 | table.insert(global_event_mapping[to], global_event_mapping[from][i]) 31 | end 32 | global_event_mapping[from] = nil 33 | else 34 | -- Transfer name to char 35 | global_event_mapping[to] = global_event_mapping[from] 36 | global_event_mapping[from] = nil 37 | end 38 | end 39 | end 40 | 41 | -- Remap space keys 42 | remap_keys("space", " ") 43 | remap_keys("ctrl_space", "ctrl_ ") 44 | remap_keys("alt_space", "alt_ ") 45 | remap_keys("ctrl_alt_space", "ctrl_alt_ ") 46 | remap_keys("shift_tab", "shift_backtab") 47 | remap_keys("before:space", "before: ") 48 | remap_keys("before:ctrl_space", "before:ctrl_ ") 49 | remap_keys("before:alt_space", "before:alt_ ") 50 | remap_keys("before:ctrl_alt_space", "before:ctrl_alt_ ") 51 | remap_keys("before:shift_tab", "before:shift_backtab") 52 | 53 | -- Show warning if any plugins weren't able to be loaded 54 | if plugin_issues then 55 | print("Various plug-ins failed to load") 56 | print("You may download these plug-ins by running the command `plugin install [plugin_name]`") 57 | print("") 58 | print("Alternatively, you may silence these warnings\nby removing the load_plugin() lines in your configuration file\nfor the missing plug-ins that are listed above") 59 | end 60 | -------------------------------------------------------------------------------- /src/pty.rs: -------------------------------------------------------------------------------- 1 | //! User friendly interface for dealing with pseudo terminals 2 | 3 | use mio::unix::SourceFd; 4 | use mio::{Events, Interest, Poll, Token}; 5 | use mlua::prelude::*; 6 | use nix::fcntl::{fcntl, FcntlArg, OFlag}; 7 | use ptyprocess::PtyProcess; 8 | use std::io::{BufReader, Read, Result, Write}; 9 | use std::os::unix::io::AsRawFd; 10 | use std::process::Command; 11 | use std::sync::{Arc, Mutex}; 12 | use std::time::Duration; 13 | 14 | #[derive(Debug)] 15 | pub struct Pty { 16 | pub process: PtyProcess, 17 | pub output: String, 18 | pub input: String, 19 | pub shell: Shell, 20 | pub force_rerender: bool, 21 | } 22 | 23 | #[derive(Debug, Clone, Copy)] 24 | pub enum Shell { 25 | Bash, 26 | Dash, 27 | Zsh, 28 | Fish, 29 | } 30 | 31 | impl Shell { 32 | pub fn manual_input_echo(self) -> bool { 33 | matches!(self, Self::Bash | Self::Dash) 34 | } 35 | 36 | pub fn inserts_extra_newline(self) -> bool { 37 | !matches!(self, Self::Zsh) 38 | } 39 | 40 | pub fn command(&self) -> &str { 41 | match self { 42 | Self::Bash => "bash", 43 | Self::Dash => "dash", 44 | Self::Zsh => "zsh", 45 | Self::Fish => "fish", 46 | } 47 | } 48 | } 49 | 50 | impl IntoLua for Shell { 51 | fn into_lua(self, lua: &Lua) -> LuaResult { 52 | let string = lua.create_string(self.command())?; 53 | Ok(LuaValue::String(string)) 54 | } 55 | } 56 | 57 | impl FromLua for Shell { 58 | fn from_lua(val: LuaValue, _: &Lua) -> LuaResult { 59 | Ok(if let LuaValue::String(inner) = val { 60 | if let Ok(s) = inner.to_str() { 61 | match s.to_owned().as_str() { 62 | "dash" => Self::Dash, 63 | "zsh" => Self::Zsh, 64 | "fish" => Self::Fish, 65 | _ => Self::Bash, 66 | } 67 | } else { 68 | Self::Bash 69 | } 70 | } else { 71 | Self::Bash 72 | }) 73 | } 74 | } 75 | 76 | impl Pty { 77 | pub fn new(shell: Shell) -> Result>> { 78 | let pty = Arc::new(Mutex::new(Self { 79 | process: PtyProcess::spawn(Command::new(shell.command()))?, 80 | output: String::new(), 81 | input: String::new(), 82 | shell, 83 | force_rerender: false, 84 | })); 85 | pty.lock().unwrap().process.set_echo(false, None)?; 86 | std::thread::sleep(std::time::Duration::from_millis(100)); 87 | pty.lock().unwrap().run_command("")?; 88 | // Spawn thread to constantly read from the terminal 89 | let pty_clone = Arc::clone(&pty); 90 | std::thread::spawn(move || loop { 91 | std::thread::sleep(std::time::Duration::from_millis(100)); 92 | let mut pty = pty_clone.lock().unwrap(); 93 | pty.force_rerender = matches!(pty.catch_up(), Ok(true)); 94 | std::mem::drop(pty); 95 | }); 96 | // Return the pty 97 | Ok(pty) 98 | } 99 | 100 | pub fn run_command(&mut self, cmd: &str) -> Result<()> { 101 | let mut stream = self.process.get_raw_handle()?; 102 | // Write the command 103 | write!(stream, "{cmd}")?; 104 | std::thread::sleep(std::time::Duration::from_millis(100)); 105 | if self.shell.manual_input_echo() { 106 | // println!("Adding (pre-cmd) {:?}", cmd); 107 | self.output += cmd; 108 | } 109 | // Read the output 110 | let mut reader = BufReader::new(stream); 111 | let mut buf = [0u8; 10240]; 112 | let bytes_read = reader.read(&mut buf)?; 113 | let mut output = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); 114 | // Add on the output 115 | if self.shell.inserts_extra_newline() { 116 | output = output.replace("\u{1b}[?2004l\r\r\n", ""); 117 | } 118 | // println!("Adding (aftercmd) \"{:?}\"", output); 119 | self.output += &output; 120 | Ok(()) 121 | } 122 | 123 | pub fn silent_run_command(&mut self, cmd: &str) -> Result<()> { 124 | self.output.clear(); 125 | self.run_command(cmd)?; 126 | if self.output.starts_with(cmd) { 127 | self.output = self.output.chars().skip(cmd.chars().count()).collect(); 128 | } 129 | Ok(()) 130 | } 131 | 132 | pub fn char_input(&mut self, c: char) -> Result<()> { 133 | self.input.push(c); 134 | if c == '\n' { 135 | // Return key pressed, send the input 136 | self.run_command(&self.input.to_string())?; 137 | self.input.clear(); 138 | } 139 | Ok(()) 140 | } 141 | 142 | pub fn char_pop(&mut self) { 143 | self.input.pop(); 144 | } 145 | 146 | pub fn clear(&mut self) -> Result<()> { 147 | self.output.clear(); 148 | self.run_command("\n")?; 149 | self.output = self.output.trim_start_matches('\n').to_string(); 150 | Ok(()) 151 | } 152 | 153 | pub fn catch_up(&mut self) -> Result { 154 | let stream = self.process.get_raw_handle()?; 155 | let raw_fd = stream.as_raw_fd(); 156 | let flags = fcntl(raw_fd, FcntlArg::F_GETFL).unwrap(); 157 | fcntl( 158 | raw_fd, 159 | FcntlArg::F_SETFL(OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK), 160 | ) 161 | .unwrap(); 162 | let mut source = SourceFd(&raw_fd); 163 | // Set up mio Poll and register the raw_fd 164 | let mut poll = Poll::new()?; 165 | let mut events = Events::with_capacity(128); 166 | poll.registry() 167 | .register(&mut source, Token(0), Interest::READABLE)?; 168 | match poll.poll(&mut events, Some(Duration::from_millis(100))) { 169 | Ok(()) => { 170 | // Data is available to read 171 | let mut reader = BufReader::new(stream); 172 | let mut buf = [0u8; 10240]; 173 | let bytes_read = reader.read(&mut buf)?; 174 | 175 | // Process the read data 176 | let mut output = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); 177 | if self.shell.inserts_extra_newline() { 178 | output = output.replace("\u{1b}[?2004l\r\r\n", ""); 179 | } 180 | 181 | // Append the output to self.output 182 | // println!("Adding (aftercmd) \"{:?}\"", output); 183 | self.output += &output; 184 | Ok(!output.is_empty()) 185 | } 186 | Err(e) => Err(e), 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | cargo tarpaulin -o Html --ignore-tests --skip-clean --workspace --exclude-files src/* src/editor/* src/config/* kaolinite/examples/cactus/src/* 2 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | git pull 2 | cargo build --release 3 | sudo cp target/release/ox /usr/bin/ox 4 | echo "Ox has been updated!" 5 | 6 | --------------------------------------------------------------------------------