├── .envrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── benches └── view │ └── draw_buffer.rs ├── build.rs ├── documentation ├── bin │ ├── build │ └── serve ├── images │ └── jump_mode.gif ├── mkdocs.yml ├── pages │ ├── configuration.md │ ├── images │ │ ├── jump_mode.gif │ │ ├── logo.svg │ │ └── terminal.ico │ ├── index.md │ ├── installation.md │ ├── stylesheets │ │ └── extra.css │ └── usage.md └── quick_start_guide ├── flake.lock ├── flake.nix ├── screenshot.png ├── snap └── snapcraft.yaml └── src ├── commands ├── application.rs ├── buffer.rs ├── confirm.rs ├── cursor.rs ├── git.rs ├── jump.rs ├── line_jump.rs ├── mod.rs ├── open.rs ├── path.rs ├── preferences.rs ├── search.rs ├── search_select.rs ├── selection.rs ├── view.rs └── workspace.rs ├── errors.rs ├── input ├── key_map │ ├── default.yml │ └── mod.rs └── mod.rs ├── lib.rs ├── main.rs ├── models ├── application │ ├── clipboard.rs │ ├── event.rs │ ├── mod.rs │ ├── modes │ │ ├── command │ │ │ ├── displayable_command.rs │ │ │ └── mod.rs │ │ ├── confirm.rs │ │ ├── insert.rs │ │ ├── jump │ │ │ ├── mod.rs │ │ │ ├── single_character_tag_generator.rs │ │ │ └── tag_generator.rs │ │ ├── line_jump.rs │ │ ├── mod.rs │ │ ├── open │ │ │ ├── displayable_path.rs │ │ │ ├── exclusions.rs │ │ │ └── mod.rs │ │ ├── path.rs │ │ ├── search.rs │ │ ├── search_select.rs │ │ ├── select.rs │ │ ├── select_line.rs │ │ ├── symbol_jump.rs │ │ ├── syntax.rs │ │ └── theme.rs │ └── preferences │ │ ├── default.yml │ │ └── mod.rs └── mod.rs ├── presenters ├── error.rs ├── mod.rs └── modes │ ├── confirm.rs │ ├── insert.rs │ ├── jump.rs │ ├── line_jump.rs │ ├── mod.rs │ ├── normal.rs │ ├── open.rs │ ├── path.rs │ ├── search.rs │ ├── search_select.rs │ ├── select.rs │ └── select_line.rs ├── themes ├── solarized_dark.tmTheme └── solarized_light.tmTheme ├── util ├── mod.rs ├── movement_lexer.rs ├── reflow.rs ├── selectable_vec.rs └── token.rs └── view ├── buffer ├── lexeme_mapper.rs ├── line_numbers.rs ├── mod.rs ├── render_cache.rs ├── render_state.rs ├── renderer.rs └── scrollable_region.rs ├── color ├── colors.rs ├── map.rs └── mod.rs ├── data.rs ├── event_listener.rs ├── mod.rs ├── presenter.rs ├── style.rs ├── terminal ├── buffer.rs ├── buffer_iterator.rs ├── cell.rs ├── cursor.rs ├── mod.rs ├── termion_terminal.rs └── test_terminal.rs └── theme_loader.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.config.os }} 11 | 12 | strategy: 13 | matrix: 14 | config: 15 | - os: ubuntu-latest 16 | target: x86_64-unknown-linux-musl 17 | - os: macos-latest 18 | target: aarch64-apple-darwin 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v3 23 | 24 | - name: Install Rust 25 | uses: actions-rs/toolchain@v1 26 | with: 27 | toolchain: stable 28 | profile: minimal 29 | components: rust-src 30 | 31 | - name: Install musl tools (Linux only) 32 | if: matrix.config.os == 'ubuntu-latest' 33 | run: sudo apt-get update && sudo apt-get install musl-tools -y 34 | 35 | - name: Add target 36 | run: rustup target add ${{ matrix.config.target }} 37 | 38 | - name: Build for target 39 | run: cargo build --release --target ${{ matrix.config.target }} 40 | 41 | - name: Rename binary 42 | run: mv target/${{ matrix.config.target }}/release/amp target/${{ matrix.config.target }}/release/amp-${{ matrix.config.target }} 43 | 44 | - name: Publish release 45 | uses: softprops/action-gh-release@v2 46 | with: 47 | files: target/${{ matrix.config.target }}/release/amp-${{ matrix.config.target }} 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build_and_test: 14 | name: cargo build/test 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | toolchain: 19 | - stable 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} 23 | - run: cargo build --verbose 24 | - run: cargo test --verbose 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv 2 | /parts 3 | /prime 4 | /result 5 | /snap/.snapcraft 6 | /stage 7 | /target 8 | *.snap 9 | *.swp 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.7.1 2 | 3 | * Added build revision to splash screen 4 | * Updated justify command to switch to normal mode after completion 5 | * Fixed an issue where justify command would strip trailing newline 6 | * Added support for multiple line length guides 7 | * Added the ability to run formatting tools, optionally on save 8 | * Fixed an issue where long paths in open mode would wrap/break UI 9 | * Stopped printing background colors, allowing transparent terminal backgrounds 10 | 11 | ### 0.7.0 12 | 13 | * Lots of under-the-hood improvements to syntax highlighting, as well as: 14 | * Replaced Oniguruma regex dependency with Rust-based fancy-regex 15 | * Better error handling 16 | * Improved terminal renderer error handling 17 | * Cursor types now change based on mode 18 | * Added ability to build amp using Nix 19 | * Added Wayland clipboard support 20 | * Updated to build using Rust 2021 edition 21 | * Added ability to reflow text based on line length guide 22 | * Improved unicode support when adding trailing newline 23 | * Added ability to configure syntax overrides based on file extension 24 | * Added ability to comment out a selection of text 25 | * Improved handling of very small terminal sizes 26 | * Updated to correctly restore terminal contents after quitting 27 | 28 | ### 0.6.2 29 | 30 | * Rewrote the build script's command parsing to work with Rust 1.41 31 | * See https://github.com/jmacdonald/amp/issues/173 for details 32 | 33 | ### 0.6.1 34 | 35 | * Added the ability to choose a syntax definition for the current buffer 36 | * Updated `git2` dependency 37 | * Disabled unused `git2` features, removing transitive openssl dependency 38 | * Fixed an issue where tabs were ignored when removing trailing whitespace (thanks, BearOve!) 39 | * Specified Rust 1.31.1 (2018 Edition) as the minimum required version 40 | 41 | ### 0.6.0 42 | 43 | * Added more information to quick start guide (thanks, John M-W!) 44 | * Added snapcraft build file (thanks, Alan Pope!) 45 | * Added proper delete key support (thanks, Jérôme Martin!) 46 | * Added https support to GitHub URL generation command (thanks, king6cong!) 47 | * Use a vendored version of OpenSSL (thanks, Cecile Tonglet!) 48 | * Fixed buffer::outdent_line when using hard tabs (thanks, Gaby!) 49 | * Fixed an issue where user syntax definitions were loaded after argument buffers (#122) 50 | * Update to compile with Rust 2018 edition 51 | * Added keybindings to support jumping directly into symbol and open modes from search mode 52 | * Handle missing themes gracefully, falling back to default (#149) 53 | * Migrate from termbox library to termion 54 | * Removes termbox's build process Python dependency 55 | * Adds 24-bit colour support 56 | * Built a TerminalBuffer to allow successive screen updates within a single 57 | render cycle without introducing screen flicker. 58 | * Improves support for UTF-8 grapheme clusters. 59 | * Since termbox uses 32-bit char values to represent cells, anything larger 60 | than 32 bits, even if spilled over into adjacent cells, will be overwritten 61 | by adjacent characters. The new TerminalBuffer type uses Cow<&str> values, 62 | allowing arbitrary-length cells, which will be streamed to the terminal 63 | and overlaid as a single visible "character", without any loss of data. 64 | * Created a new Presenter type to hold the contents of this buffer, as well 65 | as extract common bits of functionality from various mode-specific presenters. 66 | 67 | ### 0.5.2 68 | 69 | * Fixed a regression that would raise an error when trying to open Amp with a 70 | new file argument 71 | * See https://github.com/jmacdonald/amp/issues/112 for details 72 | 73 | ### 0.5.1 74 | 75 | * Added ability to open Amp in another directory via `amp path/to/directory` 76 | * Improved newline indentation heuristics 77 | * See https://github.com/jmacdonald/amp/issues/103 for details 78 | * Added `>` prefix and bold style to selection in search/select mode 79 | * See https://github.com/jmacdonald/amp/issues/106 for details 80 | * Amp will now refresh its syntax definition after a buffer's path is changed 81 | * See https://github.com/jmacdonald/amp/issues/97 for details 82 | * Added a quick start guide, referenced from the splash page 83 | * Added suspend command key binding to search/select normal mode 84 | * Added the ability to configure number of results in search/select mode 85 | * See https://amp.rs/docs/configuration/#searchselect-results for details 86 | * Updated `termbox-sys` dependency, which fixes `.termbox already exists` build errors 87 | * See https://github.com/gchp/termbox-sys/issues/11 for details 88 | 89 | ### 0.5.0 90 | 91 | * Added caching to syntax highlighting, to improve performance for large buffers 92 | * See https://medium.com/@jordan_98525/incremental-parsing-in-amp-ba5e8c3e85dc for details 93 | 94 | ### 0.4.1 95 | 96 | * Fixed syntax highlighting 97 | * Scopes were bleeding into one another; we now defer to HighlightIterator 98 | * See https://github.com/jmacdonald/amp/issues/22 for details 99 | 100 | ### 0.4.0 101 | 102 | * Application event loop is now threaded 103 | * Most notably, open mode indexing is now run in a separate thread 104 | * Scrolling is now line wrap-aware 105 | * View now redraws when terminal is resized 106 | * Search/select modes now have empty state messages 107 | * e.g. open mode will now display "Enter a query" rather than "No results" when no query is present 108 | * Open mode now displays its path when indexing 109 | * Escape in normal mode now scrolls cursor to center, via new default keybinding 110 | * app_dirs dependency bumped to a version that compiles on newer versions of Rust 111 | * Type-specific configuration now supports full filenames (e.g. "Makefile") 112 | * Various refactoring 113 | 114 | ### 0.3.4 115 | 116 | * Documentation updates 117 | * Added the ability to save new buffers without paths (created in normal mode 118 | using the `B` key binding); a new "path" mode prompts before saving. 119 | * Added the ability to load user/custom themes from the `themes` configuration 120 | sub-directory 121 | * Added a benchmark for buffer rendering 122 | * Bumped native clipboard library dependency 123 | * Added semi-colon delete key binding to select line mode 124 | 125 | ### 0.3.3 126 | 127 | * Documentation updates 128 | * buffer::backspace command no longer switches to insert mode 129 | (this is relegated to the default keymap) 130 | * Invalid keymap configurations now display the offending mode 131 | 132 | ### 0.3.2 133 | 134 | * Fix case-insensitive open mode search with uppercase characters 135 | * Add class and struct identifiers to symbol mode whitelist 136 | * Documentation and README updates 137 | 138 | ### 0.3.1 139 | 140 | * Bumped copyright year to 2018 141 | * Updated CI config to run on stable release channel 142 | * Documentation site and content updates 143 | * Added `application::display_default_keymap` command 144 | * Added ability to delete current result in search mode 145 | 146 | ### 0.3.0 147 | 148 | * Switched to Rust stable release channel 149 | * New command mode (run any built-in commands through a search/select UI) 150 | * User-defined preferences, syntaxes, and keymaps 151 | * New confirm mode, applied primarily to closing or reloading buffers 152 | * New command to view syntax scope at cursor 153 | * Extracted all logic from input handlers 154 | * Migrated input handling to simple key => command mappings 155 | * New select_all command 156 | * Updated native clipboard library 157 | 158 | 159 | ### 0.2.0 160 | 161 | * Added theme selection mode 162 | * Quality improvements to command error reporting 163 | * Updated search mode to better handle "no matches" state 164 | * Treat hash/pound symbol as delimeter when using word-based movement 165 | * Added initial preference implementation 166 | * Under the hood improvements to search/select modes (open, symbol, theme, etc.) 167 | * Updated search/select modes to perform case insensitive searches 168 | 169 | ### 0.1.0 170 | 171 | * Initial release 172 | * Added proper error handling to all commands 173 | * Updated main loop to display command errors 174 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | 3 | name = "amp" 4 | version = "0.7.1" 5 | authors = ["Jordan MacDonald "] 6 | description = "A complete text editor for your terminal." 7 | homepage = "https://amp.rs" 8 | repository = "https://github.com/jmacdonald/amp" 9 | documentation = "https://amp.rs/docs" 10 | readme = "README.md" 11 | license-file = "LICENSE" 12 | keywords = ["text", "editor", "terminal", "modal"] 13 | edition="2021" 14 | 15 | [build-dependencies] 16 | regex = "1.10" 17 | 18 | [dependencies] 19 | app_dirs2 = "2.5" 20 | scribe = "0.8" 21 | bloodhound = "0.5.5" 22 | luthor = "0.2" 23 | fragment = "0.3" 24 | regex = "1.10" 25 | libc = "0.2" 26 | syntect = "5.1" 27 | termion = "2.0" 28 | error-chain = "0.12" 29 | unicode-segmentation = "1.10" 30 | cli-clipboard = "0.4" 31 | yaml-rust = "0.4" 32 | smallvec = "1.11" 33 | lazy_static = "1.4" 34 | mio = "0.6" 35 | serial_test = "3.2.0" 36 | 37 | [dependencies.signal-hook] 38 | version = "0.1" 39 | features = ["mio-support"] 40 | 41 | [dependencies.git2] 42 | version = "0.19" 43 | # We use very little of the Git crate. Disabling its default features makes it 44 | # as bare as possible, and sidesteps its openssl dependency, among others. 45 | default-features = false # removes unused openssl dependency 46 | 47 | [dev-dependencies] 48 | criterion = "0.5" 49 | 50 | [[bench]] 51 | name = "draw_buffer" 52 | path = "benches/view/draw_buffer.rs" 53 | harness = false 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amp: A text editor for your terminal. 2 | 3 | Heavily inspired by Vi/Vim. Amp aims to take the core interaction model of Vim, 4 | simplify it, and bundle in the essential features required for a modern text 5 | editor. 6 | 7 | ![Amp screenshot](screenshot.png?raw=true "Amp") 8 | 9 | Written with :heart: in [Rust](http://rust-lang.org). 10 | 11 | Amp's internals (data structures, syntax highlighting, workspace management, etc.) 12 | have been built as a separate crate: [scribe](https://github.com/jmacdonald/scribe). 13 | 14 | For a full overview, along with documentation and installation instructions, visit [amp.rs](https://amp.rs). 15 | -------------------------------------------------------------------------------- /benches/view/draw_buffer.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate criterion; 3 | 4 | use amp::Application; 5 | use criterion::Criterion; 6 | use std::path::PathBuf; 7 | 8 | fn buffer_rendering(c: &mut Criterion) { 9 | let mut app = Application::new(&Vec::new()).unwrap(); 10 | app.workspace 11 | .open_buffer(&PathBuf::from("src/commands/buffer.rs")) 12 | .unwrap(); 13 | app.view 14 | .initialize_buffer(app.workspace.current_buffer.as_mut().unwrap()) 15 | .unwrap(); 16 | let buffer_data = app.workspace.current_buffer.as_ref().unwrap().data(); 17 | 18 | c.bench_function("buffer rendering", move |b| { 19 | b.iter(|| { 20 | let mut presenter = app.view.build_presenter().unwrap(); 21 | 22 | presenter 23 | .print_buffer( 24 | app.workspace.current_buffer.as_ref().unwrap(), 25 | &buffer_data, 26 | &app.workspace.syntax_set, 27 | None, 28 | None, 29 | ) 30 | .unwrap() 31 | }) 32 | }); 33 | } 34 | 35 | fn scrolled_buffer_rendering(c: &mut Criterion) { 36 | let mut app = Application::new(&Vec::new()).unwrap(); 37 | app.workspace 38 | .open_buffer(&PathBuf::from("src/commands/buffer.rs")) 39 | .unwrap(); 40 | app.view 41 | .initialize_buffer(app.workspace.current_buffer.as_mut().unwrap()) 42 | .unwrap(); 43 | let buffer_data = app.workspace.current_buffer.as_ref().unwrap().data(); 44 | 45 | // Scroll to the bottom of the buffer. 46 | app.workspace 47 | .current_buffer 48 | .as_mut() 49 | .unwrap() 50 | .cursor 51 | .move_to_last_line(); 52 | app.view 53 | .scroll_to_cursor(app.workspace.current_buffer.as_ref().unwrap()) 54 | .unwrap(); 55 | 56 | c.bench_function("scrolled buffer rendering", move |b| { 57 | b.iter(|| { 58 | let mut presenter = app.view.build_presenter().unwrap(); 59 | 60 | presenter 61 | .print_buffer( 62 | app.workspace.current_buffer.as_ref().unwrap(), 63 | &buffer_data, 64 | &app.workspace.syntax_set, 65 | None, 66 | None, 67 | ) 68 | .unwrap() 69 | }) 70 | }); 71 | } 72 | 73 | criterion_group!(benches, buffer_rendering, scrolled_buffer_rendering); 74 | criterion_main!(benches); 75 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::env; 3 | use std::fs::{self, read_to_string, File}; 4 | use std::io::Write; 5 | use std::path::Path; 6 | use std::process::Command; 7 | use std::result::Result; 8 | 9 | const COMMAND_REGEX: &str = r"pub fn (.*)\(app: &mut Application\) -> Result"; 10 | 11 | fn main() { 12 | generate_commands(); 13 | set_build_revision(); 14 | } 15 | 16 | /// This build task generates a Rust snippet which, when included later on in 17 | /// build process, adds logic to construct a HashMap for all 18 | /// public commands declared in the commands module. This facilitates runtime 19 | /// command referencing via string, which is required for command mode, as well 20 | /// as user-defined keymaps. 21 | fn generate_commands() { 22 | let mut output = create_output_file().unwrap(); 23 | write_commands(&mut output).unwrap(); 24 | finalize_output_file(&mut output).unwrap(); 25 | } 26 | 27 | fn create_output_file() -> Result { 28 | let out_dir = env::var("OUT_DIR").expect("The compiler did not provide $OUT_DIR"); 29 | let out_file: std::path::PathBuf = [&out_dir, "hash_map"].iter().collect(); 30 | let mut file = File::create(&out_file).map_err(|_| { 31 | format!( 32 | "Couldn't create output file: {}", 33 | out_file.to_string_lossy() 34 | ) 35 | })?; 36 | file.write( 37 | "{\n let mut commands: HashMap<&'static str, Command> = HashMap::new();\n".as_bytes(), 38 | ) 39 | .map_err(|_| "Failed to write command hash init")?; 40 | 41 | Ok(file) 42 | } 43 | 44 | fn write_commands(output: &mut File) -> Result<(), &str> { 45 | let expression = Regex::new(COMMAND_REGEX).expect("Failed to compile command matching regex"); 46 | let entries = 47 | fs::read_dir("./src/commands/").map_err(|_| "Failed to read command module directory")?; 48 | for entry in entries { 49 | let path = entry 50 | .map_err(|_| "Failed to read command module directory entry")? 51 | .path(); 52 | let module_name = module_name(&path).unwrap(); 53 | let content = read_to_string(&path).map_err(|_| "Failed to read command module data")?; 54 | for captures in expression.captures_iter(&content) { 55 | let function_name = captures.get(1).unwrap().as_str(); 56 | write_command(output, &module_name, function_name)?; 57 | } 58 | } 59 | 60 | Ok(()) 61 | } 62 | 63 | fn write_command( 64 | output: &mut File, 65 | module_name: &str, 66 | function_name: &str, 67 | ) -> Result { 68 | output 69 | .write( 70 | format!( 71 | " commands.insert(\"{module_name}::{function_name}\", {module_name}::{function_name});\n" 72 | ) 73 | .as_bytes(), 74 | ) 75 | .map_err(|_| "Failed to write command") 76 | } 77 | 78 | fn finalize_output_file(output: &mut File) -> Result { 79 | output 80 | .write(" commands\n}\n".as_bytes()) 81 | .map_err(|_| "Failed to write command hash return") 82 | } 83 | 84 | fn module_name(path: &Path) -> Result { 85 | path.file_name() 86 | .and_then(|name| { 87 | name.to_string_lossy() 88 | .split('.') 89 | .next() 90 | .map(|n| n.to_string()) 91 | }) 92 | .ok_or("Unable to parse command module from file name") 93 | } 94 | 95 | fn set_build_revision() { 96 | // Skip if the environment variable is already set 97 | let revision = env::var("BUILD_REVISION"); 98 | if revision.map(|r| !r.is_empty()) == Ok(true) { 99 | return; 100 | } 101 | 102 | // Run the Git command to get the current commit hash 103 | let output = Command::new("git") 104 | .args(&["rev-parse", "--short", "HEAD"]) 105 | .output() 106 | .expect("Failed to execute git command"); 107 | 108 | // Parse the hash 109 | let build_revision = String::from_utf8(output.stdout).expect("Invalid UTF-8 sequence"); 110 | 111 | // Write the hash as an environment variable 112 | println!("cargo:rustc-env=BUILD_REVISION={}", build_revision.trim()); 113 | } 114 | -------------------------------------------------------------------------------- /documentation/bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run --rm -it -p 8000:8000 -v ${PWD}:/docs -v ${BUILD_TARGET_DIR}:/docs/site squidfunk/mkdocs-material build 4 | -------------------------------------------------------------------------------- /documentation/bin/serve: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run --rm -it -p 8000:8000 -v ${PWD}:/docs squidfunk/mkdocs-material 4 | -------------------------------------------------------------------------------- /documentation/images/jump_mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmacdonald/amp/b245baded326ea989633ea81384656379bee3f3a/documentation/images/jump_mode.gif -------------------------------------------------------------------------------- /documentation/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Amp 2 | docs_dir: 'pages' 3 | theme: 4 | logo: 'images/logo.svg' 5 | favicon: 'images/terminal.ico' 6 | name: 'material' 7 | palette: 8 | primary: 'blue grey' 9 | accent: 'red' 10 | features: 11 | - content.code.annotate 12 | repo_name: 'jmacdonald/amp' 13 | repo_url: 'https://github.com/jmacdonald/amp' 14 | nav: 15 | - 'index.md' 16 | - 'installation.md' 17 | - 'usage.md' 18 | - 'configuration.md' 19 | extra: 20 | palette: 21 | primary: 'blue grey' 22 | accent: 'red' 23 | markdown_extensions: 24 | - admonition 25 | - attr_list 26 | - md_in_html 27 | - pymdownx.highlight 28 | - pymdownx.superfences 29 | - pymdownx.smartsymbols 30 | extra_css: 31 | - 'stylesheets/extra.css' 32 | copyright: 'Copyright © 2015 - 2018 Jordan MacDonald' 33 | -------------------------------------------------------------------------------- /documentation/pages/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Amp uses a YAML file to define preferences that sit in a platform-dependent configuration folder. The easiest way to edit these is to use the built-in `preferences::edit` command, which can be run in command mode. There's a corresponding `reload` command, too, if you persist any changes. 4 | 5 | !!! tip 6 | If you want to version this file, the aforementioned `edit` command will 7 | display the full path at the bottom of the screen once the preferences have 8 | been loaded into a new buffer for editing. 9 | 10 | ## General Options 11 | 12 | ### Theme 13 | 14 | ```yaml 15 | theme: solarized_dark 16 | ``` 17 | 18 | Used to specify the default theme. Values can be located through Amp's theme mode. 19 | 20 | !!! tip 21 | You can configure the current theme without making a permanent configuration 22 | change. Hit `t` to pick a theme that'll only last until you close the editor. 23 | It's handy for temporarily changing to a lighter theme when working outdoors, 24 | or vice-versa. 25 | 26 | ### Tab Width 27 | 28 | ```yaml 29 | tab_width: 2 30 | ``` 31 | 32 | Determines the visual width of tab characters, and when `soft_tabs` is `true`, determines the number of spaces to insert when a soft tab is inserted. 33 | 34 | ### Soft Tabs 35 | 36 | ```yaml 37 | soft_tabs: true 38 | ``` 39 | 40 | This setting configures the type of tabs used in insert mode. 41 | See: the infamous tabs vs. spaces debate. 42 | 43 | ### Line Length Guide 44 | 45 | ```yaml 46 | line_length_guide: 80 47 | # or 48 | line_length_guide: 49 | - 80 50 | - 100 51 | ``` 52 | 53 | When set to a positive integer, this renders a background vertical line at the specified offset, to guide line length. When set to `false`, the guide is hidden. 54 | 55 | It can also be set to an array, in which case a vertical line is rendered at each specified offset. 56 | 57 | ### Line Wrapping 58 | 59 | ```yaml 60 | line_wrapping: true 61 | ``` 62 | 63 | When set to `true`, lines extending beyond the visible region are wrapped to the line below. 64 | 65 | ## File Format-Specific Options 66 | 67 | The `tab_width` and `soft_tabs` options can be configured on a per-extension basis: 68 | 69 | ```yaml 70 | types: 71 | rs: 72 | tab_width: 4 73 | soft_tabs: true 74 | go: 75 | tab_width: 8 76 | soft_tabs: false 77 | ``` 78 | 79 | For setting options for common files _without_ extensions, use a file name: 80 | 81 | ```yaml 82 | types: 83 | Makefile: 84 | tab_width: 4 85 | soft_tabs: false 86 | ``` 87 | 88 | ### Line Commenting 89 | ```yaml 90 | types: 91 | rs: 92 | line_comment_prefix: // 93 | ``` 94 | 95 | This can be used to set the character (sequence) used by the `buffer::toggle_line_comment` 96 | command for adding (or removing) single-line comments on a per-extension or per-file basis. 97 | An additional whitespace character will also be inserted between prefix and line content. 98 | 99 | ### Format Tools 100 | ```yaml 101 | types: 102 | rs: 103 | format_tool: 104 | command: rustfmt 105 | options: ["--edition", "2021"] 106 | run_on_save: true 107 | ``` 108 | 109 | Many newer languages offer tools that automatically reformat code to conform to 110 | their official style guide. Amp can be configured to work with these tools by 111 | defining a type-specific `format_tool` setting, with the following behaviour: 112 | 113 | * `command`: executable (either in your $PATH or referenced absolutely) 114 | * `options`: array of command-line options, split by whitespace 115 | * `run_on_save`: whether to automatically run the configured tool on buffer save 116 | 117 | Once configured, you can run the format tool on open buffers matching the configured typed 118 | using `buffer::format` in [command mode](usage.md#running-commands). 119 | 120 | ## Key Bindings 121 | 122 | In Amp, key bindings are simple key/command associations, scoped to a specific mode. You can define custom key bindings by defining a keymap in your preferences file: 123 | 124 | ```yaml 125 | keymap: 126 | normal: 127 | j: "cursor::move_down" 128 | ``` 129 | 130 | !!! tip 131 | Wondering where to find command names? You can view the full list in a new buffer by running `application::display_available_commands` using [command mode](usage.md#running-commands). You can also view Amp's default key bindings by running `application::display_default_keymap`. 132 | 133 | ### Modifiers 134 | 135 | Amp supports qualifying key bindings with a `ctrl` modifier: 136 | 137 | ```yaml 138 | keymap: 139 | normal: 140 | ctrl-s: "buffer::save" 141 | ``` 142 | 143 | ### Wildcards 144 | 145 | You can also use wildcards in key bindings: 146 | 147 | ```yaml 148 | keymap: 149 | normal: 150 | _: "buffer::insert_char" 151 | ``` 152 | 153 | More specific key bindings will override wildcard values, making them useful as a fallback value: 154 | 155 | ``` 156 | ... 157 | _: "buffer::insert_char" 158 | s: "buffer::save" 159 | ``` 160 | 161 | ### Multiple Commands 162 | 163 | You can also pass a collection of commands to run. Amp will run all of the commands in order, stopping if/when any errors occur: 164 | 165 | ```yaml 166 | keymap: 167 | normal: 168 | v: 169 | - "application::switch_to_select_mode" 170 | - "application::switch_to_jump_mode" 171 | ``` 172 | 173 | !!! tip 174 | It may not be readily apparent, but chaining commands like this is powerful. A significant portion of Amp's functionality is 175 | built by composing multiple commands into larger, more complex ones. 176 | 177 | ## Format/Language Support 178 | 179 | Most popular formats and languages have syntax highlighting and symbol support out of the box. If you have a file open that _isn't_ higlighted, there are a few things you can do. 180 | 181 | ### Manually Picking a Definition 182 | 183 | It's possible Amp has a syntax definition for the current file, but it's not being applied because it doesn't recognize the current filename or extension as having been associated with the definition. You can explicitly apply a definition by pressing `#` to enter syntax mode, and searching by the language/format name. 184 | 185 | To make this syntax selection permanent, you can specify it in your preferences file: 186 | 187 | ```yaml 188 | types: 189 | rs: 190 | syntax: Rust 191 | ``` 192 | 193 | ### Adding a New Definition 194 | 195 | If the language/format you're using isn't highlighted, and you can't find its definition using the manual selection tool described above, you'll need to add it. You can extend the built-in set with custom syntax definitions. Amp uses Sublime Text's [`.sublime-syntax`](https://www.sublimetext.com/docs/3/syntax.html) files, which can be placed in Amp's `syntaxes` configuration subdirectory. 196 | 197 | !!! tip 198 | If you're not sure where to look, run the `preferences::edit` command. 199 | The preferences will load into a new buffer for editing, and its path 200 | will be shown at the bottom of the screen; the `syntaxes` subdirectory is in 201 | the same directory as that file. 202 | 203 | ## Themes 204 | 205 | Amp includes [Solarized](http://ethanschoonover.com/solarized) dark and light themes by default. You can extend the built-in set with custom themes of your own. Amp uses Text Mate's `.tmTheme` format, many of which can be found [here](https://github.com/filmgirl/TextMate-Themes). They should be placed in Amp's `themes` configuration subdirectory. 206 | 207 | !!! tip 208 | If you're not sure where to look, run the `preferences::edit` command. 209 | The preferences will load into a new buffer for editing, and its path 210 | will be shown at the bottom of the screen; the `themes` subdirectory is in 211 | the same directory as that file. 212 | 213 | ## Open Mode 214 | 215 | ### Excluding Files/Directories 216 | 217 | Using Unix shell-style glob patterns, Amp's file finder can be configured to exclude files and directories: 218 | 219 | ```yaml 220 | open_mode: 221 | exclusions: 222 | - "**/.git" 223 | - "**/.svn" 224 | ``` 225 | 226 | You can also opt out of exclusions altogether by setting the value to `false`: 227 | 228 | ```yaml 229 | open_mode: 230 | exclusions: false 231 | ``` 232 | 233 | ## Miscellaneous 234 | 235 | ### Search/Select Results 236 | 237 | The UI component used in open mode (and command mode, symbol jump mode, etc.) 238 | is referred to as _search/select_, internally. You can configure the number of 239 | results shown for any mode that uses this component: 240 | 241 | ```yaml 242 | search_select: 243 | max_results: 5 244 | ``` 245 | -------------------------------------------------------------------------------- /documentation/pages/images/jump_mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmacdonald/amp/b245baded326ea989633ea81384656379bee3f3a/documentation/pages/images/jump_mode.gif -------------------------------------------------------------------------------- /documentation/pages/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 48 | 53 | 54 | 56 | 57 | 59 | image/svg+xml 60 | 62 | 63 | 64 | 65 | 66 | 72 | 79 | 80 | 85 | 89 | 94 | 100 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /documentation/pages/images/terminal.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmacdonald/amp/b245baded326ea989633ea81384656379bee3f3a/documentation/pages/images/terminal.ico -------------------------------------------------------------------------------- /documentation/pages/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Amp is inspired by [Vim](https://vim.sourceforge.io)'s modal approach to 4 | text editing, which is reflected in several of its default key bindings. 5 | That similarity aside, there are several key differences. 6 | 7 | Above all else, Amp aims to _keep things as simple as possible_. There are 8 | already plenty of highly-configurable editors available. At its core, Amp aims 9 | to minimize configuration and provide a great out-of-the-box experience. The 10 | following sections describe some of the ideas central to the design of Amp that 11 | differentiate it from other options. 12 | 13 | ### UX 14 | 15 | Like Vim, Amp is a modal editor: keystrokes perform different functions based 16 | on the current mode. Many familiar modes (insert, normal, select, etc.) are 17 | available, as well as several new ones providing additional functionality. 18 | 19 | ### Essential Features 20 | 21 | Amp's primary audience is _developers_. 22 | 23 | Syntax highlighting, a fuzzy file finder, local symbol jump, and basic Git 24 | integration are available without additional configuration or external 25 | dependencies (e.g. plug-ins, ctags, external indexing binaries). 26 | 27 | ### Configuration 28 | 29 | Amp shouldn't require any initial configuration. User preferences live in a 30 | single YAML file and have sensible defaults. There's also a built-in command to 31 | easily edit this file without having to leave the application. 32 | 33 | ### Considerations 34 | 35 | Although still in its infancy, Amp is suitable for day-to-day use, with a few 36 | exceptions. There are features not yet in place; some are planned, others are not. 37 | 38 | ##### Encoding 39 | 40 | Amp only supports UTF-8 (and by proxy, ASCII). Supporting other encoding types 41 | is not planned. Windows line endings (CRLF) are also currently unsupported. 42 | 43 | ##### Split Panes 44 | 45 | Unlike Vim, Amp doesn't provide split panes, and support isn't planned. It's 46 | recommended to use [tmux](https://github.com/tmux/tmux/wiki) instead, which 47 | provides this (and much more) for your shell, text editor, and any other 48 | terminal-based applications you may use. 49 | 50 | ##### Plug-ins 51 | 52 | Many editors allow users to extend and change much of their behaviour through 53 | the use of plug-ins. This is not a goal for Amp. However, in spite of its focus 54 | on being "complete" from the start, an avenue for extended language, framework, 55 | and workflow support is necessary. Features like "go to definition" require 56 | non-trivial language support, and are great candidates for plug-ins. 57 | 58 | As a result, _Amp will eventually include a runtime and plug-in API_, providing 59 | the ability to define new commands. 60 | -------------------------------------------------------------------------------- /documentation/pages/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Instructions to compile from source can be found below. If your OS isn't listed, 4 | you'll need to follow the manual installation instructions and install the 5 | specified dependencies (build dependencies can be removed after compilation, 6 | if desired). 7 | 8 | Adding support for your preferred distro is a great way to contribute to the 9 | project! 10 | 11 | ## NixOS 12 | 13 | If you're using flakes, you can add Amp as an input to your configuration and 14 | track the main branch. Here's what that looks like in a simplified example, 15 | with Amp available in plain NixOS and Home Manager configurations: 16 | 17 | ```nix title="flake.nix" hl_lines="12-15 22 32 45" 18 | { 19 | description = "System config"; 20 | 21 | inputs = { 22 | nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; 23 | 24 | home-manager = { 25 | url = "github:nix-community/home-manager/release-24.05"; 26 | inputs.nixpkgs.follows = "nixpkgs"; 27 | }; 28 | 29 | amp = { 30 | url = "github:jmacdonald/amp/main"; 31 | inputs.nixpkgs.follows = "nixpkgs"; 32 | }; 33 | }; 34 | 35 | outputs = { 36 | self, 37 | nixpkgs, 38 | home-manager, 39 | amp, 40 | ... 41 | } @ inputs: let 42 | inherit (self) outputs; 43 | in { 44 | nixosConfigurations = { 45 | desktop = nixpkgs.lib.nixosSystem { 46 | system = "x86_64-linux"; 47 | 48 | specialArgs = { 49 | inherit inputs outputs amp; # (1)! 50 | }; 51 | 52 | # Main configuration 53 | modules = [ ./nixos ]; 54 | }; 55 | }; 56 | 57 | homeConfigurations = { 58 | "jmacdonald@desktop" = home-manager.lib.homeManagerConfiguration { 59 | pkgs = nixpkgs.legacyPackages.x86_64-linux; 60 | 61 | extraSpecialArgs = { 62 | inherit inputs outputs amp; # (2)! 63 | host = "desktop"; 64 | }; 65 | 66 | # Main configuration 67 | modules = [ ./home-manager/default.nix ]; 68 | }; 69 | }; 70 | }; 71 | } 72 | ``` 73 | 74 | 1. This adds the Amp flake to your NixOS config. 75 | 76 | 2. This adds the Amp flake to your Home Manager config. 77 | 78 | You can then use the flake in your NixOS/Home Manager modules: 79 | 80 | ```nix title="home-manager/default.nix" hl_lines="1 5" 81 | { pkgs, amp, ... }: { # (1)! 82 | 83 | # Text editors 84 | home.packages = [ 85 | amp.packages.${pkgs.system}.default # (2)! 86 | pkgs.vim 87 | ]; 88 | } 89 | ``` 90 | 91 | 1. Specify the flake as an argument to your module to get a reference to it. 92 | 2. Add Amp to the list of installed packages. This long format including the 93 | reference to `pkgs.system` is a necessary evil, since the Amp flake needs to 94 | know which system/architecture to target, and I've yet to find a way to set a 95 | default package that is able to automatically take that into consideration. 96 | 97 | Now you can update Amp by running the following: 98 | 99 | ```shell 100 | # Bump flake.lock 101 | nix flake lock --update-input amp 102 | 103 | # Build and switch to the new version 104 | home-manager switch --flake /path-to-your-nixos-conf 105 | ``` 106 | 107 | ## Arch Linux 108 | 109 | Available via [AUR](https://aur.archlinux.org/packages/amp): 110 | 111 | ```bash 112 | git clone https://aur.archlinux.org/amp.git 113 | cd amp 114 | makepkg -isr 115 | ``` 116 | 117 | ## macOS 118 | 119 | Available via [Homebrew](https://brew.sh): 120 | 121 | ```bash 122 | brew install amp 123 | ``` 124 | 125 | ## Manual installation 126 | 127 | ### Dependencies 128 | 129 | * `git` 130 | * `libxcb` (X11 clipboard support) 131 | * `openssl` 132 | * `zlib` 133 | 134 | ### Build dependencies 135 | 136 | * `cmake` 137 | * `rust` 138 | 139 | ### Building 140 | 141 | !!! info "Supported Release Channels" 142 | Amp's automated test suite is run using Rust's **stable** release channel; 143 | beta and nightly release channels are not officially supported. The oldest 144 | version of Rust currently supported is **1.38.0**. 145 | 146 | 1. Install Rust, either through your system's package manager or using [Rust's `rustup` toolchain management utility](https://www.rust-lang.org/en-US/install.html). 147 | 2. Install both the regular and build dependencies listed above. 148 | 3. Build and install: 149 | 150 | ``` 151 | cargo install amp 152 | ``` 153 | -------------------------------------------------------------------------------- /documentation/pages/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .md-logo img { 2 | width: 48px; 3 | } 4 | 5 | [data-md-component="title"] > span:first-child { 6 | display: none; 7 | } 8 | 9 | @media (max-width: 1219px) { 10 | label.md-nav__title[for="drawer"] { 11 | font-size: 0px; 12 | height: 80px; 13 | line-height: 0px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /documentation/quick_start_guide: -------------------------------------------------------------------------------- 1 | Quick Start Guide 2 | ================= 3 | 4 | Welcome to Amp! Before we get started, it's worth noting that there's a lot to 5 | cover. This guide isn't meant to be comprehensive; visit https://amp.rs/docs for 6 | a much more in-depth guide to using Amp. 7 | 8 | -- 9 | 10 | Right, let's get started. You're currently looking at an open buffer, which can 11 | be scrolled up and down using "," and "m", respectively. Go ahead and try them 12 | now; you're going to use these a lot, and they're the only keys you need to read 13 | the rest of this guide. 14 | 15 | Moving the Cursor 16 | ================= 17 | 18 | You can use the "hjkl" keys to move the cursor left, down, up, and right, just 19 | like in Vim. 20 | 21 | You can amplify these movements by holding shift. The "H" and "L" keys will go 22 | to the start and end of the current line, and the "J" and "K" keys will go to 23 | the last and first line, respectively. 24 | 25 | You can also use the "w" and "b" keys to move forwards and backwards one word at 26 | a time. 27 | 28 | Beyond that, you're going to want to use Amp's jump mode to navigate. This mode 29 | works by prefixing words with a jump token. Type the two letters in the jump 30 | token to move to the word. 31 | 32 | Type "f" to give it a shot. 33 | 34 | There's another version of jump mode that only uses single letter tokens. It's 35 | handy for moving around on the current line. Press the single quote key to use 36 | it. 37 | 38 | Editing 39 | ======= 40 | 41 | Like Vim, you can use "i" to enter insert mode and add content, and press "esc" 42 | to switch back to normal mode. You can also use "d" to delete the current word, 43 | or "c" to change it, instead. 44 | 45 | Selecting Text 46 | ============== 47 | 48 | Press the "v" key to enter select mode, and move the cursor to define a 49 | selection. You can use "d" and "c" to delete and change the selection, 50 | respectively. 51 | 52 | You can press "V" instead to use an alternative select mode, where entire lines 53 | are selected. 54 | 55 | Opening Files 56 | ============= 57 | 58 | Hit the space key to switch to open mode. Amp will start recursively indexing 59 | the current directory, which you can search. It's recommended to use small, 60 | space-separated tokens. Tokens must exist in the file's path to trigger a match, 61 | and they can be in any order. Once you've found a match, hit "enter". If it's 62 | further down the list, you can use the up/down keys, or hit "esc" and use the 63 | "jk" keys, instead. You can leave open mode by hitting "esc" again. 64 | 65 | If you'd like to create a new file, start by opening a new, empty buffer by 66 | pressing "B". 67 | 68 | Managing Files 69 | ============== 70 | 71 | You can switch between open files using the "tab" key. Press "s" to save the 72 | current buffer. If you're done with a buffer, you can close it by hitting "q". 73 | Amp will warn you if there are unsaved changes before closing the buffer. 74 | 75 | Exiting 76 | ======= 77 | 78 | When you're done with Amp, hit "Q" to quit. It's worth noting that Amp won't 79 | warn you about modified buffers when quitting. That's intentional! You may find 80 | it easier to close open buffers beforehand, and only quit from the splash 81 | screen. 82 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1731755305, 6 | "narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-24.11", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Amp: A complete text editor for your terminal"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; 6 | }; 7 | 8 | outputs = { self, nixpkgs }: 9 | let 10 | supportedSystems = [ "x86_64-linux" ]; 11 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 12 | in 13 | { 14 | # Define packages for all supported systems 15 | packages = forAllSystems (system: { 16 | default = self.buildPackage { inherit system; }; 17 | }); 18 | 19 | # Define dev shells for all supported systems 20 | devShells = forAllSystems (system: { 21 | default = self.buildShell { inherit system; }; 22 | }); 23 | 24 | # Function to build a dev shell for a given system 25 | buildShell = { system }: 26 | let pkgs = import nixpkgs { inherit system; }; 27 | in pkgs.mkShell { 28 | buildInputs = with pkgs; [ 29 | rustc 30 | cargo 31 | cargo-edit 32 | rustfmt 33 | rust-analyzer 34 | clippy 35 | ]; 36 | 37 | RUST_BACKTRACE = 1; 38 | }; 39 | 40 | # Function to build the package for a given system 41 | buildPackage = { system }: 42 | let pkgs = import nixpkgs { inherit system; }; 43 | in pkgs.rustPlatform.buildRustPackage { 44 | pname = "amp"; 45 | 46 | # Extract version from Cargo.toml 47 | version = builtins.head 48 | ( 49 | builtins.match ".*name = \"amp\"\nversion = \"([^\"]+)\".*" 50 | (builtins.readFile ./Cargo.toml) 51 | ); 52 | 53 | cargoLock.lockFile = ./Cargo.lock; 54 | 55 | # Use source files without version control noise 56 | src = pkgs.lib.cleanSource ./.; 57 | 58 | # Packages needed at runtime 59 | buildInputs = with pkgs; [ git xorg.libxcb openssl zlib ]; 60 | 61 | # Packages needed during the build 62 | nativeBuildInputs = with pkgs; [ git ]; 63 | 64 | # Make the build/check/install commands explicit so we can 65 | # provide the commit SHA for the splash screen 66 | buildPhase = '' 67 | export BUILD_REVISION=${ 68 | if self ? shortRev then self.shortRev else "" 69 | } 70 | echo "BUILD_REVISION=$BUILD_REVISION" 71 | 72 | cargo build --release 73 | ''; 74 | 75 | checkPhase = '' 76 | cargo test 77 | ''; 78 | 79 | installPhase = '' 80 | mkdir -p $out/bin 81 | cp target/release/amp $out/bin/ 82 | ''; 83 | 84 | # Amp creates files and directories in $HOME/.config/amp when run. 85 | # Since the checkPhase of the build process runs the test suite, we 86 | # need a writable location to avoid permission error test failures. 87 | HOME = "$src"; 88 | }; 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmacdonald/amp/b245baded326ea989633ea81384656379bee3f3a/screenshot.png -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: amp 2 | version: 0.5.2 3 | summary: "A complete text editor for your terminal." 4 | description: | 5 | Heavily inspired by Vi/Vim. Amp aims to take the core interaction model 6 | of Vim, simplify it, and bundle in the essential features required for 7 | a modern text editor. 8 | 9 | grade: stable 10 | confinement: classic 11 | 12 | parts: 13 | amp: 14 | plugin: rust 15 | source: ./ 16 | build-packages: 17 | - python 18 | - python3 19 | - libxmu-dev 20 | - libssl-dev 21 | - pkg-config 22 | - cmake 23 | 24 | apps: 25 | amp: 26 | command: bin/amp 27 | -------------------------------------------------------------------------------- /src/commands/confirm.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Result}; 2 | use crate::models::application::{Application, Mode}; 3 | 4 | pub fn confirm_command(app: &mut Application) -> Result { 5 | let command = if let Mode::Confirm(ref mode) = app.mode { 6 | mode.command 7 | } else { 8 | bail!("Can't confirm command outside of confirm mode"); 9 | }; 10 | 11 | command(app)?; 12 | commands::application::switch_to_normal_mode(app) 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/git.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Result}; 2 | use crate::errors; 3 | use crate::errors::*; 4 | use crate::models::application::{Application, ClipboardContent, Mode}; 5 | use git2; 6 | use regex::Regex; 7 | use std::cmp::Ordering; 8 | 9 | pub fn add(app: &mut Application) -> Result { 10 | let repo = app.repository.as_ref().ok_or("No repository available")?; 11 | let buffer = app 12 | .workspace 13 | .current_buffer 14 | .as_ref() 15 | .ok_or(BUFFER_MISSING)?; 16 | let mut index = repo 17 | .index() 18 | .chain_err(|| "Couldn't get the repository index")?; 19 | let buffer_path = buffer.path.as_ref().ok_or(BUFFER_PATH_MISSING)?; 20 | let repo_path = repo.workdir().ok_or("No path found for the repository")?; 21 | let relative_path = buffer_path 22 | .strip_prefix(repo_path) 23 | .chain_err(|| "Failed to build a relative buffer path")?; 24 | 25 | index 26 | .add_path(relative_path) 27 | .chain_err(|| "Failed to add path to index.")?; 28 | index.write().chain_err(|| "Failed to write index.") 29 | } 30 | 31 | pub fn copy_remote_url(app: &mut Application) -> Result { 32 | if let Some(ref mut repo) = app.repository { 33 | let buffer = app 34 | .workspace 35 | .current_buffer 36 | .as_ref() 37 | .ok_or(BUFFER_MISSING)?; 38 | let buffer_path = buffer.path.as_ref().ok_or(BUFFER_PATH_MISSING)?; 39 | let remote = repo 40 | .find_remote("origin") 41 | .chain_err(|| "Couldn't find a remote \"origin\"")?; 42 | let url = remote.url().ok_or("No URL for remote/origin")?; 43 | 44 | let gh_path = get_gh_path(url)?; 45 | 46 | let repo_path = repo.workdir().ok_or("No path found for the repository")?; 47 | let relative_path = buffer_path 48 | .strip_prefix(repo_path) 49 | .chain_err(|| "Failed to build a relative buffer path")?; 50 | 51 | let status = repo 52 | .status_file(relative_path) 53 | .chain_err(|| "Couldn't get status info for the specified path")?; 54 | if status.contains(git2::Status::WT_NEW) || status.contains(git2::Status::INDEX_NEW) { 55 | bail!("The provided path doesn't exist in the repository"); 56 | } 57 | 58 | // We want to build URLs that point to an object ID, so that they'll 59 | // refer to a snapshot of the file as it looks at this very moment. 60 | let mut revisions = repo 61 | .revwalk() 62 | .chain_err(|| "Couldn't build a list of revisions for the repository")?; 63 | 64 | // We need to set a starting point for the commit graph we'll 65 | // traverse. We want the most recent commit, so start at HEAD. 66 | revisions 67 | .push_head() 68 | .chain_err(|| "Failed to push HEAD to commit graph.")?; 69 | 70 | // Pull the first revision (HEAD). 71 | let last_oid = revisions 72 | .next() 73 | .and_then(|revision| revision.ok()) 74 | .ok_or("Couldn't find a git object ID for this file")?; 75 | 76 | let line_range = match app.mode { 77 | Mode::SelectLine(ref s) => { 78 | // Avoid zero-based line numbers. 79 | let line_1 = buffer.cursor.line + 1; 80 | let line_2 = s.anchor + 1; 81 | 82 | match line_1.cmp(&line_2) { 83 | Ordering::Less => format!("#L{line_1}-L{line_2}"), 84 | Ordering::Greater => format!("#L{line_2}-L{line_1}"), 85 | Ordering::Equal => format!("#L{line_1}"), 86 | } 87 | } 88 | _ => String::new(), 89 | }; 90 | 91 | let gh_url = format!( 92 | "https://github.com/{}/blob/{:?}/{}{}", 93 | gh_path, 94 | last_oid, 95 | relative_path.to_string_lossy(), 96 | line_range 97 | ); 98 | 99 | app.clipboard 100 | .set_content(ClipboardContent::Inline(gh_url))?; 101 | } else { 102 | bail!("No repository available"); 103 | } 104 | 105 | commands::application::switch_to_normal_mode(app)?; 106 | 107 | Ok(()) 108 | } 109 | 110 | fn get_gh_path(url: &str) -> errors::Result<&str> { 111 | lazy_static! { 112 | static ref REGEX: Regex = 113 | Regex::new(r"^(?:https://|git@)github.com(?::|/)(.*?)(?:.git)?$").unwrap(); 114 | } 115 | REGEX 116 | .captures(url) 117 | .and_then(|c| c.get(1)) 118 | .map(|c| c.as_str()) 119 | .chain_err(|| "Failed to capture remote repo path") 120 | } 121 | 122 | #[test] 123 | fn test_get_gh_path() { 124 | let cases = [ 125 | ("git@github.com:jmacdonald/amp.git", "jmacdonald/amp"), 126 | ("https://github.com/jmacdonald/amp.git", "jmacdonald/amp"), 127 | ("https://github.com/jmacdonald/amp", "jmacdonald/amp"), 128 | ]; 129 | 130 | cases.iter().for_each(|(url, expected_gh_path)| { 131 | assert_eq!(&get_gh_path(url).unwrap(), expected_gh_path) 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /src/commands/jump.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Result; 2 | use crate::errors::*; 3 | use crate::input::Key; 4 | use crate::models::application::modes::JumpMode; 5 | use crate::models::application::{Application, Mode}; 6 | use scribe::Workspace; 7 | 8 | pub fn match_tag(app: &mut Application) -> Result { 9 | let result = if let Mode::Jump(ref mut jump_mode) = app.mode { 10 | match jump_mode.input.len() { 11 | 0 => return Ok(()), // Not enough data to match to a position. 12 | 1 => { 13 | if jump_mode.first_phase { 14 | jump_to_tag(jump_mode, &mut app.workspace) 15 | } else { 16 | return Ok(()); // Not enough data to match to a position. 17 | } 18 | } 19 | _ => jump_to_tag(jump_mode, &mut app.workspace), 20 | } 21 | } else { 22 | bail!("Can't match jump tags outside of jump mode."); 23 | }; 24 | app.switch_to_previous_mode(); 25 | 26 | result 27 | } 28 | 29 | // Try to find a position for the input tag and jump to it. 30 | fn jump_to_tag(jump_mode: &mut JumpMode, workspace: &mut Workspace) -> Result { 31 | let position = jump_mode 32 | .map_tag(&jump_mode.input) 33 | .ok_or("Couldn't find a position for the specified tag")?; 34 | let buffer = workspace.current_buffer.as_mut().ok_or(BUFFER_MISSING)?; 35 | 36 | if !buffer.cursor.move_to(*position) { 37 | bail!( 38 | "Couldn't move to the specified tag's position ({:?})", 39 | position 40 | ) 41 | } 42 | 43 | Ok(()) 44 | } 45 | 46 | pub fn push_search_char(app: &mut Application) -> Result { 47 | if let Some(ref key) = *app.view.last_key() { 48 | if let Mode::Jump(ref mut mode) = app.mode { 49 | match *key { 50 | Key::Char('f') => { 51 | if mode.first_phase { 52 | mode.first_phase = false; 53 | } else { 54 | // Add the input to whatever we've received in jump mode so far. 55 | mode.input.push('f'); 56 | } 57 | } 58 | Key::Char(c) => mode.input.push(c), 59 | _ => bail!("Last key press wasn't a character"), 60 | } 61 | } else { 62 | bail!("Can't push jump character outside of jump mode") 63 | } 64 | } else { 65 | bail!("View hasn't tracked a key press") 66 | } 67 | 68 | match_tag(app) 69 | } 70 | -------------------------------------------------------------------------------- /src/commands/line_jump.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Result}; 2 | use crate::errors::*; 3 | use crate::input::Key; 4 | use crate::models::application::{Application, Mode}; 5 | use scribe::buffer::Position; 6 | 7 | pub fn accept_input(app: &mut Application) -> Result { 8 | if let Mode::LineJump(ref mode) = app.mode { 9 | // Try parsing an integer from the input. 10 | let line_number = mode 11 | .input 12 | .parse::() 13 | .chain_err(|| "Couldn't parse a line number from the provided input.")?; 14 | 15 | // Ignore zero-value line numbers. 16 | if line_number > 0 { 17 | let buffer = app 18 | .workspace 19 | .current_buffer 20 | .as_mut() 21 | .ok_or(BUFFER_MISSING)?; 22 | 23 | // Input values won't be zero-indexed; map the value so 24 | // that we can use it for a zero-indexed buffer position. 25 | let target_line = line_number - 1; 26 | 27 | // Build an ideal target position to which we'll try moving. 28 | let mut target_position = Position { 29 | line: target_line, 30 | offset: buffer.cursor.offset, 31 | }; 32 | 33 | if !buffer.cursor.move_to(target_position) { 34 | // Moving to that position failed. It may be because the 35 | // current offset doesn't exist there. Try falling back 36 | // to the end of the target line. 37 | let line_content = buffer 38 | .data() 39 | .lines() 40 | .nth(target_line) 41 | .map(|line| line.to_string()) 42 | .ok_or("Couldn't find the specified line")?; 43 | 44 | target_position.offset = line_content.len(); 45 | buffer.cursor.move_to(target_position); 46 | } 47 | } 48 | } else { 49 | bail!("Can't accept line jump input outside of line jump mode."); 50 | } 51 | 52 | commands::application::switch_to_normal_mode(app)?; 53 | commands::view::scroll_cursor_to_center(app)?; 54 | 55 | Ok(()) 56 | } 57 | 58 | pub fn push_search_char(app: &mut Application) -> Result { 59 | let key = app 60 | .view 61 | .last_key() 62 | .as_ref() 63 | .ok_or("View hasn't tracked a key press")?; 64 | 65 | if let Key::Char(c) = *key { 66 | if let Mode::LineJump(ref mut mode) = app.mode { 67 | mode.input.push(c) 68 | } else { 69 | bail!("Can't push search character outside of search insert mode") 70 | } 71 | } else { 72 | bail!("Last key press wasn't a character") 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | pub fn pop_search_char(app: &mut Application) -> Result { 79 | if let Mode::LineJump(ref mut mode) = app.mode { 80 | mode.input.pop() 81 | } else { 82 | bail!("Can't pop search character outside of search insert mode") 83 | }; 84 | 85 | Ok(()) 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use crate::commands; 91 | use crate::models::application::{Application, Mode}; 92 | use scribe::buffer::Position; 93 | use scribe::Buffer; 94 | 95 | #[test] 96 | fn accept_input_moves_cursor_to_requested_line_and_changes_modes() { 97 | let mut app = Application::new(&Vec::new()).unwrap(); 98 | let mut buffer = Buffer::new(); 99 | buffer.insert("amp\neditor\neditor"); 100 | 101 | // Now that we've set up the buffer, add it to the application, 102 | // switch to line jump mode, set the line input, and run the command. 103 | app.workspace.add_buffer(buffer); 104 | commands::application::switch_to_line_jump_mode(&mut app).unwrap(); 105 | match app.mode { 106 | Mode::LineJump(ref mut mode) => mode.input = "3".to_string(), 107 | _ => (), 108 | }; 109 | commands::line_jump::accept_input(&mut app).unwrap(); 110 | 111 | // Ensure that the cursor is in the right place. 112 | // NOTE: We look for a decremented version of the input line number 113 | // because users won't be inputting zero-indexed line numbers. 114 | assert_eq!( 115 | *app.workspace.current_buffer.as_ref().unwrap().cursor, 116 | Position { line: 2, offset: 0 } 117 | ); 118 | 119 | // Ensure that we're in normal mode. 120 | assert!(match app.mode { 121 | crate::models::application::Mode::Normal => true, 122 | _ => false, 123 | }); 124 | } 125 | 126 | #[test] 127 | fn accept_input_handles_unavailable_offsets() { 128 | let mut app = Application::new(&Vec::new()).unwrap(); 129 | let mut buffer = Buffer::new(); 130 | buffer.insert("amp\neditor\namp"); 131 | buffer.cursor.move_to(Position { line: 1, offset: 3 }); 132 | 133 | // Now that we've set up the buffer, add it to the application, 134 | // switch to line jump mode, set the line input, and run the command. 135 | app.workspace.add_buffer(buffer); 136 | commands::application::switch_to_line_jump_mode(&mut app).unwrap(); 137 | match app.mode { 138 | Mode::LineJump(ref mut mode) => mode.input = "3".to_string(), 139 | _ => (), 140 | }; 141 | commands::line_jump::accept_input(&mut app).unwrap(); 142 | 143 | // Ensure that the cursor is in the right place. 144 | // NOTE: We look for a decremented version of the input line number 145 | // because users won't be inputting zero-indexed line numbers. 146 | assert_eq!( 147 | *app.workspace.current_buffer.as_ref().unwrap().cursor, 148 | Position { line: 2, offset: 3 } 149 | ); 150 | 151 | // Ensure that we're in normal mode. 152 | assert!(match app.mode { 153 | crate::models::application::Mode::Normal => true, 154 | _ => false, 155 | }); 156 | } 157 | 158 | #[test] 159 | fn accept_input_ignores_zero_input() { 160 | let mut app = Application::new(&Vec::new()).unwrap(); 161 | let mut buffer = Buffer::new(); 162 | buffer.insert("amp\neditor\namp"); 163 | 164 | // Now that we've set up the buffer, add it to the application, 165 | // switch to line jump mode, set the line input, and run the command. 166 | app.workspace.add_buffer(buffer); 167 | commands::application::switch_to_line_jump_mode(&mut app).unwrap(); 168 | match app.mode { 169 | Mode::LineJump(ref mut mode) => mode.input = "0".to_string(), 170 | _ => (), 171 | }; 172 | commands::line_jump::accept_input(&mut app).unwrap(); 173 | 174 | // Ensure that the cursor is in the right place. 175 | assert_eq!( 176 | *app.workspace.current_buffer.as_ref().unwrap().cursor, 177 | Position { line: 0, offset: 0 } 178 | ); 179 | 180 | // Ensure that we're in normal mode. 181 | assert!(match app.mode { 182 | crate::models::application::Mode::Normal => true, 183 | _ => false, 184 | }); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::errors; 2 | use crate::models::application::Application; 3 | use std::collections::HashMap; 4 | 5 | pub mod application; 6 | pub mod buffer; 7 | pub mod confirm; 8 | pub mod cursor; 9 | pub mod git; 10 | pub mod jump; 11 | pub mod line_jump; 12 | pub mod open; 13 | pub mod path; 14 | pub mod preferences; 15 | pub mod search; 16 | pub mod search_select; 17 | pub mod selection; 18 | pub mod view; 19 | pub mod workspace; 20 | 21 | pub type Command = fn(&mut Application) -> Result; 22 | pub type Result = errors::Result<()>; 23 | 24 | pub fn hash_map() -> HashMap<&'static str, Command> { 25 | include!(concat!(env!("OUT_DIR"), "/hash_map")) 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/open.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Result; 2 | use crate::models::application::modes::SearchSelectMode; 3 | use crate::models::application::{Application, Mode}; 4 | 5 | pub fn nudge(app: &mut Application) -> Result { 6 | match app.mode { 7 | Mode::Open(ref mut mode) => { 8 | if mode.query().is_empty() { 9 | mode.select_next() 10 | } else { 11 | mode.pin_query() 12 | } 13 | } 14 | _ => bail!("Can't nudge outside of open mode."), 15 | } 16 | 17 | Ok(()) 18 | } 19 | 20 | pub fn toggle_selection(app: &mut Application) -> Result { 21 | match app.mode { 22 | Mode::Open(ref mut mode) => mode.toggle_selection(), 23 | _ => bail!("Can't mark selections outside of open mode."), 24 | } 25 | 26 | Ok(()) 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/path.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Result}; 2 | use crate::errors::*; 3 | use crate::input::Key; 4 | use crate::models::application::{Application, Mode, ModeKey}; 5 | use std::path::PathBuf; 6 | 7 | pub fn push_char(app: &mut Application) -> Result { 8 | let last_key = app 9 | .view 10 | .last_key() 11 | .as_ref() 12 | .ok_or("View hasn't tracked a key press")?; 13 | if let Key::Char(c) = *last_key { 14 | if let Mode::Path(ref mut mode) = app.mode { 15 | mode.push_char(c); 16 | } else { 17 | bail!("Cannot push char outside of path mode"); 18 | } 19 | } else { 20 | bail!("Last key press wasn't a character"); 21 | } 22 | Ok(()) 23 | } 24 | 25 | pub fn pop_char(app: &mut Application) -> Result { 26 | if let Mode::Path(ref mut mode) = app.mode { 27 | mode.pop_char(); 28 | } else { 29 | bail!("Cannot pop char outside of path mode"); 30 | } 31 | Ok(()) 32 | } 33 | 34 | pub fn accept_path(app: &mut Application) -> Result { 35 | let save_on_accept = if let Mode::Path(ref mut mode) = app.mode { 36 | let current_buffer = app 37 | .workspace 38 | .current_buffer 39 | .as_mut() 40 | .ok_or(BUFFER_MISSING)?; 41 | let path_name = mode.input.clone(); 42 | if path_name.is_empty() { 43 | bail!("Please provide a non-empty path") 44 | } 45 | current_buffer.path = Some(PathBuf::from(path_name)); 46 | mode.save_on_accept 47 | } else { 48 | bail!("Cannot accept path outside of path mode"); 49 | }; 50 | 51 | app.workspace 52 | .update_current_syntax() 53 | .chain_err(|| BUFFER_SYNTAX_UPDATE_FAILED)?; 54 | app.switch_to(ModeKey::Normal); 55 | 56 | if save_on_accept { 57 | commands::buffer::save(app) 58 | } else { 59 | Ok(()) 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use crate::commands; 66 | use crate::models::application::Mode; 67 | use crate::models::Application; 68 | use scribe::Buffer; 69 | use std::path::{Path, PathBuf}; 70 | 71 | #[test] 72 | fn accept_path_sets_buffer_path_based_on_input_and_switches_to_normal_mode() { 73 | let mut app = Application::new(&Vec::new()).unwrap(); 74 | 75 | let buffer = Buffer::new(); 76 | app.workspace.add_buffer(buffer); 77 | 78 | // Switch to the mode, add a name, and accept it. 79 | commands::application::switch_to_path_mode(&mut app).unwrap(); 80 | if let Mode::Path(ref mut mode) = app.mode { 81 | mode.input = String::from("new_path"); 82 | } 83 | super::accept_path(&mut app).unwrap(); 84 | 85 | assert_eq!( 86 | app.workspace.current_buffer.as_ref().unwrap().path, 87 | Some(PathBuf::from("new_path")) 88 | ); 89 | 90 | if let Mode::Normal = app.mode { 91 | } else { 92 | panic!("Not in normal mode"); 93 | } 94 | } 95 | 96 | #[test] 97 | fn accept_path_respects_save_on_accept_flag() { 98 | let mut app = Application::new(&Vec::new()).unwrap(); 99 | 100 | let buffer = Buffer::new(); 101 | app.workspace.add_buffer(buffer); 102 | 103 | // Switch to the mode, add a name, set the flag, and accept it. 104 | commands::application::switch_to_path_mode(&mut app).unwrap(); 105 | if let Mode::Path(ref mut mode) = app.mode { 106 | mode.input = Path::new(concat!(env!("OUT_DIR"), "new_path")) 107 | .to_string_lossy() 108 | .into(); 109 | mode.save_on_accept = true; 110 | } 111 | super::accept_path(&mut app).unwrap(); 112 | 113 | assert!(!app.workspace.current_buffer.as_ref().unwrap().modified()); 114 | } 115 | 116 | #[test] 117 | fn accept_path_doesnt_set_buffer_path_for_empty_input_and_doesnt_change_modes() { 118 | let mut app = Application::new(&Vec::new()).unwrap(); 119 | 120 | let buffer = Buffer::new(); 121 | app.workspace.add_buffer(buffer); 122 | 123 | // Switch to the mode, add a name, and accept it. 124 | commands::application::switch_to_path_mode(&mut app).unwrap(); 125 | if let Mode::Path(ref mut mode) = app.mode { 126 | mode.input = String::from(""); 127 | } 128 | let result = super::accept_path(&mut app); 129 | assert!(result.is_err()); 130 | assert!(app 131 | .workspace 132 | .current_buffer 133 | .as_ref() 134 | .unwrap() 135 | .path 136 | .is_none()); 137 | 138 | if let Mode::Path(_) = app.mode { 139 | } else { 140 | panic!("Not in path mode"); 141 | } 142 | } 143 | 144 | #[test] 145 | fn accept_path_updates_syntax() { 146 | let mut app = Application::new(&Vec::new()).unwrap(); 147 | 148 | let buffer = Buffer::new(); 149 | app.workspace.add_buffer(buffer); 150 | 151 | // Switch to the mode, add a name, and accept it. 152 | commands::application::switch_to_path_mode(&mut app).unwrap(); 153 | if let Mode::Path(ref mut mode) = app.mode { 154 | mode.input = String::from("path.rs"); 155 | } 156 | super::accept_path(&mut app).unwrap(); 157 | 158 | assert_eq!( 159 | app.workspace 160 | .current_buffer 161 | .as_ref() 162 | .unwrap() 163 | .syntax_definition 164 | .as_ref() 165 | .unwrap() 166 | .name, 167 | "Rust" 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/commands/preferences.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Result; 2 | use crate::models::application::{Application, Preferences}; 3 | use crate::util; 4 | 5 | pub fn edit(app: &mut Application) -> Result { 6 | let preference_buffer = Preferences::edit()?; 7 | util::add_buffer(preference_buffer, app) 8 | } 9 | 10 | pub fn reload(app: &mut Application) -> Result { 11 | app.preferences.borrow_mut().reload() 12 | } 13 | -------------------------------------------------------------------------------- /src/commands/search_select.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, application, Result}; 2 | use crate::errors::*; 3 | use crate::input::Key; 4 | use crate::models::application::modes::open::DisplayablePath; 5 | use crate::models::application::modes::{PopSearchToken, SearchSelectMode}; 6 | use crate::models::application::{Application, Mode, ModeKey}; 7 | 8 | pub fn accept(app: &mut Application) -> Result { 9 | match app.mode { 10 | Mode::Command(ref mode) => { 11 | let selection = mode.selection().ok_or("No command selected")?; 12 | 13 | // Run the selected command. 14 | (selection.command)(app)?; 15 | } 16 | Mode::Open(ref mut mode) => { 17 | if mode.selection().is_none() { 18 | bail!("No buffer selected"); 19 | } 20 | 21 | for DisplayablePath(path) in mode.selections() { 22 | let syntax_definition = app 23 | .preferences 24 | .borrow() 25 | .syntax_definition_name(path) 26 | .and_then(|name| app.workspace.syntax_set.find_syntax_by_name(&name).cloned()); 27 | 28 | app.workspace 29 | .open_buffer(path) 30 | .chain_err(|| "Couldn't open a buffer for the specified path.")?; 31 | 32 | let buffer = app.workspace.current_buffer.as_mut().unwrap(); 33 | 34 | // Only override the default syntax definition if the user provided 35 | // a valid one in their preferences. 36 | if syntax_definition.is_some() { 37 | buffer.syntax_definition = syntax_definition; 38 | } 39 | 40 | app.view.initialize_buffer(buffer)?; 41 | } 42 | } 43 | Mode::Theme(ref mut mode) => { 44 | let theme_key = mode.selection().ok_or("No theme selected")?; 45 | app.preferences.borrow_mut().set_theme(theme_key.as_str()); 46 | } 47 | Mode::SymbolJump(ref mut mode) => { 48 | let buffer = app 49 | .workspace 50 | .current_buffer 51 | .as_mut() 52 | .ok_or(BUFFER_MISSING)?; 53 | let position = mode 54 | .selection() 55 | .ok_or("Couldn't find a position for the selected symbol")? 56 | .position; 57 | 58 | if !buffer.cursor.move_to(position) { 59 | bail!("Couldn't move to the selected symbol's position"); 60 | } 61 | } 62 | Mode::Syntax(ref mut mode) => { 63 | let name = mode.selection().ok_or("No syntax selected")?; 64 | let syntax = app.workspace.syntax_set.find_syntax_by_name(name).cloned(); 65 | let buffer = app 66 | .workspace 67 | .current_buffer 68 | .as_mut() 69 | .ok_or(BUFFER_MISSING)?; 70 | buffer.syntax_definition = syntax; 71 | } 72 | _ => bail!("Can't accept selection outside of search select mode."), 73 | } 74 | 75 | app.switch_to(ModeKey::Normal); 76 | commands::view::scroll_cursor_to_center(app).ok(); 77 | 78 | Ok(()) 79 | } 80 | 81 | pub fn search(app: &mut Application) -> Result { 82 | match app.mode { 83 | Mode::Command(ref mut mode) => mode.search(), 84 | Mode::Open(ref mut mode) => mode.search(), 85 | Mode::Theme(ref mut mode) => mode.search(), 86 | Mode::SymbolJump(ref mut mode) => mode.search(), 87 | Mode::Syntax(ref mut mode) => mode.search(), 88 | _ => bail!("Can't search outside of search select mode."), 89 | }; 90 | 91 | Ok(()) 92 | } 93 | 94 | pub fn select_next(app: &mut Application) -> Result { 95 | match app.mode { 96 | Mode::Command(ref mut mode) => mode.select_next(), 97 | Mode::Open(ref mut mode) => mode.select_next(), 98 | Mode::Theme(ref mut mode) => mode.select_next(), 99 | Mode::SymbolJump(ref mut mode) => mode.select_next(), 100 | Mode::Syntax(ref mut mode) => mode.select_next(), 101 | _ => bail!("Can't change selection outside of search select mode."), 102 | } 103 | 104 | Ok(()) 105 | } 106 | 107 | pub fn select_previous(app: &mut Application) -> Result { 108 | match app.mode { 109 | Mode::Command(ref mut mode) => mode.select_previous(), 110 | Mode::Open(ref mut mode) => mode.select_previous(), 111 | Mode::Theme(ref mut mode) => mode.select_previous(), 112 | Mode::SymbolJump(ref mut mode) => mode.select_previous(), 113 | Mode::Syntax(ref mut mode) => mode.select_previous(), 114 | _ => bail!("Can't change selection outside of search select mode."), 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | pub fn enable_insert(app: &mut Application) -> Result { 121 | match app.mode { 122 | Mode::Command(ref mut mode) => mode.set_insert_mode(true), 123 | Mode::Open(ref mut mode) => mode.set_insert_mode(true), 124 | Mode::Theme(ref mut mode) => mode.set_insert_mode(true), 125 | Mode::SymbolJump(ref mut mode) => mode.set_insert_mode(true), 126 | Mode::Syntax(ref mut mode) => mode.set_insert_mode(true), 127 | _ => bail!("Can't change search insert state outside of search select mode"), 128 | } 129 | 130 | Ok(()) 131 | } 132 | 133 | pub fn disable_insert(app: &mut Application) -> Result { 134 | match app.mode { 135 | Mode::Command(ref mut mode) => mode.set_insert_mode(false), 136 | Mode::Open(ref mut mode) => mode.set_insert_mode(false), 137 | Mode::Theme(ref mut mode) => mode.set_insert_mode(false), 138 | Mode::SymbolJump(ref mut mode) => mode.set_insert_mode(false), 139 | Mode::Syntax(ref mut mode) => mode.set_insert_mode(false), 140 | _ => bail!("Can't change search insert state outside of search select mode"), 141 | } 142 | 143 | Ok(()) 144 | } 145 | 146 | pub fn push_search_char(app: &mut Application) -> Result { 147 | if let Some(Key::Char(c)) = *app.view.last_key() { 148 | match app.mode { 149 | Mode::Command(ref mut mode) => mode.push_search_char(c), 150 | Mode::Open(ref mut mode) => mode.push_search_char(c), 151 | Mode::Theme(ref mut mode) => mode.push_search_char(c), 152 | Mode::SymbolJump(ref mut mode) => mode.push_search_char(c), 153 | Mode::Syntax(ref mut mode) => mode.push_search_char(c), 154 | _ => bail!("Can't push search character outside of search select mode"), 155 | } 156 | } 157 | 158 | // Re-run the search. 159 | search(app) 160 | } 161 | 162 | pub fn pop_search_token(app: &mut Application) -> Result { 163 | match app.mode { 164 | Mode::Command(ref mut mode) => mode.pop_search_token(), 165 | Mode::Open(ref mut mode) => mode.pop_search_token(), 166 | Mode::Theme(ref mut mode) => mode.pop_search_token(), 167 | Mode::SymbolJump(ref mut mode) => mode.pop_search_token(), 168 | Mode::Syntax(ref mut mode) => mode.pop_search_token(), 169 | _ => bail!("Can't pop search token outside of search select mode"), 170 | } 171 | 172 | search(app)?; 173 | Ok(()) 174 | } 175 | 176 | pub fn step_back(app: &mut Application) -> Result { 177 | let selection_available = match app.mode { 178 | Mode::Command(ref mut mode) => mode.results().count() > 0 && !mode.query().is_empty(), 179 | Mode::Open(ref mut mode) => mode.results().count() > 0 && !mode.query().is_empty(), 180 | Mode::Theme(ref mut mode) => mode.results().count() > 0 && !mode.query().is_empty(), 181 | Mode::SymbolJump(ref mut mode) => mode.results().count() > 0 && !mode.query().is_empty(), 182 | Mode::Syntax(ref mut mode) => mode.results().count() > 0 && !mode.query().is_empty(), 183 | _ => bail!("Can't pop search token outside of search select mode"), 184 | }; 185 | 186 | if selection_available { 187 | disable_insert(app) 188 | } else { 189 | application::switch_to_normal_mode(app) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/commands/view.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Result; 2 | use crate::errors::*; 3 | use crate::models::application::Application; 4 | 5 | pub fn scroll_up(app: &mut Application) -> Result { 6 | let buffer = app 7 | .workspace 8 | .current_buffer 9 | .as_ref() 10 | .ok_or(BUFFER_MISSING)?; 11 | app.view.scroll_up(buffer, 10)?; 12 | Ok(()) 13 | } 14 | 15 | pub fn scroll_down(app: &mut Application) -> Result { 16 | let buffer = app 17 | .workspace 18 | .current_buffer 19 | .as_ref() 20 | .ok_or(BUFFER_MISSING)?; 21 | app.view.scroll_down(buffer, 10)?; 22 | Ok(()) 23 | } 24 | 25 | pub fn scroll_to_cursor(app: &mut Application) -> Result { 26 | let buffer = app 27 | .workspace 28 | .current_buffer 29 | .as_ref() 30 | .ok_or(BUFFER_MISSING)?; 31 | app.view.scroll_to_cursor(buffer)?; 32 | Ok(()) 33 | } 34 | 35 | pub fn scroll_cursor_to_center(app: &mut Application) -> Result { 36 | let buffer = app 37 | .workspace 38 | .current_buffer 39 | .as_ref() 40 | .ok_or(BUFFER_MISSING)?; 41 | app.view.scroll_to_center(buffer)?; 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/workspace.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Result; 2 | use crate::models::application::Application; 3 | use crate::util; 4 | use scribe::Buffer; 5 | 6 | pub fn next_buffer(app: &mut Application) -> Result { 7 | app.workspace.next_buffer(); 8 | 9 | Ok(()) 10 | } 11 | 12 | pub fn new_buffer(app: &mut Application) -> Result { 13 | util::add_buffer(Buffer::new(), app) 14 | } 15 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | // Create the Error, ErrorKind, ResultExt, and Result types 2 | error_chain! { 3 | foreign_links { 4 | Io(::std::io::Error) #[cfg(unix)]; 5 | } 6 | } 7 | 8 | pub const BUFFER_MISSING: &str = "No buffer available"; 9 | pub const BUFFER_PARSE_FAILED: &str = "Failed to parse buffer"; 10 | pub const BUFFER_PATH_MISSING: &str = "No path found for the current buffer"; 11 | pub const BUFFER_RELOAD_FAILED: &str = "Unable to reload buffer"; 12 | pub const BUFFER_SAVE_FAILED: &str = "Unable to save buffer"; 13 | pub const BUFFER_SYNTAX_UPDATE_FAILED: &str = "Failed to update buffer syntax definition"; 14 | pub const BUFFER_TOKENS_FAILED: &str = "Failed to generate buffer tokens"; 15 | pub const CURRENT_LINE_MISSING: &str = "The current line couldn't be found in the buffer"; 16 | pub const FORMAT_TOOL_MISSING: &str = "No format tool configured for this filetype"; 17 | pub const LOCK_POISONED: &str = "Lock has been poisoned"; 18 | pub const NO_SEARCH_RESULTS: &str = "No search results available"; 19 | pub const SCROLL_TO_CURSOR_FAILED: &str = "Failed to scroll to cursor position"; 20 | pub const SEARCH_QUERY_MISSING: &str = "No search query"; 21 | pub const SELECTED_INDEX_OUT_OF_RANGE: &str = "Selected index is out of range"; 22 | pub const STDOUT_FAILED: &str = "Failed to acquire stdout"; 23 | pub const WORKSPACE_INIT_FAILED: &str = "Failed to initialize workspace"; 24 | -------------------------------------------------------------------------------- /src/input/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::key_map::KeyMap; 2 | 3 | mod key_map; 4 | 5 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 6 | pub enum Key { 7 | Backspace, 8 | Left, 9 | Right, 10 | Up, 11 | Down, 12 | Home, 13 | End, 14 | PageUp, 15 | PageDown, 16 | Delete, 17 | Insert, 18 | Esc, 19 | Tab, 20 | Enter, 21 | AnyChar, 22 | Char(char), 23 | Ctrl(char), 24 | } 25 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // `error_chain!` can recurse deeply 2 | #![recursion_limit = "1024"] 3 | 4 | #[macro_use] 5 | extern crate error_chain; 6 | 7 | #[macro_use] 8 | extern crate lazy_static; 9 | 10 | // Private modules 11 | mod commands; 12 | mod errors; 13 | mod input; 14 | mod models; 15 | mod presenters; 16 | mod util; 17 | mod view; 18 | 19 | // External application API 20 | pub use crate::errors::Error; 21 | pub use crate::models::Application; 22 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use amp::Application; 2 | use amp::Error; 3 | use std::env; 4 | 5 | fn main() { 6 | let args: Vec = env::args().collect(); 7 | 8 | // Instantiate, run, and handle errors for the application. 9 | if let Some(e) = Application::new(&args).and_then(|mut app| app.run()).err() { 10 | handle_error(&e) 11 | } 12 | } 13 | 14 | fn handle_error(error: &Error) { 15 | // Print the proximate/contextual error. 16 | eprintln!("error: {error}"); 17 | 18 | // Print the chain of other errors that led to the proximate error. 19 | for e in error.iter().skip(1) { 20 | eprintln!("caused by: {e}"); 21 | } 22 | 23 | // Print the backtrace, if available. 24 | if let Some(backtrace) = error.backtrace() { 25 | eprintln!("backtrace: {backtrace:?}"); 26 | } 27 | 28 | // Exit with an error code. 29 | ::std::process::exit(1); 30 | } 31 | -------------------------------------------------------------------------------- /src/models/application/clipboard.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use cli_clipboard::{ClipboardContext, ClipboardProvider}; 3 | 4 | /// In-app content can be captured in both regular and full-line selection 5 | /// modes. This type describes the structure of said content, based on the 6 | /// context in which it was captured. When OS-level clipboard contents are 7 | /// used, they are always represented as inline, as we cannot infer block 8 | /// style without the copy context. 9 | #[derive(Debug, PartialEq)] 10 | pub enum ClipboardContent { 11 | Inline(String), 12 | Block(String), 13 | None, 14 | } 15 | 16 | /// Qualifies in-app copy/paste content with structural information, and 17 | /// synchronizes said content with the OS-level clipboard (preferring it 18 | /// in scenarios where it differs from the in-app equivalent). 19 | pub struct Clipboard { 20 | content: ClipboardContent, 21 | system_clipboard: Option, 22 | } 23 | 24 | impl Default for Clipboard { 25 | fn default() -> Self { 26 | Self::new() 27 | } 28 | } 29 | 30 | impl Clipboard { 31 | pub fn new() -> Clipboard { 32 | // Initialize and keep a reference to the system clipboard. 33 | let system_clipboard = match ClipboardProvider::new() { 34 | Ok(clipboard) => Some(clipboard), 35 | Err(_) => None, 36 | }; 37 | 38 | Clipboard { 39 | content: ClipboardContent::None, 40 | system_clipboard, 41 | } 42 | } 43 | 44 | /// Returns the in-app clipboard content. However, if in-app content 45 | /// differs from the system clipboard, the system clipboard content will 46 | /// be saved to the in-app clipboard as inline data and returned instead. 47 | pub fn get_content(&mut self) -> &ClipboardContent { 48 | // Check the system clipboard for newer content. 49 | let new_content = match self.system_clipboard { 50 | Some(ref mut clipboard) => { 51 | match clipboard.get_contents() { 52 | Ok(content) => { 53 | if content.is_empty() { 54 | None 55 | } else { 56 | // There is system clipboard content we can use. 57 | match self.content { 58 | ClipboardContent::Inline(ref app_content) 59 | | ClipboardContent::Block(ref app_content) => { 60 | // We have in-app clipboard content, too. Prefer 61 | // the system clipboard content if they differ. 62 | if content != *app_content { 63 | Some(ClipboardContent::Inline(content)) 64 | } else { 65 | None 66 | } 67 | } 68 | // We have no in-app clipboard content. Use the system's. 69 | _ => Some(ClipboardContent::Inline(content)), 70 | } 71 | } 72 | } 73 | _ => None, 74 | } 75 | } 76 | None => None, 77 | }; 78 | 79 | // Update the in-app clipboard if we've found newer content. 80 | if let Some(new_content) = new_content { 81 | self.content = new_content; 82 | } 83 | 84 | &self.content 85 | } 86 | 87 | // Updates the in-app and system clipboards with the specified content. 88 | pub fn set_content(&mut self, content: ClipboardContent) -> Result<()> { 89 | // Update the in-app clipboard. 90 | self.content = content; 91 | 92 | // Update the system clipboard. 93 | match self.content { 94 | ClipboardContent::Inline(ref app_content) 95 | | ClipboardContent::Block(ref app_content) => { 96 | if let Some(ref mut clipboard) = self.system_clipboard { 97 | return clipboard 98 | .set_contents(app_content.clone()) 99 | .map_err(|_| Error::from("Failed to update system clipboard")); 100 | } 101 | } 102 | _ => (), 103 | } 104 | 105 | Ok(()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/models/application/event.rs: -------------------------------------------------------------------------------- 1 | use crate::input::Key; 2 | use crate::models::application::modes::open::Index; 3 | 4 | #[derive(Debug, PartialEq)] 5 | pub enum Event { 6 | Key(Key), 7 | Resize, 8 | OpenModeIndexComplete(Index), 9 | } 10 | -------------------------------------------------------------------------------- /src/models/application/modes/command/displayable_command.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Command; 2 | use std::fmt; 3 | 4 | // Utility type to make an Amp command function presentable (via the 5 | // Display trait), which is required for any type used in search/select mode. 6 | pub struct DisplayableCommand { 7 | pub description: &'static str, 8 | pub command: Command, 9 | } 10 | 11 | impl fmt::Display for DisplayableCommand { 12 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 13 | write!(f, "{}", self.description) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/models/application/modes/command/mod.rs: -------------------------------------------------------------------------------- 1 | mod displayable_command; 2 | 3 | pub use self::displayable_command::DisplayableCommand; 4 | use crate::commands::{self, Command}; 5 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 6 | use crate::util::SelectableVec; 7 | use fragment; 8 | use std::collections::HashMap; 9 | use std::fmt; 10 | use std::slice::Iter; 11 | 12 | pub struct CommandMode { 13 | insert: bool, 14 | input: String, 15 | commands: HashMap<&'static str, Command>, 16 | results: SelectableVec, 17 | config: SearchSelectConfig, 18 | } 19 | 20 | impl CommandMode { 21 | pub fn new(config: SearchSelectConfig) -> CommandMode { 22 | CommandMode { 23 | insert: true, 24 | input: String::new(), 25 | commands: commands::hash_map(), 26 | results: SelectableVec::new(Vec::new()), 27 | config, 28 | } 29 | } 30 | 31 | pub fn reset(&mut self, config: SearchSelectConfig) { 32 | self.input.clear(); 33 | self.insert = true; 34 | self.results = SelectableVec::new(Vec::new()); 35 | self.config = config; 36 | } 37 | } 38 | 39 | impl fmt::Display for CommandMode { 40 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 41 | write!(f, "COMMAND") 42 | } 43 | } 44 | 45 | impl SearchSelectMode for CommandMode { 46 | type Item = DisplayableCommand; 47 | 48 | fn search(&mut self) { 49 | // Find the commands we're looking for using the query. 50 | let results = if self.input.is_empty() { 51 | self.commands 52 | .iter() 53 | .take(self.config.max_results) 54 | .map(|(k, v)| DisplayableCommand { 55 | description: *k, 56 | command: *v, 57 | }) 58 | .collect() 59 | } else { 60 | let commands: Vec<&'static str> = self.commands.keys().copied().collect(); 61 | 62 | fragment::matching::find(&self.input, &commands, self.config.max_results) 63 | .into_iter() 64 | .filter_map(|result| { 65 | self.commands 66 | .get(*result) 67 | .map(|command| DisplayableCommand { 68 | description: *result, 69 | command: *command, 70 | }) 71 | }) 72 | .collect() 73 | }; 74 | 75 | self.results = SelectableVec::new(results); 76 | } 77 | 78 | fn query(&mut self) -> &mut String { 79 | &mut self.input 80 | } 81 | 82 | fn insert_mode(&self) -> bool { 83 | self.insert 84 | } 85 | 86 | fn set_insert_mode(&mut self, insert_mode: bool) { 87 | self.insert = insert_mode; 88 | } 89 | 90 | fn results(&self) -> Iter { 91 | self.results.iter() 92 | } 93 | 94 | fn selection(&self) -> Option<&DisplayableCommand> { 95 | self.results.selection() 96 | } 97 | 98 | fn selected_index(&self) -> usize { 99 | self.results.selected_index() 100 | } 101 | 102 | fn select_previous(&mut self) { 103 | self.results.select_previous(); 104 | } 105 | 106 | fn select_next(&mut self) { 107 | self.results.select_next(); 108 | } 109 | 110 | fn config(&self) -> &SearchSelectConfig { 111 | &self.config 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::CommandMode; 118 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 119 | 120 | #[test] 121 | fn reset_clears_query_mode_and_results() { 122 | let config = SearchSelectConfig::default(); 123 | let mut mode = CommandMode::new(config.clone()); 124 | 125 | mode.query().push_str("application"); 126 | mode.set_insert_mode(false); 127 | mode.search(); 128 | 129 | // Ensure we have results before reset 130 | assert!(mode.results.len() > 0); 131 | 132 | mode.reset(config); 133 | assert_eq!(mode.query(), ""); 134 | assert_eq!(mode.insert_mode(), true); 135 | assert_eq!(mode.results.len(), 0); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/models/application/modes/confirm.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Command; 2 | 3 | pub struct ConfirmMode { 4 | pub command: Command, 5 | } 6 | 7 | impl ConfirmMode { 8 | pub fn new(command: Command) -> ConfirmMode { 9 | ConfirmMode { command } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/models/application/modes/insert.rs: -------------------------------------------------------------------------------- 1 | pub struct InsertMode { 2 | pub input: Option, 3 | } 4 | 5 | impl InsertMode { 6 | pub fn new() -> InsertMode { 7 | InsertMode { input: None } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/models/application/modes/jump/single_character_tag_generator.rs: -------------------------------------------------------------------------------- 1 | // The upper limit on one-letter values ("z"). 2 | const TAG_INDEX_LIMIT: u8 = 122; 3 | 4 | pub struct SingleCharacterTagGenerator { 5 | index: u8, 6 | } 7 | 8 | impl SingleCharacterTagGenerator { 9 | pub fn new() -> SingleCharacterTagGenerator { 10 | SingleCharacterTagGenerator { index: 96 } 11 | } 12 | 13 | /// Restarts the tag generator sequence. 14 | pub fn reset(&mut self) { 15 | self.index = 96; 16 | } 17 | } 18 | 19 | impl Iterator for SingleCharacterTagGenerator { 20 | type Item = String; 21 | 22 | fn next(&mut self) -> Option { 23 | if self.index >= TAG_INDEX_LIMIT { 24 | None 25 | } else { 26 | self.index += 1; 27 | 28 | // Skip f character (invalid token; used to leave first_phase). 29 | if self.index == 102 { 30 | self.index += 1; 31 | } 32 | 33 | Some((self.index as char).to_string()) 34 | } 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use super::SingleCharacterTagGenerator; 41 | 42 | #[test] 43 | fn it_returns_a_lowercase_set_of_alphabetical_characters_excluding_f() { 44 | let generator = SingleCharacterTagGenerator::new(); 45 | let expected_result = (97..123).fold(String::new(), |mut acc, i| { 46 | // Skip f 47 | if i != 102 { 48 | acc.push((i as u8) as char); 49 | } 50 | acc 51 | }); 52 | let result: String = generator.collect(); 53 | 54 | assert_eq!(result, expected_result); 55 | } 56 | 57 | #[test] 58 | fn it_prevents_overflow_recycling() { 59 | let mut generator = SingleCharacterTagGenerator::new(); 60 | for _ in 0..256 { 61 | generator.next(); 62 | } 63 | 64 | assert_eq!(generator.next(), None); 65 | } 66 | 67 | #[test] 68 | fn reset_returns_the_sequence_to_the_start() { 69 | let mut generator = SingleCharacterTagGenerator::new(); 70 | 71 | generator.next(); 72 | assert!(generator.next().unwrap() != "a"); 73 | 74 | generator.reset(); 75 | assert_eq!(generator.next().unwrap(), "a"); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/models/application/modes/jump/tag_generator.rs: -------------------------------------------------------------------------------- 1 | // The upper limit on two-letter values ("zz"). 2 | const TAG_INDEX_LIMIT: u16 = 675; 3 | 4 | pub struct TagGenerator { 5 | index: u16, 6 | } 7 | 8 | impl TagGenerator { 9 | /// Builds a new zero-indexed tag generator. 10 | pub fn new() -> TagGenerator { 11 | TagGenerator { index: 0 } 12 | } 13 | 14 | /// Restarts the tag generator sequence. 15 | pub fn reset(&mut self) { 16 | self.index = 0; 17 | } 18 | } 19 | 20 | impl Iterator for TagGenerator { 21 | type Item = String; 22 | 23 | // Returns the next two-letter tag, or none 24 | // if we've passed the limit ("zz"). 25 | fn next(&mut self) -> Option { 26 | if self.index > TAG_INDEX_LIMIT { 27 | return None; 28 | } 29 | 30 | // Calculate the tag characters based on the index value. 31 | let first_letter = ((self.index / 26) + 97) as u8; 32 | let second_letter = ((self.index % 26) + 97) as u8; 33 | 34 | // Increment the index. 35 | self.index += 1; 36 | 37 | // Stitch the two calculated letters together. 38 | match String::from_utf8(vec![first_letter, second_letter]) { 39 | Ok(tag) => Some(tag), 40 | Err(_) => panic!("Couldn't generate a valid UTF-8 jump mode tag."), 41 | } 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::TagGenerator; 48 | 49 | #[test] 50 | fn next_returns_sequential_letters_of_the_alphabet() { 51 | let mut generator = TagGenerator::new(); 52 | assert_eq!(generator.next().unwrap(), "aa"); 53 | assert_eq!(generator.next().unwrap(), "ab"); 54 | assert_eq!(generator.next().unwrap(), "ac"); 55 | } 56 | 57 | #[test] 58 | fn next_carries_overflows_to_the_next_letter() { 59 | let mut generator = TagGenerator::new(); 60 | for _ in 0..26 { 61 | generator.next(); 62 | } 63 | assert_eq!(generator.next().unwrap(), "ba"); 64 | assert_eq!(generator.next().unwrap(), "bb"); 65 | assert_eq!(generator.next().unwrap(), "bc"); 66 | } 67 | 68 | #[test] 69 | fn next_returns_none_when_limit_reached() { 70 | let mut generator = TagGenerator::new(); 71 | for _ in 0..super::TAG_INDEX_LIMIT { 72 | generator.next(); 73 | } 74 | 75 | // Ensure that values are still being produced up until the limit. 76 | assert_eq!(generator.next().unwrap(), "zz"); 77 | 78 | // Ensure that values stop being returned at and after the limit. 79 | assert!(generator.next().is_none()); 80 | assert!(generator.next().is_none()); 81 | } 82 | 83 | #[test] 84 | fn reset_returns_the_sequence_to_the_start() { 85 | let mut generator = TagGenerator::new(); 86 | 87 | generator.next(); 88 | assert!(generator.next().unwrap() != "aa"); 89 | 90 | generator.reset(); 91 | assert_eq!(generator.next().unwrap(), "aa"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/models/application/modes/line_jump.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default)] 2 | pub struct LineJumpMode { 3 | pub input: String, 4 | } 5 | 6 | impl LineJumpMode { 7 | pub fn new() -> LineJumpMode { 8 | LineJumpMode::default() 9 | } 10 | 11 | pub fn reset(&mut self) { 12 | self.input = String::new(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/application/modes/mod.rs: -------------------------------------------------------------------------------- 1 | mod command; 2 | mod confirm; 3 | pub mod jump; 4 | mod line_jump; 5 | pub mod open; 6 | mod path; 7 | mod search; 8 | mod search_select; 9 | mod select; 10 | mod select_line; 11 | mod symbol_jump; 12 | mod syntax; 13 | mod theme; 14 | 15 | pub enum Mode { 16 | Command(CommandMode), 17 | Confirm(ConfirmMode), 18 | Exit, 19 | Insert, 20 | Jump(JumpMode), 21 | LineJump(LineJumpMode), 22 | Normal, 23 | Open(OpenMode), 24 | Path(PathMode), 25 | Search(SearchMode), 26 | Select(SelectMode), 27 | SelectLine(SelectLineMode), 28 | SymbolJump(SymbolJumpMode), 29 | Syntax(SyntaxMode), 30 | Theme(ThemeMode), 31 | } 32 | 33 | #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 34 | pub enum ModeKey { 35 | Command, 36 | Confirm, 37 | Exit, 38 | Insert, 39 | Jump, 40 | LineJump, 41 | Normal, 42 | Open, 43 | Path, 44 | Search, 45 | Select, 46 | SelectLine, 47 | SymbolJump, 48 | Syntax, 49 | Theme, 50 | } 51 | 52 | pub use self::command::CommandMode; 53 | pub use self::confirm::ConfirmMode; 54 | pub use self::jump::JumpMode; 55 | pub use self::line_jump::LineJumpMode; 56 | pub use self::open::OpenMode; 57 | pub use self::path::PathMode; 58 | pub use self::search::SearchMode; 59 | pub use self::search_select::{PopSearchToken, SearchSelectConfig, SearchSelectMode}; 60 | pub use self::select::SelectMode; 61 | pub use self::select_line::SelectLineMode; 62 | pub use self::symbol_jump::SymbolJumpMode; 63 | pub use self::syntax::SyntaxMode; 64 | pub use self::theme::ThemeMode; 65 | -------------------------------------------------------------------------------- /src/models/application/modes/open/displayable_path.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::path::PathBuf; 3 | 4 | // Newtype to make a standard path buffer presentable (via the Display 5 | // trait), which is required for any type used in search/select mode. 6 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 7 | pub struct DisplayablePath(pub PathBuf); 8 | 9 | impl fmt::Display for DisplayablePath { 10 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 11 | let DisplayablePath(path) = self; 12 | write!(f, "{}", path.to_string_lossy()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/application/modes/open/exclusions.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use bloodhound::ExclusionPattern; 3 | use yaml_rust::Yaml; 4 | 5 | pub fn parse(exclusion_data: &[Yaml]) -> Result> { 6 | let mut mapped_exclusions = Vec::new(); 7 | 8 | for exclusion in exclusion_data.iter() { 9 | if let Yaml::String(ref pattern) = *exclusion { 10 | mapped_exclusions.push( 11 | ExclusionPattern::new(pattern) 12 | .chain_err(|| format!("Failed to parse exclusion pattern: {pattern}"))?, 13 | ); 14 | } else { 15 | bail!("Found a non-string exclusion that can't be parsed."); 16 | } 17 | } 18 | 19 | Ok(mapped_exclusions) 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use super::*; 25 | 26 | #[test] 27 | fn parse_converts_yaml_strings_into_glob_patterns() { 28 | let exclusion_data = vec![Yaml::String(String::from("pattern"))]; 29 | assert_eq!( 30 | parse(&exclusion_data).unwrap(), 31 | vec![ExclusionPattern::new("pattern").unwrap()] 32 | ); 33 | } 34 | 35 | #[test] 36 | fn parse_returns_an_error_when_parsing_fails() { 37 | let exclusion_data = vec![Yaml::String(String::from("["))]; 38 | 39 | assert!(parse(&exclusion_data).is_err()); 40 | } 41 | 42 | #[test] 43 | fn parse_returns_an_error_when_a_non_string_is_yaml_value_is_encountered() { 44 | let exclusion_data = vec![Yaml::Boolean(true)]; 45 | 46 | assert!(parse(&exclusion_data).is_err()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/models/application/modes/path.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Default)] 4 | pub struct PathMode { 5 | pub input: String, 6 | pub save_on_accept: bool, 7 | } 8 | 9 | impl PathMode { 10 | pub fn new() -> PathMode { 11 | PathMode::default() 12 | } 13 | 14 | pub fn push_char(&mut self, c: char) { 15 | self.input.push(c); 16 | } 17 | 18 | pub fn pop_char(&mut self) { 19 | self.input.pop(); 20 | } 21 | 22 | pub fn reset(&mut self, initial_path: String) { 23 | self.input = initial_path; 24 | self.save_on_accept = false; 25 | } 26 | } 27 | 28 | impl fmt::Display for PathMode { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | write!(f, "PATH") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/models/application/modes/search.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::util::SelectableVec; 3 | use scribe::buffer::{Buffer, Distance, Range}; 4 | use std::fmt; 5 | 6 | pub struct SearchMode { 7 | pub insert: bool, 8 | pub input: Option, 9 | pub results: Option>, 10 | } 11 | 12 | impl SearchMode { 13 | pub fn new(query: Option) -> SearchMode { 14 | SearchMode { 15 | insert: true, 16 | input: query, 17 | results: None, 18 | } 19 | } 20 | 21 | pub fn reset(&mut self) { 22 | self.insert = true; 23 | self.input = None; 24 | self.results = None; 25 | } 26 | 27 | pub fn insert_mode(&self) -> bool { 28 | self.insert 29 | } 30 | 31 | // Searches the specified buffer for the input string 32 | // and stores the result as a collection of ranges. 33 | pub fn search(&mut self, buffer: &Buffer) -> Result<()> { 34 | let query = self.input.as_ref().ok_or(SEARCH_QUERY_MISSING)?; 35 | let distance = Distance::of_str(query); 36 | 37 | // Buffer search returns match starting positions, but we'd like ranges. 38 | // This maps the positions to ranges using the search query distance 39 | // before storing them. 40 | self.results = Some(SelectableVec::new( 41 | buffer 42 | .search(query) 43 | .into_iter() 44 | .map(|start| Range::new(start, start + distance)) 45 | .collect(), 46 | )); 47 | 48 | Ok(()) 49 | } 50 | } 51 | 52 | impl fmt::Display for SearchMode { 53 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 54 | write!(f, "SEARCH") 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::SearchMode; 61 | use scribe::buffer::{Buffer, Position, Range}; 62 | 63 | #[test] 64 | fn search_populates_results_with_correct_ranges() { 65 | let mut buffer = Buffer::new(); 66 | buffer.insert("test\ntest"); 67 | 68 | let mut mode = SearchMode::new(Some(String::from("test"))); 69 | mode.search(&buffer).unwrap(); 70 | 71 | assert_eq!( 72 | *mode.results.unwrap(), 73 | vec![ 74 | Range::new( 75 | Position { line: 0, offset: 0 }, 76 | Position { line: 0, offset: 4 }, 77 | ), 78 | Range::new( 79 | Position { line: 1, offset: 0 }, 80 | Position { line: 1, offset: 4 }, 81 | ), 82 | ] 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/models/application/modes/search_select.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::slice::Iter; 3 | 4 | #[derive(Clone)] 5 | pub struct SearchSelectConfig { 6 | pub max_results: usize, 7 | } 8 | 9 | impl Default for SearchSelectConfig { 10 | fn default() -> SearchSelectConfig { 11 | SearchSelectConfig { max_results: 5 } 12 | } 13 | } 14 | 15 | /// This trait will become vastly simpler if/when fields are added to traits. 16 | /// See: https://github.com/rust-lang/rfcs/pull/1546 17 | pub trait SearchSelectMode: Display { 18 | type Item: Display; 19 | 20 | fn query(&mut self) -> &mut String; 21 | fn search(&mut self); 22 | fn insert_mode(&self) -> bool; 23 | fn set_insert_mode(&mut self, insert_mode: bool); 24 | fn results(&self) -> Iter; 25 | fn selection(&self) -> Option<&Self::Item>; 26 | fn selected_index(&self) -> usize; 27 | fn select_previous(&mut self); 28 | fn select_next(&mut self); 29 | fn config(&self) -> &SearchSelectConfig; 30 | fn message(&mut self) -> Option { 31 | if !self.query().is_empty() && self.results().count() == 0 { 32 | Some(String::from("No matching entries found.")) 33 | } else { 34 | None 35 | } 36 | } 37 | 38 | fn push_search_char(&mut self, c: char) { 39 | self.query().push(c); 40 | } 41 | } 42 | 43 | pub trait PopSearchToken: SearchSelectMode { 44 | fn pop_search_token(&mut self) { 45 | let query = self.query(); 46 | 47 | // Find the last word boundary (transition to/from whitespace), using 48 | // using fold to carry the previous character's type forward. 49 | let mut boundary_index = 0; 50 | query 51 | .char_indices() 52 | .fold(true, |was_whitespace, (index, c)| { 53 | if c.is_whitespace() != was_whitespace { 54 | boundary_index = index; 55 | } 56 | 57 | c.is_whitespace() 58 | }); 59 | 60 | query.truncate(boundary_index); 61 | } 62 | } 63 | 64 | // SearchSelectMode gives PopSearchToken for free 65 | impl PopSearchToken for T {} 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use super::{PopSearchToken, SearchSelectConfig, SearchSelectMode}; 70 | use std::fmt; 71 | use std::slice::Iter; 72 | 73 | #[derive(Default)] 74 | struct TestMode { 75 | input: String, 76 | selection: String, 77 | results: Vec, 78 | config: SearchSelectConfig, 79 | } 80 | 81 | impl fmt::Display for TestMode { 82 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 83 | write!(f, "TEST") 84 | } 85 | } 86 | 87 | impl SearchSelectMode for TestMode { 88 | type Item = String; 89 | 90 | fn query(&mut self) -> &mut String { 91 | &mut self.input 92 | } 93 | 94 | fn search(&mut self) {} 95 | fn insert_mode(&self) -> bool { 96 | false 97 | } 98 | fn set_insert_mode(&mut self, _: bool) {} 99 | fn results(&self) -> Iter { 100 | self.results.iter() 101 | } 102 | fn selection(&self) -> Option<&String> { 103 | Some(&self.selection) 104 | } 105 | fn selected_index(&self) -> usize { 106 | 0 107 | } 108 | fn select_previous(&mut self) {} 109 | fn select_next(&mut self) {} 110 | fn config(&self) -> &SearchSelectConfig { 111 | &self.config 112 | } 113 | } 114 | 115 | #[test] 116 | fn push_search_char_updates_query() { 117 | let mut mode = TestMode { 118 | ..Default::default() 119 | }; 120 | mode.push_search_char('a'); 121 | assert_eq!(mode.query(), "a"); 122 | } 123 | 124 | #[test] 125 | fn pop_search_token_pops_all_characters_when_on_only_token() { 126 | let mut mode = TestMode { 127 | input: String::from("amp"), 128 | ..Default::default() 129 | }; 130 | mode.pop_search_token(); 131 | assert_eq!(mode.query(), ""); 132 | } 133 | 134 | #[test] 135 | fn pop_search_token_pops_all_adjacent_non_whitespace_characters_when_on_non_whitespace_character( 136 | ) { 137 | let mut mode = TestMode { 138 | input: String::from("amp editor"), 139 | ..Default::default() 140 | }; 141 | mode.pop_search_token(); 142 | assert_eq!(mode.query(), "amp "); 143 | } 144 | 145 | #[test] 146 | fn pop_search_token_pops_all_whitespace_characters_when_on_whitespace_character() { 147 | let mut mode = TestMode { 148 | input: String::from("amp "), 149 | ..Default::default() 150 | }; 151 | mode.pop_search_token(); 152 | assert_eq!(mode.query(), "amp"); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/models/application/modes/select.rs: -------------------------------------------------------------------------------- 1 | use scribe::buffer::Position; 2 | 3 | pub struct SelectMode { 4 | pub anchor: Position, 5 | } 6 | 7 | impl SelectMode { 8 | pub fn new(anchor: Position) -> SelectMode { 9 | SelectMode { anchor } 10 | } 11 | 12 | pub fn reset(&mut self, anchor: Position) { 13 | self.anchor = anchor; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/models/application/modes/select_line.rs: -------------------------------------------------------------------------------- 1 | use scribe::buffer::{LineRange, Position, Range}; 2 | 3 | pub struct SelectLineMode { 4 | pub anchor: usize, 5 | } 6 | 7 | impl SelectLineMode { 8 | pub fn new(anchor: usize) -> SelectLineMode { 9 | SelectLineMode { anchor } 10 | } 11 | 12 | pub fn reset(&mut self, anchor: usize) { 13 | self.anchor = anchor; 14 | } 15 | 16 | pub fn to_range(&self, cursor: &Position) -> Range { 17 | LineRange::new(self.anchor, cursor.line).to_inclusive_range() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/models/application/modes/symbol_jump.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 3 | use crate::util::SelectableVec; 4 | use fragment; 5 | use fragment::matching::AsStr; 6 | use scribe::buffer::{Position, Token, TokenSet}; 7 | use std::clone::Clone; 8 | use std::fmt; 9 | use std::iter::Iterator; 10 | use std::slice::Iter; 11 | use std::str::FromStr; 12 | use syntect::highlighting::ScopeSelectors; 13 | 14 | pub struct SymbolJumpMode { 15 | insert: bool, 16 | input: String, 17 | symbols: Vec, 18 | results: SelectableVec, 19 | config: SearchSelectConfig, 20 | } 21 | 22 | #[derive(PartialEq, Debug)] 23 | pub struct Symbol { 24 | pub token: String, 25 | pub position: Position, 26 | } 27 | 28 | impl fmt::Display for Symbol { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | write!(f, "{}", &self.token) 31 | } 32 | } 33 | 34 | impl Clone for Symbol { 35 | fn clone(&self) -> Symbol { 36 | Symbol { 37 | token: self.token.clone(), 38 | position: self.position, 39 | } 40 | } 41 | 42 | fn clone_from(&mut self, source: &Self) { 43 | self.token = source.token.clone(); 44 | self.position = source.position; 45 | } 46 | } 47 | 48 | impl AsStr for Symbol { 49 | fn as_str(&self) -> &str { 50 | &self.token 51 | } 52 | } 53 | 54 | impl SymbolJumpMode { 55 | pub fn new(config: SearchSelectConfig) -> Result { 56 | Ok(SymbolJumpMode { 57 | insert: true, 58 | input: String::new(), 59 | symbols: Vec::new(), 60 | results: SelectableVec::new(Vec::new()), 61 | config, 62 | }) 63 | } 64 | 65 | pub fn reset(&mut self, tokens: &TokenSet, config: SearchSelectConfig) -> Result<()> { 66 | self.insert = true; 67 | self.input.clear(); 68 | self.symbols = symbols(tokens.iter().chain_err(|| BUFFER_PARSE_FAILED)?); 69 | self.results = SelectableVec::new(Vec::new()); 70 | self.config = config; 71 | 72 | Ok(()) 73 | } 74 | } 75 | 76 | impl fmt::Display for SymbolJumpMode { 77 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 78 | write!(f, "SYMBOL") 79 | } 80 | } 81 | 82 | impl SearchSelectMode for SymbolJumpMode { 83 | type Item = Symbol; 84 | 85 | fn search(&mut self) { 86 | // Find the symbols we're looking for using the query. 87 | let results = if self.input.is_empty() { 88 | self.symbols 89 | .iter() 90 | .take(self.config.max_results) 91 | .cloned() 92 | .collect() 93 | } else { 94 | fragment::matching::find(&self.input, &self.symbols, self.config.max_results) 95 | .into_iter() 96 | .map(|i| i.clone()) 97 | .collect() 98 | }; 99 | 100 | self.results = SelectableVec::new(results); 101 | } 102 | 103 | fn query(&mut self) -> &mut String { 104 | &mut self.input 105 | } 106 | 107 | fn insert_mode(&self) -> bool { 108 | self.insert 109 | } 110 | 111 | fn set_insert_mode(&mut self, insert_mode: bool) { 112 | self.insert = insert_mode; 113 | } 114 | 115 | fn results(&self) -> Iter { 116 | self.results.iter() 117 | } 118 | 119 | fn selection(&self) -> Option<&Symbol> { 120 | self.results.selection() 121 | } 122 | 123 | fn selected_index(&self) -> usize { 124 | self.results.selected_index() 125 | } 126 | 127 | fn select_previous(&mut self) { 128 | self.results.select_previous(); 129 | } 130 | 131 | fn select_next(&mut self) { 132 | self.results.select_next(); 133 | } 134 | 135 | fn config(&self) -> &SearchSelectConfig { 136 | &self.config 137 | } 138 | } 139 | 140 | fn symbols<'a, T>(tokens: T) -> Vec 141 | where 142 | T: Iterator>, 143 | { 144 | let eligible_scopes = 145 | ScopeSelectors::from_str("entity.name.function, entity.name.class, entity.name.struct") 146 | .unwrap(); 147 | tokens 148 | .filter_map(|token| { 149 | if let Token::Lexeme(lexeme) = token { 150 | // Build a symbol, provided it's of the right type. 151 | if eligible_scopes 152 | .does_match(lexeme.scope.as_slice()) 153 | .is_some() 154 | { 155 | return Some(Symbol { 156 | token: lexeme.value.to_string(), 157 | position: lexeme.position, 158 | }); 159 | } 160 | } 161 | 162 | None 163 | }) 164 | .collect() 165 | } 166 | 167 | #[cfg(test)] 168 | mod tests { 169 | use super::SymbolJumpMode; 170 | use super::{symbols, Symbol}; 171 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 172 | use crate::models::application::Application; 173 | use scribe::buffer::{Lexeme, Position, ScopeStack, Token}; 174 | use std::path::Path; 175 | use std::str::FromStr; 176 | 177 | #[test] 178 | fn symbols_are_limited_to_functions() { 179 | let tokens = vec![ 180 | Token::Lexeme(Lexeme { 181 | value: "text", 182 | position: Position { line: 0, offset: 0 }, 183 | scope: ScopeStack::from_str("meta.block.rust").unwrap(), 184 | }), 185 | Token::Lexeme(Lexeme { 186 | value: "function", 187 | position: Position { line: 1, offset: 0 }, 188 | scope: ScopeStack::from_str("entity.name.function").unwrap(), 189 | }), 190 | Token::Lexeme(Lexeme { 191 | value: "non-function", 192 | position: Position { line: 2, offset: 0 }, 193 | scope: ScopeStack::from_str("meta.entity.name.function").unwrap(), 194 | }), 195 | ]; 196 | 197 | let results = symbols(tokens.into_iter()); 198 | assert_eq!(results.len(), 1); 199 | assert_eq!( 200 | results.first().unwrap(), 201 | &Symbol { 202 | token: "function".to_string(), 203 | position: Position { line: 1, offset: 0 } 204 | } 205 | ); 206 | } 207 | 208 | #[test] 209 | fn reset_clears_query_mode_and_results() { 210 | let config = SearchSelectConfig::default(); 211 | let mut mode = SymbolJumpMode::new(config.clone()).unwrap(); 212 | let mut app = Application::new(&[]).unwrap(); 213 | app.workspace.open_buffer(&Path::new("build.rs")).unwrap(); 214 | let token_set = app.workspace.current_buffer_tokens().unwrap(); 215 | 216 | // Do an initial reset to get the results populated 217 | mode.reset(&token_set, config.clone()).unwrap(); 218 | mode.query().push_str("main"); 219 | mode.set_insert_mode(false); 220 | mode.search(); 221 | 222 | // Ensure we have results before reset 223 | assert!(mode.results.len() > 0); 224 | 225 | mode.reset(&token_set, config).unwrap(); 226 | assert_eq!(mode.query(), ""); 227 | assert_eq!(mode.insert_mode(), true); 228 | assert_eq!(mode.results().len(), 0); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/models/application/modes/syntax.rs: -------------------------------------------------------------------------------- 1 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 2 | use crate::util::SelectableVec; 3 | use fragment; 4 | use std::fmt; 5 | use std::slice::Iter; 6 | 7 | pub struct SyntaxMode { 8 | insert: bool, 9 | input: String, 10 | syntaxes: Vec, 11 | results: SelectableVec, 12 | config: SearchSelectConfig, 13 | } 14 | 15 | impl SyntaxMode { 16 | pub fn new(config: SearchSelectConfig) -> SyntaxMode { 17 | SyntaxMode { 18 | insert: true, 19 | input: String::new(), 20 | syntaxes: Vec::new(), 21 | results: SelectableVec::new(Vec::new()), 22 | config, 23 | } 24 | } 25 | 26 | pub fn reset(&mut self, syntaxes: Vec, config: SearchSelectConfig) { 27 | self.input.clear(); 28 | self.insert = true; 29 | self.syntaxes = syntaxes; 30 | self.results = SelectableVec::new(Vec::new()); 31 | self.config = config; 32 | } 33 | } 34 | 35 | impl fmt::Display for SyntaxMode { 36 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 37 | write!(f, "SYNTAX") 38 | } 39 | } 40 | 41 | impl SearchSelectMode for SyntaxMode { 42 | type Item = String; 43 | 44 | fn search(&mut self) { 45 | // Find the themes we're looking for using the query. 46 | let results = if self.input.is_empty() { 47 | self.syntaxes 48 | .iter() 49 | .take(self.config.max_results) 50 | .cloned() 51 | .collect() 52 | } else { 53 | fragment::matching::find(&self.input, &self.syntaxes, self.config.max_results) 54 | .into_iter() 55 | .map(|i| i.clone()) 56 | .collect() 57 | }; 58 | 59 | self.results = SelectableVec::new(results); 60 | } 61 | 62 | fn query(&mut self) -> &mut String { 63 | &mut self.input 64 | } 65 | 66 | fn insert_mode(&self) -> bool { 67 | self.insert 68 | } 69 | 70 | fn set_insert_mode(&mut self, insert_mode: bool) { 71 | self.insert = insert_mode; 72 | } 73 | 74 | fn results(&self) -> Iter { 75 | self.results.iter() 76 | } 77 | 78 | fn selection(&self) -> Option<&String> { 79 | self.results.selection() 80 | } 81 | 82 | fn selected_index(&self) -> usize { 83 | self.results.selected_index() 84 | } 85 | 86 | fn select_previous(&mut self) { 87 | self.results.select_previous(); 88 | } 89 | 90 | fn select_next(&mut self) { 91 | self.results.select_next(); 92 | } 93 | 94 | fn config(&self) -> &SearchSelectConfig { 95 | &self.config 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::SyntaxMode; 102 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 103 | 104 | #[test] 105 | fn reset_clears_query_mode_and_results() { 106 | let config = SearchSelectConfig::default(); 107 | let mut mode = SyntaxMode::new(config.clone()); 108 | 109 | mode.reset(vec![String::from("syntax")], config.clone()); 110 | mode.query().push_str("syntax"); 111 | mode.set_insert_mode(false); 112 | mode.search(); 113 | 114 | // Ensure we have results before reset 115 | assert!(mode.results.len() > 0); 116 | 117 | mode.reset(vec![], config); 118 | assert_eq!(mode.query(), ""); 119 | assert_eq!(mode.insert_mode(), true); 120 | assert_eq!(mode.results.len(), 0); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/models/application/modes/theme.rs: -------------------------------------------------------------------------------- 1 | use crate::models::application::modes::{SearchSelectConfig, SearchSelectMode}; 2 | use crate::util::SelectableVec; 3 | use fragment; 4 | use std::fmt; 5 | use std::slice::Iter; 6 | 7 | #[derive(Default)] 8 | pub struct ThemeMode { 9 | insert: bool, 10 | input: String, 11 | themes: Vec, 12 | results: SelectableVec, 13 | config: SearchSelectConfig, 14 | } 15 | 16 | impl ThemeMode { 17 | pub fn new(config: SearchSelectConfig) -> ThemeMode { 18 | ThemeMode { 19 | config, 20 | insert: true, 21 | ..Default::default() 22 | } 23 | } 24 | 25 | pub fn reset(&mut self, themes: Vec, config: SearchSelectConfig) { 26 | *self = ThemeMode { 27 | config, 28 | insert: true, 29 | themes, 30 | ..Default::default() 31 | }; 32 | } 33 | } 34 | 35 | impl fmt::Display for ThemeMode { 36 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 37 | write!(f, "THEME") 38 | } 39 | } 40 | 41 | impl SearchSelectMode for ThemeMode { 42 | type Item = String; 43 | 44 | fn search(&mut self) { 45 | // Find the themes we're looking for using the query. 46 | let results = if self.input.is_empty() { 47 | self.themes 48 | .iter() 49 | .take(self.config.max_results) 50 | .cloned() 51 | .collect() 52 | } else { 53 | fragment::matching::find(&self.input, &self.themes, self.config.max_results) 54 | .into_iter() 55 | .map(|i| i.clone()) 56 | .collect() 57 | }; 58 | 59 | self.results = SelectableVec::new(results); 60 | } 61 | 62 | fn query(&mut self) -> &mut String { 63 | &mut self.input 64 | } 65 | 66 | fn insert_mode(&self) -> bool { 67 | self.insert 68 | } 69 | 70 | fn set_insert_mode(&mut self, insert_mode: bool) { 71 | self.insert = insert_mode; 72 | } 73 | 74 | fn results(&self) -> Iter { 75 | self.results.iter() 76 | } 77 | 78 | fn selection(&self) -> Option<&String> { 79 | self.results.selection() 80 | } 81 | 82 | fn selected_index(&self) -> usize { 83 | self.results.selected_index() 84 | } 85 | 86 | fn select_previous(&mut self) { 87 | self.results.select_previous(); 88 | } 89 | 90 | fn select_next(&mut self) { 91 | self.results.select_next(); 92 | } 93 | 94 | fn config(&self) -> &SearchSelectConfig { 95 | &self.config 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/models/application/preferences/default.yml: -------------------------------------------------------------------------------- 1 | theme: solarized_dark 2 | tab_width: 2 3 | soft_tabs: true 4 | line_length_guide: 80 5 | line_wrapping: true 6 | 7 | open_mode: 8 | exclusions: 9 | - "**/.git" 10 | 11 | types: 12 | c: 13 | line_comment_prefix: // 14 | cc: 15 | line_comment_prefix: // 16 | cmake: 17 | line_comment_prefix: '#' 18 | cpp: 19 | line_comment_prefix: // 20 | cxx: 21 | line_comment_prefix: // 22 | go: 23 | line_comment_prefix: // 24 | js: 25 | line_comment_prefix: // 26 | lua: 27 | line_comment_prefix: -- 28 | Makefile: 29 | line_comment_prefix: '#' 30 | py: 31 | line_comment_prefix: '#' 32 | rb: 33 | line_comment_prefix: '#' 34 | rs: 35 | line_comment_prefix: // 36 | sh: 37 | line_comment_prefix: '#' 38 | swift: 39 | line_comment_prefix: // 40 | tex: 41 | line_comment_prefix: '%' 42 | toml: 43 | line_comment_prefix: '#' 44 | yaml: 45 | line_comment_prefix: '#' 46 | yml: 47 | line_comment_prefix: '#' 48 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | 3 | // Published API 4 | pub use self::application::Application; 5 | -------------------------------------------------------------------------------- /src/presenters/error.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::view::{Colors, StatusLineData, Style, View}; 3 | use scribe::Workspace; 4 | 5 | pub fn display(workspace: &mut Workspace, view: &mut View, error: &Error) -> Result<()> { 6 | let data; 7 | let mut presenter = view.build_presenter().unwrap(); 8 | 9 | if let Some(buffer) = workspace.current_buffer.as_ref() { 10 | data = buffer.data(); 11 | let _ = presenter.print_buffer(buffer, &data, &workspace.syntax_set, None, None); 12 | } 13 | 14 | presenter.print_status_line(&[StatusLineData { 15 | content: error.description().to_string(), 16 | style: Style::Bold, 17 | colors: Colors::Warning, 18 | }]); 19 | 20 | presenter.present()?; 21 | 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/presenters/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod modes; 3 | 4 | use crate::view::{Colors, StatusLineData, Style}; 5 | use git2::{self, Repository, Status}; 6 | use scribe::Workspace; 7 | use std::path::{Path, PathBuf}; 8 | 9 | fn path_as_title(path: &Path) -> String { 10 | format!(" {}", path.to_string_lossy()) 11 | } 12 | 13 | fn current_buffer_status_line_data(workspace: &mut Workspace) -> StatusLineData { 14 | let modified = workspace 15 | .current_buffer 16 | .as_ref() 17 | .map(|b| b.modified()) 18 | .unwrap_or(false); 19 | 20 | let (content, style) = workspace 21 | .current_buffer_path() 22 | .map(|path| { 23 | // Determine buffer title styles based on its modification status. 24 | if modified { 25 | // Use an emboldened path with an asterisk. 26 | let mut title = path_as_title(path); 27 | title.push('*'); 28 | 29 | (title, Style::Bold) 30 | } else { 31 | (path_as_title(path), Style::Default) 32 | } 33 | }) 34 | .unwrap_or((String::new(), Style::Default)); 35 | 36 | StatusLineData { 37 | content, 38 | style, 39 | colors: Colors::Focused, 40 | } 41 | } 42 | 43 | fn git_status_line_data(repo: &Option, path: &Option) -> StatusLineData { 44 | // Build a display value for the current buffer's git status. 45 | let mut content = String::new(); 46 | if let Some(ref repo) = *repo { 47 | if let Some(ref path) = *path { 48 | if let Some(repo_path) = repo.workdir() { 49 | if let Ok(relative_path) = path.strip_prefix(repo_path) { 50 | if let Ok(status) = repo.status_file(relative_path) { 51 | content = presentable_status(&status).to_string(); 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | StatusLineData { 59 | content, 60 | style: Style::Default, 61 | colors: Colors::Focused, 62 | } 63 | } 64 | fn presentable_status(status: &Status) -> &str { 65 | if status.contains(git2::Status::WT_NEW) { 66 | if status.contains(git2::Status::INDEX_NEW) { 67 | // Parts of the file are staged as new in the index. 68 | "[partially staged]" 69 | } else { 70 | // The file has never been added to the repository. 71 | "[untracked]" 72 | } 73 | } else if status.contains(git2::Status::INDEX_NEW) { 74 | // The complete file is staged as new in the index. 75 | "[staged]" 76 | } else if status.contains(git2::Status::WT_MODIFIED) { 77 | if status.contains(git2::Status::INDEX_MODIFIED) { 78 | // The file has both staged and unstaged modifications. 79 | "[partially staged]" 80 | } else { 81 | // The file has unstaged modifications. 82 | "[modified]" 83 | } 84 | } else if status.contains(git2::Status::INDEX_MODIFIED) { 85 | // The file has staged modifications. 86 | "[staged]" 87 | } else { 88 | // The file is tracked, but has no modifications. 89 | "[ok]" 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use super::presentable_status; 96 | use git2; 97 | 98 | #[test] 99 | pub fn presentable_status_returns_untracked_when_status_is_locally_new() { 100 | let status = git2::Status::WT_NEW; 101 | assert_eq!(presentable_status(&status), "[untracked]".to_string()); 102 | } 103 | 104 | #[test] 105 | pub fn presentable_status_returns_ok_when_status_unmodified() { 106 | let status = git2::Status::CURRENT; 107 | assert_eq!(presentable_status(&status), "[ok]".to_string()); 108 | } 109 | 110 | #[test] 111 | pub fn presentable_status_returns_staged_when_only_modified_in_index() { 112 | let status = git2::Status::INDEX_MODIFIED; 113 | assert_eq!(presentable_status(&status), "[staged]".to_string()); 114 | } 115 | 116 | #[test] 117 | pub fn presentable_status_returns_staged_when_new_in_index() { 118 | let status = git2::Status::INDEX_NEW; 119 | assert_eq!(presentable_status(&status), "[staged]".to_string()); 120 | } 121 | 122 | #[test] 123 | pub fn presentable_status_returns_partially_staged_when_modified_locally_and_in_index() { 124 | let status = git2::Status::WT_MODIFIED | git2::Status::INDEX_MODIFIED; 125 | assert_eq!( 126 | presentable_status(&status), 127 | "[partially staged]".to_string() 128 | ); 129 | } 130 | 131 | #[test] 132 | pub fn presentable_status_returns_partially_staged_when_new_locally_and_in_index() { 133 | let status = git2::Status::WT_NEW | git2::Status::INDEX_NEW; 134 | assert_eq!( 135 | presentable_status(&status), 136 | "[partially staged]".to_string() 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/presenters/modes/confirm.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::view::{Colors, StatusLineData, Style, View}; 3 | use scribe::Workspace; 4 | 5 | pub fn display(workspace: &mut Workspace, view: &mut View, error: &Option) -> Result<()> { 6 | let mut presenter = view.build_presenter()?; 7 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 8 | let data = buf.data(); 9 | 10 | // Draw the visible set of tokens to the terminal. 11 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 12 | 13 | if let Some(e) = error { 14 | presenter.print_error(e.description()); 15 | } else { 16 | // Draw the status line as a search prompt. 17 | let confirmation = "Are you sure? (y/n)".to_string(); 18 | presenter.print_status_line(&[StatusLineData { 19 | content: confirmation, 20 | style: Style::Bold, 21 | colors: Colors::Warning, 22 | }]); 23 | } 24 | 25 | // Render the changes to the screen. 26 | presenter.present()?; 27 | 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /src/presenters/modes/insert.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::presenters::current_buffer_status_line_data; 3 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 4 | use scribe::Workspace; 5 | 6 | pub fn display(workspace: &mut Workspace, view: &mut View, error: &Option) -> Result<()> { 7 | let mut presenter = view.build_presenter()?; 8 | let buffer_status = current_buffer_status_line_data(workspace); 9 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 10 | let data = buf.data(); 11 | 12 | // Draw the visible set of tokens to the terminal. 13 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 14 | 15 | if let Some(e) = error { 16 | presenter.print_error(e.description()); 17 | } else { 18 | presenter.print_status_line(&[ 19 | StatusLineData { 20 | content: " INSERT ".to_string(), 21 | style: Style::Default, 22 | colors: Colors::Insert, 23 | }, 24 | buffer_status, 25 | ]); 26 | } 27 | 28 | // Show a blinking, vertical bar indicating input. 29 | presenter.set_cursor_type(CursorType::BlinkingBar); 30 | 31 | // Render the changes to the screen. 32 | presenter.present()?; 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/presenters/modes/jump.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::JumpMode; 3 | use crate::presenters::current_buffer_status_line_data; 4 | use crate::view::{Colors, StatusLineData, Style, View}; 5 | use scribe::Workspace; 6 | 7 | pub fn display( 8 | workspace: &mut Workspace, 9 | mode: &mut JumpMode, 10 | view: &mut View, 11 | error: &Option, 12 | ) -> Result<()> { 13 | let mut presenter = view.build_presenter()?; 14 | let buffer_status = current_buffer_status_line_data(workspace); 15 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 16 | let data = buf.data(); 17 | 18 | mode.reset_display(); 19 | 20 | // Draw the visible set of tokens to the terminal. 21 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, Some(mode))?; 22 | 23 | if let Some(e) = error { 24 | presenter.print_error(e.description()); 25 | } else { 26 | presenter.print_status_line(&[ 27 | StatusLineData { 28 | content: " JUMP ".to_string(), 29 | style: Style::Default, 30 | colors: Colors::Inverted, 31 | }, 32 | buffer_status, 33 | ]); 34 | } 35 | 36 | // Don't display a cursor. 37 | presenter.set_cursor(None); 38 | 39 | // Render the changes to the screen. 40 | presenter.present()?; 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /src/presenters/modes/line_jump.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::LineJumpMode; 3 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 4 | use scribe::buffer::Position; 5 | use scribe::Workspace; 6 | 7 | pub fn display( 8 | workspace: &mut Workspace, 9 | mode: &LineJumpMode, 10 | view: &mut View, 11 | error: &Option, 12 | ) -> Result<()> { 13 | let mut presenter = view.build_presenter()?; 14 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 15 | let data = buf.data(); 16 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 17 | 18 | let input_prompt = format!("Go to line: {}", mode.input); 19 | let input_prompt_len = input_prompt.len(); 20 | if let Some(e) = error { 21 | presenter.print_error(e.description()); 22 | } else { 23 | // Draw the status line as an input prompt. 24 | presenter.print_status_line(&[StatusLineData { 25 | content: input_prompt, 26 | style: Style::Default, 27 | colors: Colors::Default, 28 | }]); 29 | } 30 | 31 | // Move the cursor to the end of the search query input. 32 | let cursor_line = presenter.height() - 1; 33 | presenter.set_cursor(Some(Position { 34 | line: cursor_line, 35 | offset: input_prompt_len, 36 | })); 37 | 38 | // Show a blinking, vertical bar indicating input. 39 | presenter.set_cursor_type(CursorType::BlinkingBar); 40 | 41 | // Render the changes to the screen. 42 | presenter.present()?; 43 | 44 | Ok(()) 45 | } 46 | -------------------------------------------------------------------------------- /src/presenters/modes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod confirm; 2 | pub mod insert; 3 | pub mod jump; 4 | pub mod line_jump; 5 | pub mod normal; 6 | pub mod open; 7 | pub mod path; 8 | pub mod search; 9 | pub mod search_select; 10 | pub mod select; 11 | pub mod select_line; 12 | -------------------------------------------------------------------------------- /src/presenters/modes/normal.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::presenters::{current_buffer_status_line_data, git_status_line_data}; 3 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 4 | use git2::Repository; 5 | use scribe::buffer::Position; 6 | use scribe::Workspace; 7 | 8 | pub fn display( 9 | workspace: &mut Workspace, 10 | view: &mut View, 11 | repo: &Option, 12 | error: &Option, 13 | ) -> Result<()> { 14 | let mut presenter = view.build_presenter()?; 15 | let buffer_status = current_buffer_status_line_data(workspace); 16 | 17 | if let Some(buf) = workspace.current_buffer.as_ref() { 18 | // Draw the visible set of tokens to the terminal. 19 | let data = buf.data(); 20 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 21 | 22 | // Determine mode display color based on buffer modification status. 23 | let colors = if buf.modified() { 24 | Colors::Warning 25 | } else { 26 | Colors::Inverted 27 | }; 28 | 29 | if let Some(e) = error { 30 | presenter.print_error(e.description()); 31 | } else { 32 | // Build the status line mode and buffer title display. 33 | presenter.print_status_line(&[ 34 | StatusLineData { 35 | content: " NORMAL ".to_string(), 36 | style: Style::Default, 37 | colors, 38 | }, 39 | buffer_status, 40 | git_status_line_data(repo, &buf.path), 41 | ]); 42 | } 43 | 44 | // Restore the default cursor, suggesting non-input mode. 45 | presenter.set_cursor_type(CursorType::Block); 46 | 47 | presenter.present()?; 48 | } else { 49 | let content = [ 50 | format!("Amp v{}", env!("CARGO_PKG_VERSION")), 51 | format!("Build revision {}", env!("BUILD_REVISION")), 52 | String::from("© 2015-2024 Jordan MacDonald"), 53 | String::from(" "), 54 | String::from("Press \"?\" to view quick start guide"), 55 | ]; 56 | let line_count = content.len(); 57 | let vertical_offset = line_count / 2; 58 | 59 | for (line_no, line) in content.iter().enumerate() { 60 | let position = Position { 61 | line: (presenter.height() / 2 + line_no).saturating_sub(vertical_offset), 62 | offset: (presenter.width() / 2).saturating_sub(line.chars().count() / 2), 63 | }; 64 | 65 | presenter.print(&position, Style::Default, Colors::Default, line); 66 | } 67 | 68 | if let Some(e) = error { 69 | presenter.print_error(e.description()); 70 | } 71 | 72 | presenter.set_cursor(None); 73 | presenter.present()?; 74 | } 75 | 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /src/presenters/modes/open.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::{OpenMode, SearchSelectMode}; 3 | use crate::presenters::current_buffer_status_line_data; 4 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 5 | use scribe::buffer::Position; 6 | use scribe::Workspace; 7 | use std::cmp; 8 | use unicode_segmentation::UnicodeSegmentation; 9 | 10 | pub fn display( 11 | workspace: &mut Workspace, 12 | mode: &mut OpenMode, 13 | view: &mut View, 14 | error: &Option, 15 | ) -> Result<()> { 16 | let data; 17 | let padded_message; 18 | let mut presenter = view.build_presenter()?; 19 | let mode_config = mode.config().clone(); 20 | let mut padded_content = Vec::new(); 21 | let mut remaining_lines = Vec::new(); 22 | 23 | let buffer_status = current_buffer_status_line_data(workspace); 24 | 25 | if let Some(buf) = workspace.current_buffer.as_ref() { 26 | data = buf.data(); 27 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 28 | 29 | if let Some(e) = error { 30 | presenter.print_error(e.description()); 31 | } else { 32 | presenter.print_status_line(&[ 33 | StatusLineData { 34 | content: format!(" {mode} "), 35 | style: Style::Default, 36 | colors: Colors::Inverted, 37 | }, 38 | buffer_status, 39 | ]); 40 | } 41 | } 42 | 43 | if let Some(message) = mode.message() { 44 | padded_message = format!("{:width$}", message, width = presenter.width()); 45 | presenter.print( 46 | &Position { line: 0, offset: 0 }, 47 | Style::Default, 48 | Colors::Default, 49 | &padded_message, 50 | ); 51 | } else { 52 | // Draw the list of search results. 53 | let selected_indices = mode.selected_indices(); 54 | 55 | for (line, result) in mode.results().enumerate() { 56 | let (content, colors, style) = if line == mode.selected_index() { 57 | (format!("> {result}"), Colors::Focused, Style::Bold) 58 | } else if selected_indices.contains(&line) { 59 | (format!(" {result}"), Colors::Focused, Style::Bold) 60 | } else { 61 | (format!(" {result}"), Colors::Default, Style::Default) 62 | }; 63 | 64 | // Ensure content doesn't exceed the screen width 65 | let trimmed_content: String = content 66 | .graphemes(true) 67 | .enumerate() 68 | .take_while(|(i, _)| i < &presenter.width()) 69 | .map(|(_, g)| g) 70 | .collect(); 71 | 72 | padded_content.push(( 73 | Position { line, offset: 0 }, 74 | style, 75 | colors, 76 | format!("{:width$}", trimmed_content, width = presenter.width()), 77 | )); 78 | } 79 | 80 | for (position, style, colors, content) in padded_content.iter() { 81 | presenter.print(position, *style, *colors, content); 82 | } 83 | } 84 | 85 | // Clear any remaining lines in the result display area. 86 | for line in cmp::max(mode.results().len(), 1)..mode_config.max_results { 87 | remaining_lines.push(( 88 | Position { line, offset: 0 }, 89 | Style::Default, 90 | Colors::Default, 91 | format!("{:width$}", ' ', width = presenter.width()), 92 | )); 93 | } 94 | 95 | for (position, style, colors, content) in remaining_lines.iter() { 96 | presenter.print(position, *style, *colors, content); 97 | } 98 | 99 | // Draw the divider. 100 | let line = mode_config.max_results; 101 | let colors = if mode.insert_mode() { 102 | Colors::Insert 103 | } else { 104 | Colors::Inverted 105 | }; 106 | 107 | let mut pinned_content = String::new(); 108 | if !mode.pinned_query().is_empty() { 109 | pinned_content = format!(" {} ", mode.pinned_query()); 110 | 111 | presenter.print( 112 | &Position { line, offset: 0 }, 113 | Style::Bold, 114 | Colors::PinnedQuery, 115 | &pinned_content, 116 | ); 117 | } 118 | let pinned_content_length = pinned_content.graphemes(true).count(); 119 | 120 | let padded_content = format!( 121 | "{:width$}", 122 | mode.query(), 123 | width = presenter.width() - pinned_content_length 124 | ); 125 | 126 | presenter.print( 127 | &Position { 128 | line, 129 | offset: pinned_content_length, 130 | }, 131 | Style::Bold, 132 | colors, 133 | &padded_content, 134 | ); 135 | 136 | if mode.insert_mode() { 137 | // Place the cursor on the search input line, right after its contents. 138 | presenter.set_cursor(Some(Position { 139 | line: mode_config.max_results, 140 | offset: pinned_content_length + mode.query().graphemes(true).count(), 141 | })); 142 | 143 | // Show a blinking, vertical bar indicating input. 144 | presenter.set_cursor_type(CursorType::BlinkingBar); 145 | } else { 146 | // Hide the cursor. 147 | presenter.set_cursor(None); 148 | } 149 | 150 | // Render the changes to the screen. 151 | presenter.present()?; 152 | 153 | Ok(()) 154 | } 155 | -------------------------------------------------------------------------------- /src/presenters/modes/path.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::PathMode; 3 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 4 | use scribe::buffer::Position; 5 | use scribe::Workspace; 6 | use unicode_segmentation::UnicodeSegmentation; 7 | 8 | pub fn display( 9 | workspace: &mut Workspace, 10 | mode: &PathMode, 11 | view: &mut View, 12 | error: &Option, 13 | ) -> Result<()> { 14 | let mut presenter = view.build_presenter()?; 15 | 16 | // Draw the visible set of tokens to the terminal. 17 | let buffer = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 18 | let data = buffer.data(); 19 | presenter.print_buffer(buffer, &data, &workspace.syntax_set, None, None)?; 20 | 21 | let mode_display = format!(" {mode} "); 22 | let search_input = format!(" {}", mode.input); 23 | 24 | let cursor_offset = mode_display.graphemes(true).count() + search_input.graphemes(true).count(); 25 | 26 | if let Some(e) = error { 27 | presenter.print_error(e.description()); 28 | } else { 29 | presenter.print_status_line(&[ 30 | StatusLineData { 31 | content: mode_display, 32 | style: Style::Default, 33 | colors: Colors::PathMode, 34 | }, 35 | StatusLineData { 36 | content: search_input, 37 | style: Style::Default, 38 | colors: Colors::Focused, 39 | }, 40 | ]); 41 | } 42 | 43 | // Move the cursor to the end of the search query input. 44 | { 45 | let cursor_line = presenter.height() - 1; 46 | presenter.set_cursor(Some(Position { 47 | line: cursor_line, 48 | offset: cursor_offset, 49 | })); 50 | } 51 | 52 | // Show a blinking, vertical bar indicating input. 53 | presenter.set_cursor_type(CursorType::BlinkingBar); 54 | 55 | // Render the changes to the screen. 56 | presenter.present()?; 57 | 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /src/presenters/modes/search.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::SearchMode; 3 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 4 | use scribe::buffer::Position; 5 | use scribe::Workspace; 6 | use unicode_segmentation::UnicodeSegmentation; 7 | 8 | pub fn display( 9 | workspace: &mut Workspace, 10 | mode: &SearchMode, 11 | view: &mut View, 12 | error: &Option, 13 | ) -> Result<()> { 14 | let mut presenter = view.build_presenter()?; 15 | 16 | // Draw the visible set of tokens to the terminal. 17 | let buffer = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 18 | let data = buffer.data(); 19 | presenter.print_buffer( 20 | buffer, 21 | &data, 22 | &workspace.syntax_set, 23 | mode.results.as_ref().map(|r| r.as_slice()), 24 | None, 25 | )?; 26 | 27 | let mode_display = format!(" {mode} "); 28 | let search_input = format!(" {}", mode.input.as_ref().unwrap_or(&String::new())); 29 | let result_display = if mode.insert { 30 | String::new() 31 | } else if let Some(ref results) = mode.results { 32 | if results.len() == 1 { 33 | String::from("1 match") 34 | } else { 35 | format!( 36 | "{} of {} matches", 37 | results.selected_index() + 1, 38 | results.len() 39 | ) 40 | } 41 | } else { 42 | String::new() 43 | }; 44 | 45 | let cursor_offset = mode_display.graphemes(true).count() + search_input.graphemes(true).count(); 46 | 47 | if let Some(e) = error { 48 | presenter.print_error(e.description()); 49 | } else { 50 | presenter.print_status_line(&[ 51 | StatusLineData { 52 | content: mode_display, 53 | style: Style::Default, 54 | colors: Colors::SearchMode, 55 | }, 56 | StatusLineData { 57 | content: search_input, 58 | style: Style::Default, 59 | colors: Colors::Focused, 60 | }, 61 | StatusLineData { 62 | content: result_display, 63 | style: Style::Default, 64 | colors: Colors::Focused, 65 | }, 66 | ]); 67 | } 68 | 69 | // Move the cursor to the end of the search query input. 70 | if mode.insert { 71 | let cursor_line = presenter.height() - 1; 72 | presenter.set_cursor(Some(Position { 73 | line: cursor_line, 74 | offset: cursor_offset, 75 | })); 76 | } 77 | 78 | // Show a blinking, vertical bar indicating input. 79 | presenter.set_cursor_type(CursorType::BlinkingBar); 80 | 81 | // Render the changes to the screen. 82 | presenter.present()?; 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/presenters/modes/search_select.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::SearchSelectMode; 3 | use crate::presenters::current_buffer_status_line_data; 4 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 5 | use scribe::buffer::Position; 6 | use scribe::Workspace; 7 | use std::cmp; 8 | use std::fmt::Display; 9 | use unicode_segmentation::UnicodeSegmentation; 10 | 11 | pub fn display( 12 | workspace: &mut Workspace, 13 | mode: &mut T, 14 | view: &mut View, 15 | error: &Option, 16 | ) -> Result<()> { 17 | let data; 18 | let padded_message; 19 | let mut presenter = view.build_presenter()?; 20 | let mode_config = mode.config().clone(); 21 | let mut padded_content = Vec::new(); 22 | let mut remaining_lines = Vec::new(); 23 | 24 | let buffer_status = current_buffer_status_line_data(workspace); 25 | 26 | if let Some(buf) = workspace.current_buffer.as_ref() { 27 | data = buf.data(); 28 | presenter.print_buffer(buf, &data, &workspace.syntax_set, None, None)?; 29 | 30 | if let Some(e) = error { 31 | presenter.print_error(e.description()); 32 | } else { 33 | presenter.print_status_line(&[ 34 | StatusLineData { 35 | content: format!(" {mode} "), 36 | style: Style::Default, 37 | colors: Colors::Inverted, 38 | }, 39 | buffer_status, 40 | ]); 41 | } 42 | } 43 | 44 | if let Some(message) = mode.message() { 45 | padded_message = format!("{:width$}", message, width = presenter.width()); 46 | presenter.print( 47 | &Position { line: 0, offset: 0 }, 48 | Style::Default, 49 | Colors::Default, 50 | &padded_message, 51 | ); 52 | } else { 53 | // Draw the list of search results. 54 | for (line, result) in mode.results().enumerate() { 55 | let (content, colors, style) = if line == mode.selected_index() { 56 | (format!("> {result}"), Colors::Focused, Style::Bold) 57 | } else { 58 | (format!(" {result}"), Colors::Default, Style::Default) 59 | }; 60 | 61 | // Ensure content doesn't exceed the screen width 62 | let trimmed_content: String = content 63 | .graphemes(true) 64 | .enumerate() 65 | .take_while(|(i, _)| i < &presenter.width()) 66 | .map(|(_, g)| g) 67 | .collect(); 68 | 69 | padded_content.push(( 70 | Position { line, offset: 0 }, 71 | style, 72 | colors, 73 | format!("{:width$}", trimmed_content, width = presenter.width()), 74 | )); 75 | } 76 | 77 | for (position, style, colors, content) in padded_content.iter() { 78 | presenter.print(position, *style, *colors, content); 79 | } 80 | } 81 | 82 | // Clear any remaining lines in the result display area. 83 | for line in cmp::max(mode.results().len(), 1)..mode_config.max_results { 84 | remaining_lines.push(( 85 | Position { line, offset: 0 }, 86 | Style::Default, 87 | Colors::Default, 88 | format!("{:width$}", ' ', width = presenter.width()), 89 | )); 90 | } 91 | 92 | for (position, style, colors, content) in remaining_lines.iter() { 93 | presenter.print(position, *style, *colors, content); 94 | } 95 | 96 | // Draw the divider. 97 | let line = mode_config.max_results; 98 | let colors = if mode.insert_mode() { 99 | Colors::Insert 100 | } else { 101 | Colors::Inverted 102 | }; 103 | 104 | let padded_content = format!("{:width$}", mode.query(), width = presenter.width()); 105 | 106 | presenter.print( 107 | &Position { line, offset: 0 }, 108 | Style::Bold, 109 | colors, 110 | &padded_content, 111 | ); 112 | 113 | if mode.insert_mode() { 114 | // Place the cursor on the search input line, right after its contents. 115 | presenter.set_cursor(Some(Position { 116 | line: mode_config.max_results, 117 | offset: mode.query().graphemes(true).count(), 118 | })); 119 | 120 | // Show a blinking, vertical bar indicating input. 121 | presenter.set_cursor_type(CursorType::BlinkingBar); 122 | } else { 123 | // Hide the cursor. 124 | presenter.set_cursor(None); 125 | } 126 | 127 | // Render the changes to the screen. 128 | presenter.present()?; 129 | 130 | Ok(()) 131 | } 132 | -------------------------------------------------------------------------------- /src/presenters/modes/select.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::SelectMode; 3 | use crate::presenters::current_buffer_status_line_data; 4 | use crate::view::{Colors, CursorType, StatusLineData, Style, View}; 5 | use scribe::buffer::Range; 6 | use scribe::Workspace; 7 | 8 | pub fn display( 9 | workspace: &mut Workspace, 10 | mode: &SelectMode, 11 | view: &mut View, 12 | error: &Option, 13 | ) -> Result<()> { 14 | let mut presenter = view.build_presenter()?; 15 | let buffer_status = current_buffer_status_line_data(workspace); 16 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 17 | let selected_range = Range::new(mode.anchor, *buf.cursor.clone()); 18 | let data = buf.data(); 19 | 20 | // Draw the visible set of tokens to the terminal. 21 | presenter.print_buffer( 22 | buf, 23 | &data, 24 | &workspace.syntax_set, 25 | Some(&[selected_range]), 26 | None, 27 | )?; 28 | 29 | if let Some(e) = error { 30 | presenter.print_error(e.description()); 31 | } else { 32 | presenter.print_status_line(&[ 33 | StatusLineData { 34 | content: " SELECT ".to_string(), 35 | style: Style::Default, 36 | colors: Colors::SelectMode, 37 | }, 38 | buffer_status, 39 | ]); 40 | } 41 | 42 | // Show a vertical bar to allow unambiguous/precise selection. 43 | presenter.set_cursor_type(CursorType::Bar); 44 | 45 | // Render the changes to the screen. 46 | presenter.present()?; 47 | 48 | Ok(()) 49 | } 50 | -------------------------------------------------------------------------------- /src/presenters/modes/select_line.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::models::application::modes::SelectLineMode; 3 | use crate::presenters::current_buffer_status_line_data; 4 | use crate::view::{Colors, StatusLineData, Style, View}; 5 | use scribe::Workspace; 6 | 7 | pub fn display( 8 | workspace: &mut Workspace, 9 | mode: &SelectLineMode, 10 | view: &mut View, 11 | error: &Option, 12 | ) -> Result<()> { 13 | let mut presenter = view.build_presenter()?; 14 | let buffer_status = current_buffer_status_line_data(workspace); 15 | let buf = workspace.current_buffer.as_ref().ok_or(BUFFER_MISSING)?; 16 | let selected_range = mode.to_range(&buf.cursor); 17 | let data = buf.data(); 18 | 19 | // Draw the visible set of tokens to the terminal. 20 | presenter.print_buffer( 21 | buf, 22 | &data, 23 | &workspace.syntax_set, 24 | Some(&[selected_range]), 25 | None, 26 | )?; 27 | 28 | if let Some(e) = error { 29 | presenter.print_error(e.description()); 30 | } else { 31 | presenter.print_status_line(&[ 32 | StatusLineData { 33 | content: " SELECT LINE ".to_string(), 34 | style: Style::Default, 35 | colors: Colors::SelectMode, 36 | }, 37 | buffer_status, 38 | ]); 39 | } 40 | 41 | // Render the changes to the screen. 42 | presenter.present()?; 43 | 44 | Ok(()) 45 | } 46 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::selectable_vec::SelectableVec; 2 | 3 | pub mod movement_lexer; 4 | pub mod reflow; 5 | mod selectable_vec; 6 | pub mod token; 7 | 8 | use crate::errors::*; 9 | use crate::models::Application; 10 | use scribe::buffer::{Buffer, LineRange, Position, Range}; 11 | use std::path::Path; 12 | 13 | /// Translates a line range to a regular range, including its last line. 14 | /// Handles ranges including and end line without trailing newline character. 15 | pub fn inclusive_range(line_range: &LineRange, buffer: &mut Buffer) -> Range { 16 | let data = buffer.data(); 17 | let next_line = line_range.end() + 1; 18 | let line_count = buffer.line_count(); 19 | let end_position = if line_count > next_line { 20 | // There's a line below the end of the range, so just use that 21 | // to capture the last line and its trailing newline character. 22 | Position { 23 | line: next_line, 24 | offset: 0, 25 | } 26 | } else { 27 | // There isn't a line below the end of the range, so try to get 28 | // the last line's length and use that as the ending offset. 29 | match data.lines().nth(line_range.end()) { 30 | Some(line_content) => { 31 | // Found the last line's content; use it. 32 | Position { 33 | line: line_range.end(), 34 | offset: line_content.len(), 35 | } 36 | } 37 | // Couldn't find any content for the last line; use a zero offset. 38 | None => Position { 39 | line: line_range.end(), 40 | offset: 0, 41 | }, 42 | } 43 | }; 44 | 45 | Range::new( 46 | Position { 47 | line: line_range.start(), 48 | offset: 0, 49 | }, 50 | end_position, 51 | ) 52 | } 53 | 54 | /// Convenience method to open/initialize a file as a buffer in the workspace. 55 | pub fn open_buffer(path: &Path, app: &mut Application) -> Result<()> { 56 | let syntax_definition = app 57 | .preferences 58 | .borrow() 59 | .syntax_definition_name(&path) 60 | .and_then(|name| app.workspace.syntax_set.find_syntax_by_name(&name).cloned()); 61 | 62 | app.workspace 63 | .open_buffer(&path) 64 | .chain_err(|| "Couldn't open a buffer for the specified path.")?; 65 | 66 | let buffer = app.workspace.current_buffer.as_mut().unwrap(); 67 | 68 | // Only override the default syntax definition if the user provided 69 | // a valid one in their preferences. 70 | if syntax_definition.is_some() { 71 | buffer.syntax_definition = syntax_definition; 72 | } 73 | 74 | app.view.initialize_buffer(buffer)?; 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Convenience method to add/initialize an in-memory buffer in the workspace. 80 | pub fn add_buffer(buffer: Buffer, app: &mut Application) -> Result<()> { 81 | app.workspace.add_buffer(buffer); 82 | app.view 83 | .initialize_buffer(app.workspace.current_buffer.as_mut().unwrap())?; 84 | 85 | Ok(()) 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use scribe::buffer::{LineRange, Position, Range}; 91 | use scribe::Buffer; 92 | 93 | #[test] 94 | fn inclusive_range_works_correctly_without_trailing_newline() { 95 | let mut buffer = Buffer::new(); 96 | buffer.insert("amp\neditor"); 97 | let range = LineRange::new(1, 1); 98 | 99 | assert_eq!( 100 | super::inclusive_range(&range, &mut buffer), 101 | Range::new( 102 | Position { line: 1, offset: 0 }, 103 | Position { line: 1, offset: 6 } 104 | ) 105 | ); 106 | } 107 | 108 | #[test] 109 | fn inclusive_range_works_correctly_with_trailing_newline() { 110 | let mut buffer = Buffer::new(); 111 | buffer.insert("amp\neditor\n"); 112 | let range = LineRange::new(1, 1); 113 | 114 | assert_eq!( 115 | super::inclusive_range(&range, &mut buffer), 116 | Range::new( 117 | Position { line: 1, offset: 0 }, 118 | Position { line: 2, offset: 0 } 119 | ) 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/util/movement_lexer.rs: -------------------------------------------------------------------------------- 1 | use luthor::token::{Category, Token}; 2 | use luthor::{StateFunction, Tokenizer}; 3 | 4 | fn initial_state(lexer: &mut Tokenizer) -> Option { 5 | if lexer.has_prefix("::") { 6 | lexer.tokenize(Category::Text); 7 | lexer.tokenize_next(2, Category::Text); 8 | } 9 | 10 | match lexer.current_char() { 11 | Some(c) => { 12 | match c { 13 | ' ' | '\n' | '\t' => { 14 | lexer.tokenize(Category::Text); 15 | lexer.advance(); 16 | return Some(StateFunction(whitespace)); 17 | } 18 | '`' | '=' | '_' | '-' | '.' | '(' | ')' | '{' | '}' | ';' | '|' | ',' | ':' 19 | | '<' | '>' | '\'' | '"' | '?' | '@' | '#' | '/' | '\\' | '[' | ']' => { 20 | lexer.tokenize(Category::Text); 21 | lexer.tokenize_next(1, Category::Text); 22 | return Some(StateFunction(whitespace)); 23 | } 24 | _ => { 25 | if c.is_uppercase() { 26 | lexer.tokenize(Category::Text); 27 | lexer.advance(); 28 | return Some(StateFunction(uppercase)); 29 | } 30 | 31 | lexer.advance() 32 | } 33 | } 34 | 35 | Some(StateFunction(initial_state)) 36 | } 37 | 38 | None => { 39 | lexer.tokenize(Category::Text); 40 | None 41 | } 42 | } 43 | } 44 | 45 | fn whitespace(lexer: &mut Tokenizer) -> Option { 46 | match lexer.current_char() { 47 | Some(c) => match c { 48 | ' ' | '\n' | '\t' => { 49 | lexer.advance(); 50 | Some(StateFunction(whitespace)) 51 | } 52 | _ => { 53 | lexer.tokenize(Category::Whitespace); 54 | Some(StateFunction(initial_state)) 55 | } 56 | }, 57 | 58 | None => { 59 | lexer.tokenize(Category::Whitespace); 60 | None 61 | } 62 | } 63 | } 64 | 65 | fn uppercase(lexer: &mut Tokenizer) -> Option { 66 | match lexer.current_char() { 67 | Some(c) => { 68 | if c.is_alphabetic() { 69 | lexer.advance(); 70 | 71 | if c.is_uppercase() { 72 | Some(StateFunction(uppercase)) 73 | } else { 74 | Some(StateFunction(initial_state)) 75 | } 76 | } else { 77 | lexer.tokenize(Category::Text); 78 | Some(StateFunction(initial_state)) 79 | } 80 | } 81 | None => { 82 | lexer.tokenize(Category::Text); 83 | None 84 | } 85 | } 86 | } 87 | 88 | pub fn lex(data: &str) -> Vec { 89 | let mut lexer = Tokenizer::new(data); 90 | let mut state_function = StateFunction(initial_state); 91 | loop { 92 | let StateFunction(actual_function) = state_function; 93 | match actual_function(&mut lexer) { 94 | Some(f) => state_function = f, 95 | None => return lexer.tokens(), 96 | } 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use super::*; 103 | use luthor::token::{Category, Token}; 104 | 105 | #[test] 106 | fn it_works() { 107 | let data = 108 | "local_variable = camelCase.method(param)\n CamelCaseClass something-else CONSTANT val"; 109 | let tokens = lex(data); 110 | let expected_tokens = vec![ 111 | Token { 112 | lexeme: "local".to_string(), 113 | category: Category::Text, 114 | }, 115 | Token { 116 | lexeme: "_".to_string(), 117 | category: Category::Text, 118 | }, 119 | Token { 120 | lexeme: "variable".to_string(), 121 | category: Category::Text, 122 | }, 123 | Token { 124 | lexeme: " ".to_string(), 125 | category: Category::Whitespace, 126 | }, 127 | Token { 128 | lexeme: "=".to_string(), 129 | category: Category::Text, 130 | }, 131 | Token { 132 | lexeme: " ".to_string(), 133 | category: Category::Whitespace, 134 | }, 135 | Token { 136 | lexeme: "camel".to_string(), 137 | category: Category::Text, 138 | }, 139 | Token { 140 | lexeme: "Case".to_string(), 141 | category: Category::Text, 142 | }, 143 | Token { 144 | lexeme: ".".to_string(), 145 | category: Category::Text, 146 | }, 147 | Token { 148 | lexeme: "method".to_string(), 149 | category: Category::Text, 150 | }, 151 | Token { 152 | lexeme: "(".to_string(), 153 | category: Category::Text, 154 | }, 155 | Token { 156 | lexeme: "param".to_string(), 157 | category: Category::Text, 158 | }, 159 | Token { 160 | lexeme: ")".to_string(), 161 | category: Category::Text, 162 | }, 163 | Token { 164 | lexeme: "\n ".to_string(), 165 | category: Category::Whitespace, 166 | }, 167 | Token { 168 | lexeme: "Camel".to_string(), 169 | category: Category::Text, 170 | }, 171 | Token { 172 | lexeme: "Case".to_string(), 173 | category: Category::Text, 174 | }, 175 | Token { 176 | lexeme: "Class".to_string(), 177 | category: Category::Text, 178 | }, 179 | Token { 180 | lexeme: " ".to_string(), 181 | category: Category::Whitespace, 182 | }, 183 | Token { 184 | lexeme: "something".to_string(), 185 | category: Category::Text, 186 | }, 187 | Token { 188 | lexeme: "-".to_string(), 189 | category: Category::Text, 190 | }, 191 | Token { 192 | lexeme: "else".to_string(), 193 | category: Category::Text, 194 | }, 195 | Token { 196 | lexeme: " ".to_string(), 197 | category: Category::Whitespace, 198 | }, 199 | Token { 200 | lexeme: "CONSTANT".to_string(), 201 | category: Category::Text, 202 | }, 203 | Token { 204 | lexeme: " ".to_string(), 205 | category: Category::Whitespace, 206 | }, 207 | Token { 208 | lexeme: "val".to_string(), 209 | category: Category::Text, 210 | }, 211 | ]; 212 | 213 | for (index, token) in tokens.iter().enumerate() { 214 | assert_eq!(*token, expected_tokens[index]); 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/util/selectable_vec.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use std::ops::Deref; 3 | 4 | /// A simple decorator around a Vec that allows a single element to be selected. 5 | /// The selection can be incremented/decremented in single steps, and the 6 | /// selected value wraps when moved beyond either edge of the set. 7 | #[derive(Default)] 8 | pub struct SelectableVec { 9 | set: Vec, 10 | selected_index: usize, 11 | } 12 | 13 | impl SelectableVec { 14 | pub fn new(set: Vec) -> SelectableVec { 15 | SelectableVec { 16 | set, 17 | selected_index: 0, 18 | } 19 | } 20 | 21 | pub fn set_selected_index(&mut self, index: usize) -> Result<()> { 22 | if index >= self.set.len() { 23 | bail!(SELECTED_INDEX_OUT_OF_RANGE); 24 | } 25 | 26 | self.selected_index = index; 27 | Ok(()) 28 | } 29 | 30 | pub fn selected_index(&self) -> usize { 31 | self.selected_index 32 | } 33 | 34 | pub fn selection(&self) -> Option<&T> { 35 | self.set.get(self.selected_index) 36 | } 37 | 38 | pub fn select_previous(&mut self) { 39 | if self.selected_index > 0 { 40 | self.selected_index -= 1; 41 | } else { 42 | self.selected_index = self.set.len() - 1; 43 | } 44 | } 45 | 46 | pub fn select_next(&mut self) { 47 | if self.selected_index < self.set.len() - 1 { 48 | self.selected_index += 1; 49 | } else { 50 | self.selected_index = 0; 51 | } 52 | } 53 | } 54 | 55 | impl Deref for SelectableVec { 56 | type Target = Vec; 57 | 58 | fn deref(&self) -> &Vec { 59 | &self.set 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use super::SelectableVec; 66 | 67 | #[test] 68 | fn selection_returns_none_when_the_set_is_empty() { 69 | let selectable_vec: SelectableVec = SelectableVec::new(Vec::new()); 70 | assert!(selectable_vec.selection().is_none()); 71 | } 72 | 73 | #[test] 74 | fn selection_returns_selected_element() { 75 | let mut selectable_vec: SelectableVec = SelectableVec::new(vec![0, 1, 2]); 76 | selectable_vec.select_next(); 77 | assert_eq!(selectable_vec.selection(), Some(&1)); 78 | } 79 | 80 | #[test] 81 | fn select_next_wraps_at_end_of_set() { 82 | let mut selectable_vec: SelectableVec = SelectableVec::new(vec![0, 1]); 83 | selectable_vec.select_next(); 84 | selectable_vec.select_next(); 85 | assert_eq!(selectable_vec.selection(), Some(&0)); 86 | } 87 | 88 | #[test] 89 | fn select_previous_wraps_at_start_of_set() { 90 | let mut selectable_vec: SelectableVec = SelectableVec::new(vec![0, 1]); 91 | selectable_vec.select_previous(); 92 | assert_eq!(selectable_vec.selection(), Some(&1)); 93 | } 94 | 95 | #[test] 96 | fn set_selected_index_works_when_in_range() { 97 | let mut selectable_vec: SelectableVec = SelectableVec::new(vec![0, 1]); 98 | assert_eq!(selectable_vec.selected_index(), 0); 99 | selectable_vec.set_selected_index(1).unwrap(); 100 | assert_eq!(selectable_vec.selected_index(), 1); 101 | } 102 | 103 | #[test] 104 | fn set_selected_index_rejects_values_outside_range() { 105 | let mut selectable_vec: SelectableVec = SelectableVec::new(vec![0, 1]); 106 | assert_eq!(selectable_vec.selected_index(), 0); 107 | assert!(selectable_vec.set_selected_index(2).is_err()); 108 | assert_eq!(selectable_vec.selected_index(), 0); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/util/token.rs: -------------------------------------------------------------------------------- 1 | use crate::util::movement_lexer; 2 | use luthor::token::Category; 3 | use scribe::buffer::{Buffer, Position}; 4 | 5 | #[derive(Clone, Copy, PartialEq)] 6 | pub enum Direction { 7 | Forward, 8 | Backward, 9 | } 10 | 11 | pub fn adjacent_token_position( 12 | buffer: &Buffer, 13 | whitespace: bool, 14 | direction: Direction, 15 | ) -> Option { 16 | let mut line = 0; 17 | let mut offset = 0; 18 | let mut previous_position = Position { line: 0, offset: 0 }; 19 | let tokens = movement_lexer::lex(&buffer.data()); 20 | for token in tokens { 21 | let position = Position { line, offset }; 22 | if position > *buffer.cursor && direction == Direction::Forward { 23 | // We've found the next token! 24 | if whitespace { 25 | // We're allowing whitespace, so return the token. 26 | return Some(position); 27 | } else { 28 | // We're not allowing whitespace; skip this token if that's what it is. 29 | match token.category { 30 | Category::Whitespace => (), 31 | _ => { 32 | return Some(position); 33 | } 34 | } 35 | } 36 | } 37 | 38 | // We've not yet found it; advance to the next token. 39 | match token.lexeme.split('\n').count() { 40 | 1 => { 41 | // There's only one line in this token, so 42 | // only advance the offset by its size. 43 | offset += token.lexeme.len() 44 | } 45 | n => { 46 | // There are multiple lines, so advance the 47 | // line count and set the offset to the last 48 | // line's length 49 | line += n - 1; 50 | offset = token.lexeme.split('\n').last().unwrap().len(); 51 | } 52 | }; 53 | 54 | // If we're looking backwards and the next iteration will pass the 55 | // cursor, return the current position, or the previous if it's whitespace. 56 | let next_position = Position { line, offset }; 57 | if next_position >= *buffer.cursor && direction == Direction::Backward { 58 | match token.category { 59 | Category::Whitespace => { 60 | return Some(previous_position); 61 | } 62 | _ => { 63 | return Some(position); 64 | } 65 | } 66 | } 67 | 68 | // Keep a reference to the current position in case the next 69 | // token is whitespace, and we need to return this instead. 70 | previous_position = position; 71 | } 72 | 73 | None 74 | } 75 | -------------------------------------------------------------------------------- /src/view/buffer/lexeme_mapper.rs: -------------------------------------------------------------------------------- 1 | use scribe::buffer::Position; 2 | 3 | #[derive(Debug, PartialEq)] 4 | pub enum MappedLexeme<'a> { 5 | Focused(&'a str), 6 | Blurred(&'a str), 7 | } 8 | 9 | pub trait LexemeMapper { 10 | fn map<'x>(&'x mut self, lexeme: &str, position: Position) -> Vec>; 11 | } 12 | -------------------------------------------------------------------------------- /src/view/buffer/line_numbers.rs: -------------------------------------------------------------------------------- 1 | use scribe::Buffer; 2 | use std::iter::Iterator; 3 | 4 | pub const PADDING_WIDTH: usize = 2; 5 | 6 | pub struct LineNumbers { 7 | current_number: usize, 8 | buffer_line_count_width: usize, 9 | } 10 | 11 | impl LineNumbers { 12 | pub fn new(buffer: &Buffer, offset: Option) -> LineNumbers { 13 | LineNumbers { 14 | current_number: offset.unwrap_or(0), 15 | buffer_line_count_width: buffer.line_count().to_string().len(), 16 | } 17 | } 18 | 19 | pub fn width(&self) -> usize { 20 | self.buffer_line_count_width + PADDING_WIDTH 21 | } 22 | } 23 | 24 | impl Iterator for LineNumbers { 25 | type Item = String; 26 | 27 | fn next(&mut self) -> Option { 28 | self.current_number += 1; 29 | Some(format!( 30 | " {:>width$} ", 31 | self.current_number, 32 | width = self.buffer_line_count_width 33 | )) 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | mod tests { 39 | use super::*; 40 | use scribe::Buffer; 41 | 42 | #[test] 43 | fn width_considers_buffer_line_count_and_padding() { 44 | let mut buffer = Buffer::new(); 45 | for _ in 0..101 { 46 | buffer.insert("\n"); 47 | } 48 | let line_numbers = LineNumbers::new(&buffer, None); 49 | 50 | assert_eq!(line_numbers.width(), 5); 51 | } 52 | 53 | #[test] 54 | fn line_numbers_without_offset_start_at_one() { 55 | let buffer = Buffer::new(); 56 | let mut line_numbers = LineNumbers::new(&buffer, None); 57 | let next_number: usize = line_numbers 58 | .next() 59 | .unwrap() 60 | .split_whitespace() 61 | .last() 62 | .unwrap() 63 | .parse() 64 | .unwrap(); 65 | assert_eq!(next_number, 1); 66 | } 67 | 68 | #[test] 69 | fn line_numbers_with_offset_start_at_offset_plus_one() { 70 | let buffer = Buffer::new(); 71 | let offset = 10; 72 | let mut line_numbers = LineNumbers::new(&buffer, Some(offset)); 73 | let next_number: usize = line_numbers 74 | .next() 75 | .unwrap() 76 | .split_whitespace() 77 | .last() 78 | .unwrap() 79 | .parse() 80 | .unwrap(); 81 | assert_eq!(next_number, offset + 1); 82 | } 83 | 84 | #[test] 85 | fn line_numbers_increment_by_one() { 86 | let buffer = Buffer::new(); 87 | let mut line_numbers = LineNumbers::new(&buffer, None); 88 | line_numbers.next(); 89 | let next_number: usize = line_numbers 90 | .next() 91 | .unwrap() 92 | .split_whitespace() 93 | .last() 94 | .unwrap() 95 | .parse() 96 | .unwrap(); 97 | assert_eq!(next_number, 2); 98 | } 99 | 100 | #[test] 101 | fn line_numbers_are_left_padded_based_on_buffer_line_count_width() { 102 | let mut buffer = Buffer::new(); 103 | for _ in 0..101 { 104 | buffer.insert("\n"); 105 | } 106 | let mut line_numbers = LineNumbers::new(&buffer, None); 107 | assert_eq!(line_numbers.next().unwrap(), " 1 "); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/view/buffer/mod.rs: -------------------------------------------------------------------------------- 1 | mod lexeme_mapper; 2 | mod line_numbers; 3 | mod render_cache; 4 | mod render_state; 5 | mod renderer; 6 | mod scrollable_region; 7 | 8 | pub use self::lexeme_mapper::{LexemeMapper, MappedLexeme}; 9 | pub use self::line_numbers::LineNumbers; 10 | pub use self::render_cache::RenderCache; 11 | pub use self::render_state::RenderState; 12 | pub use self::renderer::BufferRenderer; 13 | pub use self::scrollable_region::ScrollableRegion; 14 | -------------------------------------------------------------------------------- /src/view/buffer/render_cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | pub trait RenderCache { 4 | fn invalidate_from(&mut self, _: usize) {} 5 | } 6 | 7 | impl RenderCache for HashMap { 8 | /// Invalidates cache entries beyond the specified limit. 9 | fn invalidate_from(&mut self, limit: usize) { 10 | self.retain(|&k, _| k < limit) 11 | } 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use super::RenderCache; 17 | use std::collections::HashMap; 18 | 19 | #[test] 20 | fn invalidate_from_clears_entries_starting_from_specified_index() { 21 | let mut cache = HashMap::new(); 22 | cache.insert(100, String::new()); 23 | cache.insert(200, String::new()); 24 | cache.insert(300, String::new()); 25 | cache.invalidate_from(200); 26 | 27 | let mut expected_cache = HashMap::new(); 28 | expected_cache.insert(100, String::new()); 29 | 30 | assert_eq!(cache, expected_cache); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/view/buffer/render_state.rs: -------------------------------------------------------------------------------- 1 | use syntect::highlighting::{HighlightState, Highlighter}; 2 | use syntect::parsing::{ParseState, ScopeStack, SyntaxReference}; 3 | 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct RenderState { 6 | pub highlight: HighlightState, 7 | pub parse: ParseState, 8 | } 9 | 10 | impl RenderState { 11 | pub fn new(highlighter: &Highlighter, syntax: &SyntaxReference) -> RenderState { 12 | RenderState { 13 | highlight: HighlightState::new(highlighter, ScopeStack::new()), 14 | parse: ParseState::new(syntax), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/view/color/colors.rs: -------------------------------------------------------------------------------- 1 | use crate::view::color::RGBColor; 2 | 3 | /// A convenience type used to represent a foreground/background 4 | /// color combination. Provides generic/convenience variants to 5 | /// discourage color selection outside of the theme, whenever possible. 6 | #[derive(Clone, Copy, Debug, PartialEq, Default)] 7 | pub enum Colors { 8 | #[default] 9 | Default, // default/background 10 | Focused, // default/alt background 11 | Inverted, // background/default 12 | Insert, // white/green 13 | Warning, // white/yellow 14 | PinnedQuery, // white/blue 15 | PathMode, // white/pink 16 | SearchMode, // white/purple 17 | SelectMode, // white/blue 18 | CustomForeground(RGBColor), 19 | CustomFocusedForeground(RGBColor), 20 | Custom(RGBColor, RGBColor), 21 | } 22 | -------------------------------------------------------------------------------- /src/view/color/map.rs: -------------------------------------------------------------------------------- 1 | use crate::view::color::to_rgb_color; 2 | use crate::view::color::{Colors, RGBColor}; 3 | use syntect::highlighting::Theme; 4 | 5 | pub trait ColorMap { 6 | fn map_colors(&self, colors: Colors) -> Colors; 7 | } 8 | 9 | impl ColorMap for Theme { 10 | fn map_colors(&self, colors: Colors) -> Colors { 11 | let fg = self 12 | .settings 13 | .foreground 14 | .map(to_rgb_color) 15 | .unwrap_or(RGBColor(255, 255, 255)); 16 | 17 | let bg = self 18 | .settings 19 | .background 20 | .map(to_rgb_color) 21 | .unwrap_or(RGBColor(0, 0, 0)); 22 | 23 | let alt_bg = self 24 | .settings 25 | .line_highlight 26 | .map(to_rgb_color) 27 | .unwrap_or(RGBColor(55, 55, 55)); 28 | 29 | match colors { 30 | Colors::Default => Colors::CustomForeground(fg), 31 | Colors::Focused => Colors::Custom(fg, alt_bg), 32 | Colors::Inverted => Colors::Custom(bg, fg), 33 | Colors::Insert => Colors::Custom(RGBColor(255, 255, 255), RGBColor(0, 180, 0)), 34 | Colors::Warning => Colors::Custom(RGBColor(255, 255, 255), RGBColor(240, 140, 20)), 35 | Colors::PinnedQuery => Colors::Custom(RGBColor(255, 255, 255), RGBColor(0, 120, 160)), 36 | Colors::PathMode => Colors::Custom(RGBColor(255, 255, 255), RGBColor(255, 20, 147)), 37 | Colors::SearchMode => Colors::Custom(RGBColor(255, 255, 255), RGBColor(120, 0, 120)), 38 | Colors::SelectMode => Colors::Custom(RGBColor(255, 255, 255), RGBColor(0, 120, 160)), 39 | Colors::CustomForeground(custom_fg) => Colors::CustomForeground(custom_fg), 40 | Colors::CustomFocusedForeground(custom_fg) => Colors::Custom(custom_fg, alt_bg), 41 | Colors::Custom(custom_fg, custom_bg) => Colors::Custom(custom_fg, custom_bg), 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/view/color/mod.rs: -------------------------------------------------------------------------------- 1 | extern crate termion; 2 | 3 | // Define and export our own Colors type. 4 | mod colors; 5 | pub use self::colors::Colors; 6 | 7 | // Define and export a trait for mapping 8 | // convenience Colors to printable equivalents. 9 | mod map; 10 | pub use self::map::ColorMap; 11 | 12 | // Re-export external RGB/RGBA types. 13 | pub use self::termion::color::Rgb as RGBColor; 14 | use syntect::highlighting::Color as RGBAColor; 15 | 16 | // Convenience function to convert from RGBA to RGB. 17 | pub fn to_rgb_color(color: RGBAColor) -> RGBColor { 18 | RGBColor(color.r, color.g, color.b) 19 | } 20 | -------------------------------------------------------------------------------- /src/view/data.rs: -------------------------------------------------------------------------------- 1 | use crate::view::{Colors, Style}; 2 | 3 | pub struct StatusLineData { 4 | pub content: String, 5 | pub style: Style, 6 | pub colors: Colors, 7 | } 8 | -------------------------------------------------------------------------------- /src/view/event_listener.rs: -------------------------------------------------------------------------------- 1 | use crate::models::application::Event; 2 | use crate::view::Terminal; 3 | use std::sync::mpsc::{Receiver, Sender}; 4 | use std::sync::Arc; 5 | use std::thread; 6 | 7 | pub struct EventListener { 8 | terminal: Arc>, 9 | events: Sender, 10 | killswitch: Receiver<()>, 11 | } 12 | 13 | impl EventListener { 14 | /// Spins up a thread that loops forever, waiting on terminal events 15 | /// and forwarding those to the application event channel. 16 | pub fn start( 17 | terminal: Arc>, 18 | events: Sender, 19 | killswitch: Receiver<()>, 20 | ) { 21 | thread::spawn(move || { 22 | EventListener { 23 | terminal, 24 | events, 25 | killswitch, 26 | } 27 | .listen(); 28 | }); 29 | } 30 | 31 | fn listen(&mut self) { 32 | loop { 33 | if let Some(event) = self.terminal.listen() { 34 | self.events.send(event).ok(); 35 | } else if self.killswitch.try_recv().is_ok() { 36 | break; 37 | } 38 | } 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::EventListener; 45 | use crate::input::Key; 46 | use crate::models::application::Event; 47 | use crate::view::terminal::*; 48 | use std::sync::mpsc; 49 | 50 | #[test] 51 | fn start_listens_for_and_sends_key_events_from_terminal() { 52 | let terminal = build_terminal().unwrap(); 53 | let (event_tx, event_rx) = mpsc::channel(); 54 | let (_, killswitch_rx) = mpsc::sync_channel(0); 55 | EventListener::start(terminal.clone(), event_tx, killswitch_rx); 56 | let event = event_rx.recv().unwrap(); 57 | 58 | assert_eq!(event, Event::Key(Key::Char('A'))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/view/presenter.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use crate::view::buffer::{BufferRenderer, LexemeMapper}; 3 | use crate::view::color::{ColorMap, Colors}; 4 | use crate::view::style::Style; 5 | use crate::view::terminal::{Cell, CursorType, TerminalBuffer}; 6 | use crate::view::StatusLineData; 7 | use crate::view::View; 8 | use scribe::buffer::{Buffer, Position, Range}; 9 | use scribe::util::LineIterator; 10 | use std::borrow::Cow; 11 | use syntect::highlighting::Theme; 12 | use syntect::parsing::SyntaxSet; 13 | 14 | /// The `Presenter` type forms the main view API for mode-specific presenters. 15 | /// It provides the ability to read view dimensions, draw individual character 16 | /// "cells", and render higher-level components like buffers. Writes are 17 | /// buffered and flushed to the terminal with the `present` method. 18 | pub struct Presenter<'p> { 19 | cursor_position: Option, 20 | terminal_buffer: TerminalBuffer<'p>, 21 | theme: Theme, 22 | pub view: &'p mut View, 23 | } 24 | 25 | impl<'p> Presenter<'p> { 26 | pub fn new(view: &mut View) -> Result { 27 | let theme = { 28 | let preferences = view.preferences.borrow(); 29 | let theme_name = preferences.theme(); 30 | let theme = view 31 | .theme_set 32 | .themes 33 | .get(theme_name) 34 | .ok_or_else(|| format!("Couldn't find \"{theme_name}\" theme"))?; 35 | theme.clone() 36 | }; 37 | 38 | Ok(Presenter { 39 | cursor_position: None, 40 | terminal_buffer: TerminalBuffer::new(view.terminal.width(), view.terminal.height()), 41 | theme, 42 | view, 43 | }) 44 | } 45 | 46 | pub fn width(&self) -> usize { 47 | self.view.terminal.width() 48 | } 49 | 50 | pub fn height(&self) -> usize { 51 | self.view.terminal.height() 52 | } 53 | 54 | pub fn clear(&mut self) { 55 | self.terminal_buffer.clear() 56 | } 57 | 58 | pub fn set_cursor(&mut self, position: Option) { 59 | self.cursor_position = position; 60 | } 61 | 62 | pub fn set_cursor_type(&mut self, cursor_type: CursorType) { 63 | self.view.terminal.set_cursor_type(cursor_type); 64 | } 65 | 66 | pub fn present(&mut self) -> Result<()> { 67 | for (position, cell) in self.terminal_buffer.iter() { 68 | self.view.terminal.print( 69 | &position, 70 | cell.style, 71 | self.theme.map_colors(cell.colors), 72 | &cell.content, 73 | )?; 74 | } 75 | self.view.terminal.set_cursor(self.cursor_position); 76 | self.view.terminal.present(); 77 | 78 | Ok(()) 79 | } 80 | 81 | pub fn print_buffer( 82 | &mut self, 83 | buffer: &Buffer, 84 | buffer_data: &'p str, 85 | syntax_set: &'p SyntaxSet, 86 | highlights: Option<&[Range]>, 87 | lexeme_mapper: Option<&'p mut dyn LexemeMapper>, 88 | ) -> Result<()> { 89 | let scroll_offset = self.view.get_region(buffer)?.line_offset(); 90 | let lines = LineIterator::new(buffer_data); 91 | 92 | self.cursor_position = BufferRenderer::new( 93 | buffer, 94 | highlights, 95 | scroll_offset, 96 | &**self.view.terminal, 97 | &self.theme, 98 | &self.view.preferences.borrow(), 99 | self.view.get_render_cache(buffer)?, 100 | syntax_set, 101 | &mut self.terminal_buffer, 102 | ) 103 | .render(lines, lexeme_mapper)?; 104 | 105 | Ok(()) 106 | } 107 | 108 | pub fn print_status_line(&mut self, entries: &[StatusLineData]) { 109 | let line = self.view.terminal.height() - 1; 110 | 111 | entries 112 | .iter() 113 | .enumerate() 114 | .fold(0, |offset, (index, element)| { 115 | let content = match entries.len() { 116 | // There's only one element; have it fill the line. 117 | 1 => format!( 118 | "{:width$}", 119 | element.content, 120 | width = self.view.terminal.width(), 121 | ), 122 | 123 | // Expand the last element to fill the remaining width. 124 | 2 if index == entries.len() - 1 => format!( 125 | "{:width$}", 126 | element.content, 127 | width = self.view.terminal.width().saturating_sub(offset), 128 | ), 129 | 2 => element.content.clone(), 130 | 131 | _ if index == entries.len() - 2 => { 132 | let space = offset + entries[index + 1].content.len(); 133 | format!( 134 | "{:width$}", 135 | element.content, 136 | width = self.view.terminal.width().saturating_sub(space), 137 | ) 138 | } 139 | _ => element.content.clone(), 140 | }; 141 | 142 | // Update the tracked offset. 143 | let updated_offset = offset + content.len(); 144 | 145 | self.print( 146 | &Position { line, offset }, 147 | element.style, 148 | element.colors, 149 | content, 150 | ); 151 | 152 | updated_offset 153 | }); 154 | } 155 | 156 | pub fn print_error>(&mut self, error: I) { 157 | self.print_status_line(&[StatusLineData { 158 | content: error.into(), 159 | style: Style::Bold, 160 | colors: Colors::Warning, 161 | }]); 162 | } 163 | 164 | pub fn print(&mut self, position: &Position, style: Style, colors: Colors, content: C) 165 | where 166 | C: Into>, 167 | { 168 | self.terminal_buffer.set_cell( 169 | *position, 170 | Cell { 171 | content: content.into(), 172 | style, 173 | colors, 174 | }, 175 | ); 176 | } 177 | } 178 | 179 | #[cfg(test)] 180 | mod tests { 181 | use crate::models::application::Preferences; 182 | use crate::view::View; 183 | use scribe::{Buffer, Workspace}; 184 | use std::cell::RefCell; 185 | use std::path::{Path, PathBuf}; 186 | use std::rc::Rc; 187 | use std::sync::mpsc; 188 | 189 | #[test] 190 | fn print_buffer_initializes_renderer_with_cached_state() { 191 | let preferences = Rc::new(RefCell::new(Preferences::new(None))); 192 | let (tx, _) = mpsc::channel(); 193 | let mut view = View::new(preferences, tx).unwrap(); 194 | 195 | // Set up a Rust-categorized buffer. 196 | let mut workspace = Workspace::new(Path::new("."), None).unwrap(); 197 | let mut buffer = Buffer::new(); 198 | buffer.id = Some(0); 199 | buffer.path = Some(PathBuf::from("rust.rs")); 200 | for _ in 0..200 { 201 | buffer.insert("line\n"); 202 | } 203 | 204 | // Initialize the buffer's render cache, but get rid of the callback 205 | // so that we can test the cache without it being invalidated. 206 | view.initialize_buffer(&mut buffer).unwrap(); 207 | // buffer.change_callback = None; 208 | workspace.add_buffer(buffer); 209 | 210 | // Scroll down enough to trigger caching during the render process. 211 | view.scroll_down(workspace.current_buffer.as_ref().unwrap(), 105) 212 | .unwrap(); 213 | 214 | // Ensure there is nothing in the render cache for this buffer. 215 | let mut cache = view 216 | .get_render_cache(workspace.current_buffer.as_ref().unwrap()) 217 | .unwrap(); 218 | assert_eq!(cache.borrow().iter().count(), 0); 219 | 220 | // Draw the buffer. 221 | let mut presenter = view.build_presenter().unwrap(); 222 | let data = workspace.current_buffer.as_ref().unwrap().data(); 223 | presenter 224 | .print_buffer( 225 | workspace.current_buffer.as_ref().unwrap(), 226 | &data, 227 | &workspace.syntax_set, 228 | None, 229 | None, 230 | ) 231 | .unwrap(); 232 | 233 | // Ensure there is something in the render cache for this buffer. 234 | cache = view 235 | .get_render_cache(workspace.current_buffer.as_ref().unwrap()) 236 | .unwrap(); 237 | assert_ne!(cache.borrow().iter().count(), 0); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/view/style.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq, Default)] 2 | pub enum Style { 3 | #[default] 4 | Default, 5 | Bold, 6 | Inverted, 7 | Italic, 8 | } 9 | -------------------------------------------------------------------------------- /src/view/terminal/buffer.rs: -------------------------------------------------------------------------------- 1 | use crate::view::terminal::{Cell, TerminalBufferIterator}; 2 | use scribe::buffer::Position; 3 | 4 | pub struct TerminalBuffer<'c> { 5 | width: usize, 6 | height: usize, 7 | cells: Vec>, 8 | } 9 | 10 | impl<'c> TerminalBuffer<'c> { 11 | pub fn new(width: usize, height: usize) -> TerminalBuffer<'c> { 12 | TerminalBuffer { 13 | width, 14 | height, 15 | cells: vec![Cell::default(); width * height], 16 | } 17 | } 18 | 19 | pub fn set_cell(&mut self, position: Position, cell: Cell<'c>) { 20 | let index = position.line * self.width + position.offset; 21 | 22 | if index < self.cells.len() { 23 | self.cells[position.line * self.width + position.offset] = cell; 24 | } 25 | } 26 | 27 | pub fn clear(&mut self) { 28 | self.cells = vec![Cell::default(); self.width * self.height]; 29 | } 30 | 31 | pub fn iter(&self) -> TerminalBufferIterator { 32 | TerminalBufferIterator::new(self.width, &self.cells) 33 | } 34 | 35 | #[cfg(test)] 36 | // For testing purposes, produces a String representation of the 37 | // terminal buffer that can be used to assert a particular state. 38 | pub fn content(&self) -> String { 39 | let mut content = String::new(); 40 | let mut line = 0; 41 | for (position, cell) in self.iter() { 42 | // Add newline characters to the representation. 43 | if position.line > line { 44 | content.push('\n'); 45 | line += 1; 46 | } 47 | content.push_str(&*cell.content); 48 | } 49 | 50 | content 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use super::TerminalBuffer; 57 | use crate::view::terminal::Cell; 58 | use crate::view::{Colors, Style}; 59 | use scribe::buffer::Position; 60 | use std::borrow::Cow; 61 | 62 | #[test] 63 | fn new_sets_cell_capacity() { 64 | let width = 5; 65 | let height = 10; 66 | let buffer = TerminalBuffer::new(width, height); 67 | 68 | assert_eq!(50, buffer.cells.capacity()); 69 | } 70 | 71 | #[test] 72 | fn new_sets_cell_defaults() { 73 | let width = 5; 74 | let height = 10; 75 | let buffer = TerminalBuffer::new(width, height); 76 | 77 | assert_eq!(buffer.cells[0], Cell::default()); 78 | } 79 | 80 | #[test] 81 | fn set_cell_sets_correct_cell() { 82 | let mut buffer = TerminalBuffer::new(5, 10); 83 | let cell = Cell { 84 | content: Cow::from("a"), 85 | colors: Colors::Default, 86 | style: Style::Default, 87 | }; 88 | buffer.set_cell(Position { line: 2, offset: 1 }, cell.clone()); 89 | 90 | assert_eq!(buffer.cells[11], cell); 91 | } 92 | 93 | #[test] 94 | fn clear_resets_cells_to_default() { 95 | let mut buffer = TerminalBuffer::new(5, 10); 96 | let cell = Cell { 97 | content: Cow::from(" "), 98 | colors: Colors::Default, 99 | style: Style::Default, 100 | }; 101 | buffer.set_cell(Position { line: 2, offset: 1 }, cell.clone()); 102 | 103 | assert_eq!(buffer.cells[11], cell); 104 | buffer.clear(); 105 | 106 | assert_eq!(buffer.cells[11], Cell::default()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/view/terminal/buffer_iterator.rs: -------------------------------------------------------------------------------- 1 | use crate::view::terminal::Cell; 2 | use scribe::buffer::Position; 3 | use unicode_segmentation::UnicodeSegmentation; 4 | 5 | /// Iterates over the provided cells, yielding slices for each line. 6 | pub struct TerminalBufferIterator<'c> { 7 | index: usize, 8 | width: usize, 9 | cells: &'c Vec>, 10 | } 11 | 12 | impl<'c> TerminalBufferIterator<'c> { 13 | pub fn new(width: usize, cells: &'c Vec>) -> TerminalBufferIterator { 14 | TerminalBufferIterator { 15 | index: 0, 16 | width, 17 | cells, 18 | } 19 | } 20 | } 21 | 22 | impl<'c> Iterator for TerminalBufferIterator<'c> { 23 | type Item = (Position, &'c Cell<'c>); 24 | 25 | /// Iterates over lines of cells. 26 | fn next(&mut self) -> Option { 27 | if self.index < self.cells.len() { 28 | let position = Position { 29 | line: self.index / self.width, 30 | offset: self.index % self.width, 31 | }; 32 | let cell = &self.cells[self.index]; 33 | self.index += cell.content.graphemes(true).count().max(1); 34 | 35 | Some((position, cell)) 36 | } else { 37 | None 38 | } 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::TerminalBufferIterator; 45 | use crate::view::terminal::Cell; 46 | use crate::view::{Colors, Style}; 47 | use scribe::buffer::Position; 48 | use std::borrow::Cow; 49 | 50 | #[test] 51 | fn terminal_buffer_iterator_yields_cells_and_their_positions() { 52 | let width = 3; 53 | let cells = vec![ 54 | Cell { 55 | content: Cow::from("a"), 56 | colors: Colors::Default, 57 | style: Style::Default, 58 | }, 59 | Cell { 60 | content: Cow::from("m"), 61 | colors: Colors::Default, 62 | style: Style::Default, 63 | }, 64 | Cell { 65 | content: Cow::from("p"), 66 | colors: Colors::Default, 67 | style: Style::Default, 68 | }, 69 | ]; 70 | let mut iterator = TerminalBufferIterator::new(width, &cells); 71 | 72 | assert_eq!( 73 | iterator.next(), 74 | Some((Position { line: 0, offset: 0 }, &cells[0])) 75 | ); 76 | assert_eq!( 77 | iterator.next(), 78 | Some((Position { line: 0, offset: 1 }, &cells[1])) 79 | ); 80 | assert_eq!( 81 | iterator.next(), 82 | Some((Position { line: 0, offset: 2 }, &cells[2])) 83 | ); 84 | assert_eq!(iterator.next(), None); 85 | } 86 | 87 | #[test] 88 | fn terminal_buffer_iterator_considers_width_when_calculating_positions() { 89 | let width = 2; 90 | let cells = vec![ 91 | Cell { 92 | content: Cow::from("a"), 93 | colors: Colors::Default, 94 | style: Style::Default, 95 | }, 96 | Cell { 97 | content: Cow::from("m"), 98 | colors: Colors::Default, 99 | style: Style::Default, 100 | }, 101 | Cell { 102 | content: Cow::from("p"), 103 | colors: Colors::Default, 104 | style: Style::Default, 105 | }, 106 | ]; 107 | let mut iterator = TerminalBufferIterator::new(width, &cells); 108 | 109 | assert_eq!( 110 | iterator.nth(2), 111 | Some((Position { line: 1, offset: 0 }, &cells[2])) 112 | ); 113 | } 114 | 115 | #[test] 116 | fn terminal_buffer_iterator_handles_overlapping_cells_correctly() { 117 | let width = 4; 118 | let cells = vec![ 119 | Cell { 120 | content: Cow::from("amp"), 121 | colors: Colors::Default, 122 | style: Style::Default, 123 | }, 124 | Cell { 125 | content: Cow::from("b"), 126 | colors: Colors::Default, 127 | style: Style::Default, 128 | }, 129 | Cell { 130 | content: Cow::from("c"), 131 | colors: Colors::Default, 132 | style: Style::Default, 133 | }, 134 | Cell { 135 | content: Cow::from("d"), 136 | colors: Colors::Default, 137 | style: Style::Default, 138 | }, 139 | ]; 140 | let mut iterator = TerminalBufferIterator::new(width, &cells); 141 | 142 | assert_eq!( 143 | iterator.next(), 144 | Some((Position { line: 0, offset: 0 }, &cells[0])) 145 | ); 146 | assert_eq!( 147 | iterator.next(), 148 | Some((Position { line: 0, offset: 3 }, &cells[3])) 149 | ); 150 | assert_eq!(iterator.next(), None); 151 | } 152 | 153 | #[test] 154 | fn terminal_buffer_iterator_handles_empty_cells_correctly() { 155 | let width = 4; 156 | let cells = vec![ 157 | Cell { 158 | content: Cow::from(""), 159 | colors: Colors::Default, 160 | style: Style::Default, 161 | }, 162 | Cell { 163 | content: Cow::from("a"), 164 | colors: Colors::Default, 165 | style: Style::Default, 166 | }, 167 | ]; 168 | let mut iterator = TerminalBufferIterator::new(width, &cells); 169 | 170 | assert_eq!( 171 | iterator.next(), 172 | Some((Position { line: 0, offset: 0 }, &cells[0])) 173 | ); 174 | assert_eq!( 175 | iterator.next(), 176 | Some((Position { line: 0, offset: 1 }, &cells[1])) 177 | ); 178 | assert_eq!(iterator.next(), None); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/view/terminal/cell.rs: -------------------------------------------------------------------------------- 1 | use crate::view::{Colors, Style}; 2 | use std::borrow::Cow; 3 | 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct Cell<'c> { 6 | pub content: Cow<'c, str>, 7 | pub colors: Colors, 8 | pub style: Style, 9 | } 10 | 11 | impl<'c> Default for Cell<'c> { 12 | fn default() -> Self { 13 | Cell { 14 | content: " ".into(), 15 | colors: Colors::default(), 16 | style: Style::default(), 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/view/terminal/cursor.rs: -------------------------------------------------------------------------------- 1 | pub enum CursorType { 2 | Bar, 3 | BlinkingBar, 4 | Block, 5 | } 6 | -------------------------------------------------------------------------------- /src/view/terminal/mod.rs: -------------------------------------------------------------------------------- 1 | mod buffer; 2 | mod buffer_iterator; 3 | mod cell; 4 | mod cursor; 5 | mod termion_terminal; 6 | 7 | #[cfg(test)] 8 | mod test_terminal; 9 | 10 | use crate::errors::*; 11 | use crate::models::application::Event; 12 | use crate::view::{Colors, Style}; 13 | use scribe::buffer::Position; 14 | use std::process::Command; 15 | use std::sync::Arc; 16 | 17 | pub use self::buffer::TerminalBuffer; 18 | pub use self::buffer_iterator::TerminalBufferIterator; 19 | pub use self::cell::Cell; 20 | pub use self::cursor::CursorType; 21 | 22 | #[cfg(test)] 23 | pub use self::test_terminal::TestTerminal; 24 | 25 | const MIN_WIDTH: usize = 10; 26 | const MIN_HEIGHT: usize = 10; 27 | 28 | pub trait Terminal { 29 | fn listen(&self) -> Option; 30 | fn clear(&self); 31 | fn present(&self); 32 | fn width(&self) -> usize; 33 | fn height(&self) -> usize; 34 | fn set_cursor(&self, _: Option); 35 | fn set_cursor_type(&self, _: CursorType); 36 | fn print(&self, _: &Position, _: Style, _: Colors, _: &str) -> Result<()>; 37 | fn suspend(&self); 38 | fn replace(&self, _: &mut Command) -> Result<()>; 39 | } 40 | 41 | #[cfg(not(test))] 42 | pub fn build_terminal() -> Result>> { 43 | Ok(Arc::new( 44 | Box::new(termion_terminal::TermionTerminal::new()?), 45 | )) 46 | } 47 | 48 | #[cfg(test)] 49 | pub fn build_terminal() -> Result>> { 50 | Ok(Arc::new(Box::new(TestTerminal::new()))) 51 | } 52 | -------------------------------------------------------------------------------- /src/view/terminal/test_terminal.rs: -------------------------------------------------------------------------------- 1 | use super::Terminal; 2 | use crate::errors::*; 3 | use crate::input::Key; 4 | use crate::models::application::Event; 5 | use crate::view::{Colors, CursorType, Style}; 6 | use scribe::buffer::Position; 7 | use std::process::Command; 8 | use std::sync::Mutex; 9 | 10 | const WIDTH: usize = 10; 11 | const HEIGHT: usize = 10; 12 | 13 | // A headless terminal that tracks printed data, which can be 14 | // returned as a String to test display logic of other types. 15 | pub struct TestTerminal { 16 | data: Mutex<[[Option<(char, Colors)>; WIDTH]; HEIGHT]>, // 2D array of chars to represent screen 17 | cursor: Mutex>, 18 | key_sent: Mutex, 19 | } 20 | 21 | impl TestTerminal { 22 | pub fn new() -> TestTerminal { 23 | TestTerminal { 24 | data: Mutex::new([[None; WIDTH]; HEIGHT]), 25 | cursor: Mutex::new(None), 26 | key_sent: Mutex::new(false), 27 | } 28 | } 29 | 30 | // Returns a String representation of the printed data. 31 | pub fn content(&self) -> String { 32 | let mut data = String::new(); 33 | let mut last_row_with_data = 0; 34 | let mut last_column_with_data = 0; 35 | 36 | for (y, row) in self.data.lock().unwrap().iter().enumerate() { 37 | for (x, cell) in row.iter().enumerate() { 38 | if let Some((c, _)) = *cell { 39 | for _ in last_row_with_data..y { 40 | data.push('\n'); 41 | last_column_with_data = 0; 42 | } 43 | 44 | for _ in last_column_with_data..x { 45 | data.push(' '); 46 | } 47 | 48 | data.push(c); 49 | 50 | last_row_with_data = y; 51 | 52 | // Since the column changes on each character, and we don't 53 | // want to print a space in between every character, we 54 | // set it ahead when we've run into a character to 55 | // differentiate from leading spaces. 56 | last_column_with_data = x + 1; 57 | } 58 | } 59 | } 60 | 61 | data 62 | } 63 | } 64 | 65 | impl Terminal for TestTerminal { 66 | fn listen(&self) -> Option { 67 | // This implementation will return a key once, followed by nothing. 68 | // This allows us to test both scenarios, the latter being crucial 69 | // to stopping the application in test mode; the input listener only 70 | // checks for kill signals when the terminal returns no input. 71 | let mut key_sent = self.key_sent.lock().unwrap(); 72 | if *key_sent { 73 | None 74 | } else { 75 | *key_sent = true; 76 | Some(Event::Key(Key::Char('A'))) 77 | } 78 | } 79 | fn clear(&self) { 80 | for row in self.data.lock().unwrap().iter_mut() { 81 | *row = [None; WIDTH]; 82 | } 83 | } 84 | fn present(&self) {} 85 | fn width(&self) -> usize { 86 | WIDTH 87 | } 88 | fn height(&self) -> usize { 89 | HEIGHT 90 | } 91 | fn set_cursor(&self, position: Option) { 92 | let mut cursor = self.cursor.lock().unwrap(); 93 | *cursor = position; 94 | } 95 | fn set_cursor_type(&self, _: CursorType) {} 96 | fn suspend(&self) {} 97 | fn replace(&self, command: &mut Command) -> Result<()> { 98 | command 99 | .status() 100 | .expect("test terminal replace command failed"); 101 | Ok(()) 102 | } 103 | fn print(&self, position: &Position, _: Style, colors: Colors, content: &str) -> Result<()> { 104 | // Ignore lines beyond visible height. 105 | if position.line >= self.height() { 106 | return Ok(()); 107 | } 108 | 109 | let mut data = self.data.lock().unwrap(); 110 | let string_content = format!("{content}"); 111 | 112 | for (i, c) in string_content.chars().enumerate() { 113 | // Ignore characters beyond visible width. 114 | if i + position.offset >= WIDTH { 115 | break; 116 | } 117 | 118 | data[position.line][i + position.offset] = Some((c, colors)); 119 | } 120 | 121 | Ok(()) 122 | } 123 | } 124 | 125 | #[cfg(test)] 126 | mod tests { 127 | use super::TestTerminal; 128 | use crate::view::terminal::Terminal; 129 | use crate::view::{Colors, Style}; 130 | use scribe::buffer::Position; 131 | 132 | #[test] 133 | fn print_sets_terminal_data_correctly() { 134 | let terminal = Box::new(TestTerminal::new()); 135 | terminal 136 | .print( 137 | &Position { line: 0, offset: 0 }, 138 | Style::Default, 139 | Colors::Default, 140 | &"data", 141 | ) 142 | .unwrap(); 143 | 144 | assert_eq!(terminal.content(), "data"); 145 | } 146 | 147 | #[test] 148 | fn data_uses_newlines_and_spaces_to_represent_structure() { 149 | let terminal = Box::new(TestTerminal::new()); 150 | 151 | // Setting a non-zero x coordinate on a previous line exercises column resetting. 152 | terminal 153 | .print( 154 | &Position { line: 0, offset: 2 }, 155 | Style::Default, 156 | Colors::Default, 157 | &"some", 158 | ) 159 | .unwrap(); 160 | terminal 161 | .print( 162 | &Position { line: 2, offset: 5 }, 163 | Style::Default, 164 | Colors::Default, 165 | &"data", 166 | ) 167 | .unwrap(); 168 | 169 | assert_eq!(terminal.content(), " some\n\n data"); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/view/theme_loader.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use std::collections::BTreeMap; 3 | use std::ffi::OsStr; 4 | use std::fs::File; 5 | use std::io::{BufReader, Cursor, Read, Seek}; 6 | use std::path::PathBuf; 7 | use syntect::highlighting::{Theme, ThemeSet}; 8 | 9 | pub struct ThemeLoader { 10 | path: PathBuf, 11 | themes: BTreeMap, 12 | } 13 | 14 | impl ThemeLoader { 15 | pub fn new(path: PathBuf) -> ThemeLoader { 16 | ThemeLoader { 17 | path, 18 | themes: BTreeMap::new(), 19 | } 20 | } 21 | 22 | /// Consumes the ThemeLoader to produce a ThemeSet. 23 | pub fn load(mut self) -> Result { 24 | self.load_defaults()?; 25 | self.load_user()?; 26 | 27 | Ok(ThemeSet { 28 | themes: self.themes, 29 | }) 30 | } 31 | 32 | fn load_user(&mut self) -> Result<()> { 33 | let theme_dir_entries = self 34 | .path 35 | .read_dir() 36 | .chain_err(|| "Failed to read themes directory")?; 37 | 38 | let theme_paths = theme_dir_entries 39 | .filter_map(|dir| dir.ok()) 40 | .map(|theme| theme.path()) 41 | .filter(|path| path.is_file()) 42 | .filter(|path| path.extension() == Some(OsStr::new("tmTheme"))); 43 | 44 | for theme_path in theme_paths { 45 | if let Ok(theme) = File::open(&theme_path) { 46 | if let Some(file_stem) = theme_path.file_stem() { 47 | if let Some(theme_name) = file_stem.to_str() { 48 | self.insert_theme(theme_name, theme)? 49 | } 50 | } 51 | } 52 | } 53 | 54 | Ok(()) 55 | } 56 | 57 | fn load_defaults(&mut self) -> Result<()> { 58 | self.insert_theme( 59 | "solarized_dark", 60 | Cursor::new(include_str!("../themes/solarized_dark.tmTheme")), 61 | )?; 62 | self.insert_theme( 63 | "solarized_light", 64 | Cursor::new(include_str!("../themes/solarized_light.tmTheme")), 65 | )?; 66 | 67 | Ok(()) 68 | } 69 | 70 | fn insert_theme(&mut self, theme_name: &str, theme_data: D) -> Result<()> { 71 | let mut reader = BufReader::new(theme_data); 72 | if let Ok(theme_set) = ThemeSet::load_from_reader(&mut reader) { 73 | self.themes.insert(String::from(theme_name), theme_set); 74 | } else { 75 | bail!("Failed to load {} theme", theme_name); 76 | } 77 | 78 | Ok(()) 79 | } 80 | } 81 | --------------------------------------------------------------------------------