16 |
17 |
18 |
19 |
20 | 
21 | 
22 | 
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 |
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'