├── _project.vim ├── res ├── icon.png ├── help_popup.html ├── style │ ├── main.css │ └── github.css ├── layout.html ├── default_config.yaml └── js │ └── main.js ├── .gitignore ├── src ├── lib.rs ├── main.rs ├── ui │ ├── dialogs.rs │ ├── browser.rs │ ├── action.rs │ └── mod.rs ├── markdown.rs ├── background.rs ├── assets.rs └── input.rs ├── CHANGELOG.md ├── .github └── workflows │ └── release-binary.yml ├── LICENSE-MIT ├── Cargo.toml ├── tests ├── test_input.rs ├── test_markdown.rs ├── test_background.rs ├── test_assets.rs └── test_ui_actions.rs ├── CODE_OF_CONDUCT.md ├── README.md ├── LICENSE-APACHE └── Cargo.lock /_project.vim: -------------------------------------------------------------------------------- 1 | runtime! projects/rust.vim 2 | -------------------------------------------------------------------------------- /res/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndrewRadev/quickmd/HEAD/res/icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | /.cargo/config.toml 4 | 5 | notes 6 | 7 | # Ignored since it contains system-specific linker config 8 | /.cargo/config.toml 9 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | 3 | //! This is a GTK app that allows you to quickly preview a markdown file. It's not intended to be 4 | //! used as a library, but the individual components are there, and theoretically usable. Check the 5 | //! `README.md` file for instructions on how to use it as an app. 6 | 7 | pub mod assets; 8 | pub mod background; 9 | pub mod input; 10 | pub mod markdown; 11 | pub mod ui; 12 | -------------------------------------------------------------------------------- /res/help_popup.html: -------------------------------------------------------------------------------- 1 | 2 | Configuration 3 | 4 | Settings: {yaml_path} 5 | CSS: {css_path} 6 | 7 | Default Keybindings 8 | 9 | j/k: Scroll up and down. 10 | J/K: Scroll up and down by a larger amount. 11 | g/G: Jump to the beginning/end of the document. 12 | 13 | e: Launch an external editor (needs to be configured). 14 | E: Launch an external editor and exit viewer. 15 | 16 | CTRL + <scroll>: Zoom in/out 17 | +/-/=: Zoom in/out/reset 18 | 19 | CTRL + q: Quit 20 | -------------------------------------------------------------------------------- /res/style/main.css: -------------------------------------------------------------------------------- 1 | main { 2 | width: 66%; 3 | margin: 0 auto; 4 | } 5 | 6 | #link-preview { 7 | position: fixed; 8 | bottom: 0; 9 | right: 0; 10 | padding: 5px 10px; 11 | background-color: #f5f6f7; 12 | border-radius: 5px 0 0 0; 13 | border-top: 1px solid #dcdfe3; 14 | border-left: 1px solid #dcdfe3; 15 | font-size: 0.7em; 16 | opacity: 0; 17 | } 18 | 19 | #link-preview.visible { 20 | opacity: 0.95; 21 | transition: 300ms ease-out; 22 | } 23 | 24 | #link-preview.hidden { 25 | opacity: 0; 26 | transition: 300ms ease-out; 27 | } 28 | -------------------------------------------------------------------------------- /res/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {page_state} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | {body} 16 |
17 | 18 | 20 | 21 | 23 | 24 | {hl_tags} 25 | 26 | 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.3.0] - 2020-05-06 6 | 7 | - Added: Code highlighting 8 | - Added: GFM features like tables and strikethrough (basically, whatever [pulldown-cmark provides](https://docs.rs/pulldown-cmark/0.7.0/pulldown_cmark/struct.Options.html)) 9 | 10 | ## [0.2.0] - 2020-04-05 11 | 12 | - Added: Image sizes are cached via javascript and set on page reload. That way, reloading the 13 | browser due to text changes should not lead to any flicker. 14 | - Added: Reading markdown from STDIN is now possible by providing `-` as a filename. 15 | - Changed: Custom header bar was removed. One should be added automatically by the window 16 | manager/desktop environment. 17 | 18 | ## [0.1.2] - 2020-03-23 19 | 20 | - Fixed: file-watching bug 21 | 22 | ## [0.1.1] - 2020-03-22 23 | 24 | - Initial release 25 | -------------------------------------------------------------------------------- /.github/workflows/release-binary.yml: -------------------------------------------------------------------------------- 1 | name: Release binary 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | 13 | - name: Install latest rust toolchain 14 | uses: actions-rs/toolchain@v1 15 | with: 16 | toolchain: stable 17 | default: true 18 | override: true 19 | 20 | - name: Install gtk and webkit2gtk 21 | run: sudo apt update && sudo apt install libgtk-3-dev libwebkit2gtk-4.0-dev 22 | 23 | - name: Build 24 | run: cargo build --all --release && strip target/release/quickmd && mv target/release/quickmd target/release/quickmd_amd64 25 | 26 | - name: Release 27 | uses: softprops/action-gh-release@v1 28 | if: startsWith(github.ref, 'refs/tags/') 29 | with: 30 | files: | 31 | target/release/quickmd_amd64 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Andrew Radev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "quickmd" 3 | version = "0.7.1" 4 | authors = ["Andrew Radev "] 5 | edition = "2024" 6 | license = "MIT OR Apache-2.0" 7 | description = "Quickly preview a markdown file" 8 | readme = "README.md" 9 | keywords = ["markdown", "gtk"] 10 | exclude = ["tests/**", "_project.vim"] 11 | repository = "https://github.com/AndrewRadev/quickmd" 12 | 13 | [badges] 14 | maintenance = { status = "actively-developed" } 15 | 16 | [dependencies] 17 | anyhow = "1.0.68" 18 | directories = "4.0.1" 19 | env_logger = { version = "0.10.0", default-features = false } 20 | gdk = "0.15" 21 | gdk-pixbuf = "0.15" 22 | gio = "0.15" 23 | glib = "0.15" 24 | gtk = { version = "0.15", features = ["v3_24"] } 25 | log = "0.4" 26 | notify = "4.0.17" 27 | pathbuftools = "0.1.2" 28 | pulldown-cmark = { version = "0.9.2", default-features = false, features = ["simd"] } 29 | regex = "1.7.1" 30 | serde = { version = "1.0", features = ["derive"] } 31 | serde_json = "1.0" 32 | serde_yaml = "0.9.16" 33 | structopt = { version = "0.3.26", default-features = false } 34 | tempfile = "3.3.0" 35 | webkit2gtk = "0.18.2" 36 | 37 | [dev-dependencies] 38 | claim = "0.5.0" 39 | -------------------------------------------------------------------------------- /res/default_config.yaml: -------------------------------------------------------------------------------- 1 | # The default zoom level of the webview. In case of a HiDPI display, it can be useful to set this 2 | # to a higher value. 3 | # 4 | # In the UI, the zoom level can be changed in increments of 0.1 by holding the Control key and 5 | # scrolling up or down. 6 | # 7 | zoom: 1.0 8 | 9 | # The command-line components to launch an editor with the "e" key (or exec into one with "E"). The 10 | # `{path}` placeholder will be replaced with the full path to the current markdown file. 11 | # 12 | # Other examples for editor command-lines: 13 | # 14 | # - ["code", "{path}"] 15 | # - ["gnome-terminal", "--", "vim", "{path}"] 16 | # - ["alacritty", "-e", "vim", "{path}"] 17 | # 18 | editor_command: ["gvim", "{path}"] 19 | 20 | # You can set your own keybindings, or unset the defaults by setting them to 21 | # "Noop". See the API documentaiton for a full list of actions, under 22 | # `ui::action::Action`. 23 | # 24 | # The keybindings are passed along to GDK: 25 | # - if given `key_char`, it's passed along to `Key::from_unicode` 26 | # - if given `key_name`, `Key::from_name` is called with it. So, "plus" 27 | # instead of "+". You can also use it for special keys like "Escape", "F1", etc. 28 | # - Modifiers that are supported: "control", "shift", "alt" 29 | # 30 | mappings: [] 31 | # mappings: 32 | # - { key_char: "q", action: "Quit" } 33 | # - { key_name: "minus", mods: ["control"], action: "ZoomOut" } 34 | -------------------------------------------------------------------------------- /tests/test_input.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::fs; 3 | 4 | use claim::assert_matches; 5 | use quickmd::input::InputFile; 6 | 7 | #[test] 8 | fn test_input_file_constructed_from_local_file() { 9 | let path = PathBuf::from("./test.md"); 10 | let buffer = Vec::::new(); 11 | let input_file = InputFile::from(&path, buffer.as_slice()).unwrap(); 12 | 13 | assert_matches!(input_file, InputFile::Filesystem(_)); 14 | assert_eq!("./test.md", input_file.path().to_str().unwrap()); 15 | } 16 | 17 | #[test] 18 | fn test_input_file_constructed_from_stdin() { 19 | let path = PathBuf::from("-"); 20 | let buffer = Vec::::new(); 21 | let input_file = InputFile::from(&path, buffer.as_slice()).unwrap(); 22 | 23 | assert_matches!(input_file, InputFile::Stdin(_)); 24 | // Not really "-", would be a temporary file: 25 | assert_ne!("-", input_file.path().to_str().unwrap()); 26 | } 27 | 28 | #[test] 29 | fn test_reading_from_file_built_from_stdin() { 30 | let path = PathBuf::from("-"); 31 | let buffer: Vec = "# Test\nTest content".bytes().collect(); 32 | let input_file = InputFile::from(&path, buffer.as_slice()).unwrap(); 33 | 34 | assert_eq!(b"# Test\nTest content", fs::read(input_file.path()).unwrap().as_slice()); 35 | } 36 | 37 | #[test] 38 | fn test_temporary_file_is_cleaned_up_on_drop() { 39 | let path = PathBuf::from("-"); 40 | let buffer: Vec = "# Test\nTest content".bytes().collect(); 41 | let input_file = InputFile::from(&path, buffer.as_slice()).unwrap(); 42 | let path = input_file.path().to_path_buf(); 43 | 44 | assert!(path.exists()); 45 | drop(input_file); 46 | assert!(!path.exists()); 47 | } 48 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::process; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::anyhow; 6 | use log::debug; 7 | 8 | use quickmd::assets::Assets; 9 | use quickmd::background; 10 | use quickmd::input::{Config, Options, InputFile}; 11 | use quickmd::markdown::Renderer; 12 | use quickmd::ui; 13 | 14 | fn main() { 15 | let options = Options::build(); 16 | options.init_logging(); 17 | 18 | let config = Config::load(). 19 | unwrap_or_default(); 20 | 21 | debug!("Loaded config: {:?}", config); 22 | debug!(" > path: {}", Config::yaml_path().display()); 23 | debug!("Using input options: {:?}", options); 24 | 25 | if let Err(e) = run(&config, &options) { 26 | eprintln!("{}", e); 27 | process::exit(1); 28 | } 29 | } 30 | 31 | fn run(config: &Config, options: &Options) -> anyhow::Result<()> { 32 | if options.install_default_config { 33 | return Config::try_install_default(); 34 | } 35 | 36 | gtk::init()?; 37 | 38 | if let Some(input_file) = options.input_file.as_ref() { 39 | launch_app(input_file, options, config) 40 | } else { 41 | let input_file = launch_file_picker()?; 42 | launch_app(&input_file, options, config) 43 | } 44 | } 45 | 46 | fn launch_file_picker() -> anyhow::Result { 47 | ui::dialogs::FilePicker::new().run().ok_or_else(|| { 48 | anyhow!("Please provide a markdown file to render or call the program with - to read from STDIN") 49 | }) 50 | } 51 | 52 | fn launch_app(input_file: &Path, options: &Options, config: &Config) -> anyhow::Result<()> { 53 | let input_file = InputFile::from(input_file, io::stdin())?; 54 | let is_real_file = input_file.is_real_file(); 55 | let md_path = input_file.path(); 56 | 57 | if !md_path.exists() { 58 | let error = anyhow!("File not found: {}", md_path.display()); 59 | return Err(error); 60 | } 61 | 62 | let renderer = Renderer::new(md_path.to_path_buf()); 63 | let assets = Assets::init(options.output_dir.clone())?; 64 | 65 | let mut ui = ui::App::init(config.clone(), input_file.clone(), assets)?; 66 | let (ui_sender, ui_receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); 67 | ui.init_render_loop(ui_receiver); 68 | 69 | // Initial render 70 | ui_sender.send(ui::Event::LoadHtml(renderer.run()?))?; 71 | 72 | if is_real_file && options.watch { 73 | background::init_update_loop(renderer, ui_sender); 74 | } 75 | 76 | ui.run(); 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /src/ui/dialogs.rs: -------------------------------------------------------------------------------- 1 | //! Modal dialogs. 2 | 3 | use std::path::PathBuf; 4 | 5 | use gtk::prelude::*; 6 | 7 | use crate::input::Config; 8 | 9 | /// A popup to choose a file if it wasn't provided on the command-line. 10 | /// 11 | pub struct FilePicker(gtk::FileChooserDialog); 12 | 13 | impl FilePicker { 14 | /// Construct a new file picker that only shows markdown files by default 15 | /// 16 | pub fn new() -> FilePicker { 17 | let dialog = gtk::FileChooserDialog::new( 18 | Some("Open"), 19 | Some(>k::Window::new(gtk::WindowType::Popup)), 20 | gtk::FileChooserAction::Open, 21 | ); 22 | 23 | // Only show markdown files 24 | let filter = gtk::FileFilter::new(); 25 | filter.set_name(Some("Markdown files (*.md, *.markdown)")); 26 | filter.add_pattern("*.md"); 27 | filter.add_pattern("*.markdown"); 28 | dialog.add_filter(&filter); 29 | 30 | // Just in case, allow showing all files 31 | let filter = gtk::FileFilter::new(); 32 | filter.add_pattern("*"); 33 | filter.set_name(Some("All files")); 34 | dialog.add_filter(&filter); 35 | 36 | // Add the cancel and open buttons to that dialog. 37 | dialog.add_button("Cancel", gtk::ResponseType::Cancel); 38 | dialog.add_button("Open", gtk::ResponseType::Ok); 39 | 40 | FilePicker(dialog) 41 | } 42 | 43 | /// Open the file picker popup and get the selected file. 44 | /// 45 | pub fn run(&self) -> Option { 46 | if self.0.run() == gtk::ResponseType::Ok { 47 | self.0.filename() 48 | } else { 49 | None 50 | } 51 | } 52 | } 53 | 54 | impl Drop for FilePicker { 55 | fn drop(&mut self) { self.0.close(); } 56 | } 57 | 58 | /// Open a popup that renders documentation for all the default keyboard and mouse mappings. 59 | /// 60 | pub fn open_help_dialog(window: >k::Window) -> gtk::ResponseType { 61 | use gtk::{DialogFlags, MessageType, ButtonsType}; 62 | 63 | let dialog = gtk::MessageDialog::new( 64 | Some(window), 65 | DialogFlags::MODAL | DialogFlags::DESTROY_WITH_PARENT, 66 | MessageType::Info, 67 | ButtonsType::Close, 68 | "" 69 | ); 70 | 71 | let content = format!{ 72 | include_str!("../../res/help_popup.html"), 73 | yaml_path = Config::yaml_path().display(), 74 | css_path = Config::css_path().display(), 75 | }; 76 | 77 | dialog.set_markup(&content); 78 | dialog.connect_response(|d, _response| d.close()); 79 | 80 | dialog.run() 81 | } 82 | -------------------------------------------------------------------------------- /tests/test_markdown.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::io::Write; 3 | 4 | use tempfile::NamedTempFile; 5 | use quickmd::markdown::Renderer; 6 | 7 | #[test] 8 | fn test_keeps_track_of_rendered_languages() { 9 | let mut file = NamedTempFile::new().unwrap(); 10 | 11 | writeln!(file, "``` vim" ).unwrap(); 12 | writeln!(file, "```" ).unwrap(); 13 | writeln!(file, "" ).unwrap(); 14 | writeln!(file, " ignored" ).unwrap(); 15 | writeln!(file, "" ).unwrap(); 16 | writeln!(file, "```ruby" ).unwrap(); 17 | writeln!(file, "```" ).unwrap(); 18 | writeln!(file, "" ).unwrap(); 19 | writeln!(file, "```" ).unwrap(); 20 | writeln!(file, "```" ).unwrap(); 21 | writeln!(file, "``` rust").unwrap(); 22 | writeln!(file, "```" ).unwrap(); 23 | 24 | let renderer = Renderer::new(file.path().to_path_buf()); 25 | let content = renderer.run().unwrap(); 26 | let expected: HashSet<_> = 27 | vec!["vim", "ruby", "rust"].into_iter().map(String::from).collect(); 28 | 29 | assert_eq!(expected, content.code_languages); 30 | } 31 | 32 | #[test] 33 | fn test_renders_local_images() { 34 | let mut file = NamedTempFile::new().unwrap(); 35 | let tempdir = file.path().parent().unwrap().to_path_buf(); 36 | 37 | writeln!(file, "![demo image](local-image-01.png)").unwrap(); 38 | writeln!(file, "![demo image](.local-image-02.png)").unwrap(); 39 | writeln!(file, "![demo image](/local-image-03.png)").unwrap(); 40 | writeln!(file, "![demo image](./local-image-04.png)").unwrap(); 41 | writeln!(file, "![demo image](../local-image-05.png)").unwrap(); 42 | writeln!(file, "![demo image](http://remote-image-01.png)").unwrap(); 43 | writeln!(file, "![demo image](https://remote-image-02.png)").unwrap(); 44 | 45 | let renderer = Renderer::new(file.path().to_path_buf()); 46 | let content = renderer.run().unwrap(); 47 | 48 | assert!(content.html.contains(&format!("src=\"file://{}/local-image-01.png\"", tempdir.display()))); 49 | assert!(content.html.contains(&format!("src=\"file://{}/.local-image-02.png\"", tempdir.display()))); 50 | assert!(content.html.contains(&format!("src=\"file://{}/local-image-03.png\"", tempdir.display()))); 51 | assert!(content.html.contains(&format!("src=\"file://{}/local-image-04.png\"", tempdir.display()))); 52 | assert!(content.html.contains(&format!("src=\"file://{}/../local-image-05.png\"", tempdir.display()))); 53 | assert!(content.html.contains("src=\"http://remote-image-01.png\"")); 54 | assert!(content.html.contains("src=\"https://remote-image-02.png\"")); 55 | } 56 | -------------------------------------------------------------------------------- /tests/test_background.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::sync::mpsc; 3 | use std::sync::mpsc::RecvTimeoutError::Timeout as TimeoutError; 4 | use std::time::Duration; 5 | 6 | use claim::assert_matches; 7 | 8 | use quickmd::ui; 9 | use quickmd::markdown::Renderer; 10 | use quickmd::background::init_update_loop; 11 | 12 | #[test] 13 | fn test_update_loop_detects_file_updates() { 14 | let tempdir = tempfile::tempdir().unwrap(); 15 | let path = tempdir.path().join("file.md"); 16 | 17 | fs::write(&path, "# Test").unwrap(); 18 | let renderer = Renderer::new(path.clone()); 19 | 20 | let (sender, receiver) = mpsc::channel(); 21 | init_update_loop(renderer, sender); 22 | // Wait for the watcher thread to get ready 23 | std::thread::sleep(Duration::from_millis(10)); 24 | 25 | fs::write(path, "# Changed").unwrap(); 26 | 27 | // Expect LoadHtml message 28 | let message = receiver.recv_timeout(Duration::from_millis(300)); 29 | assert_matches!(message, Ok(ui::Event::LoadHtml(_))); 30 | 31 | // Expect no further message 32 | let message = receiver.recv_timeout(Duration::from_millis(300)); 33 | assert_matches!(message, Err(TimeoutError)); 34 | } 35 | 36 | #[test] 37 | fn test_update_loop_detects_file_creation() { 38 | let tempdir = tempfile::tempdir().unwrap(); 39 | let path = tempdir.path().join("file.md"); 40 | 41 | fs::write(&path, "# Test").unwrap(); 42 | let renderer = Renderer::new(path.clone()); 43 | 44 | let (sender, receiver) = mpsc::channel(); 45 | init_update_loop(renderer, sender); 46 | // Wait for the watcher thread to get ready 47 | std::thread::sleep(Duration::from_millis(10)); 48 | 49 | fs::remove_file(&path).unwrap(); 50 | fs::write(&path, "# Changed").unwrap(); 51 | 52 | // Expect LoadHtml message 53 | let message = receiver.recv_timeout(Duration::from_millis(300)); 54 | assert_matches!(message, Ok(ui::Event::LoadHtml(_))); 55 | 56 | // Expect no further message 57 | let message = receiver.recv_timeout(Duration::from_millis(300)); 58 | assert_matches!(message, Err(TimeoutError)); 59 | } 60 | 61 | #[test] 62 | fn test_update_loop_ignores_unrelated_files() { 63 | let tempdir = tempfile::tempdir().unwrap(); 64 | let path = tempdir.path().join("file.md"); 65 | let other_path = tempdir.path().join("other.md"); 66 | 67 | // Create both files 68 | fs::write(&path, "# Test").unwrap(); 69 | fs::write(&other_path, "# Other").unwrap(); 70 | 71 | let renderer = Renderer::new(path.clone()); 72 | 73 | let (sender, receiver) = mpsc::channel(); 74 | init_update_loop(renderer, sender); 75 | // Wait for the watcher thread to get ready 76 | std::thread::sleep(Duration::from_millis(10)); 77 | 78 | // Update only the "other" file 79 | fs::write(&other_path, "# Updated").unwrap(); 80 | 81 | // No message 82 | let message = receiver.recv_timeout(Duration::from_millis(300)); 83 | assert_matches!(message, Err(TimeoutError)); 84 | } 85 | -------------------------------------------------------------------------------- /res/js/main.js: -------------------------------------------------------------------------------- 1 | // Page state container 2 | let title = document.querySelector('title'); 3 | // Page state object 4 | let page_state = JSON.parse(title.innerHTML); 5 | 6 | // Update scroll position on load: 7 | window.scroll(0, page_state.scroll_top); 8 | 9 | // Store scroll position on scroll: 10 | window.addEventListener('scroll', function() { 11 | page_state.scroll_top = window.pageYOffset; 12 | title.innerHTML = JSON.stringify(page_state); 13 | }); 14 | 15 | // Set image sizes we have data for, store sizes for new images: 16 | document.querySelectorAll('img').forEach(function(img) { 17 | const width = page_state.image_widths[img.src]; 18 | const height = page_state.image_heights[img.src]; 19 | let style = ""; 20 | 21 | if (width) { style = `${style} width: ${width}px;`; } 22 | if (height) { style = `${style} height: ${height}px;`; } 23 | 24 | img.style = style; 25 | 26 | img.onload = function() { 27 | // Remove the style overloads on load in case the image has changed: 28 | img.style = ""; 29 | 30 | // Cache calculated sizes: 31 | page_state.image_heights[this.src] = this.height; 32 | page_state.image_widths[this.src] = this.width; 33 | title.innerHTML = JSON.stringify(page_state); 34 | }; 35 | }); 36 | 37 | // Create anchors for all the headings: 38 | document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function(heading) { 39 | if (!heading.id) { 40 | let content = heading.innerHTML.trim().toLowerCase(); 41 | let slug = content.replace(/[^\w\d]+/g, '-'); 42 | 43 | heading.id = slug; 44 | } 45 | }); 46 | 47 | // Show link preview at the bottom: 48 | let linkPreview = document.querySelector('#link-preview'); 49 | let rootUrl = window.location.href; 50 | 51 | document.querySelectorAll('a').forEach(function(link) { 52 | let url = link.href; 53 | let description; 54 | 55 | if (url.startsWith(rootUrl)) { 56 | // it's a local anchor, let's just take that part 57 | description = `Jump: ${url.replace(rootUrl, '')}` 58 | } else { 59 | // it's an external URL, copy it 60 | description = `Copy: ${url}` 61 | 62 | link.addEventListener('click', function(e) { 63 | e.preventDefault(); 64 | 65 | // Create a temporary text input whose contents we can "select" and copy using `execCommand`. 66 | let tempInput = document.createElement('input'); 67 | tempInput.setAttribute('type', 'text'); 68 | tempInput.setAttribute('value', url); 69 | document.body.insertBefore(tempInput, linkPreview); 70 | 71 | tempInput.select(); 72 | document.execCommand('copy'); 73 | document.body.removeChild(tempInput); 74 | 75 | linkPreview.innerHTML = "Copied to clipboard!"; 76 | }); 77 | } 78 | 79 | link.addEventListener('mouseenter', function() { 80 | linkPreview.innerHTML = description; 81 | linkPreview.classList.remove('hidden'); 82 | linkPreview.classList.add('visible'); 83 | }); 84 | 85 | link.addEventListener('mouseleave', function() { 86 | linkPreview.classList.remove('visible'); 87 | linkPreview.classList.add('hidden'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/markdown.rs: -------------------------------------------------------------------------------- 1 | //! Markdown rendering. 2 | //! 3 | //! Uses the [`pulldown_cmark`] crate with Github-flavored markdown options enabled. Extracts 4 | //! languages used in code blocks for highlighting purposes. 5 | 6 | use std::fs; 7 | use std::io; 8 | use std::path::{PathBuf, Path}; 9 | use std::collections::HashSet; 10 | use regex::Regex; 11 | use pulldown_cmark::{Parser, Options, Event, html}; 12 | 13 | /// Encapsulates a markdown file and provides an interface to turn its contents into HTML. 14 | /// 15 | pub struct Renderer { 16 | /// The original path given to the renderer. 17 | pub md_path: PathBuf, 18 | 19 | /// The canonicalized path to use in file operations. 20 | pub canonical_md_path: PathBuf, 21 | } 22 | 23 | impl Renderer { 24 | /// Create a new renderer instance that wraps the given markdown file. 25 | /// 26 | pub fn new(md_path: PathBuf) -> Self { 27 | let canonical_md_path = md_path.canonicalize(). 28 | unwrap_or_else(|_| md_path.clone()); 29 | 30 | Renderer { md_path, canonical_md_path } 31 | } 32 | 33 | /// Turn the current contents of the markdown file into HTML. 34 | /// 35 | pub fn run(&self) -> Result { 36 | let markdown = fs::read_to_string(&self.canonical_md_path)?; 37 | let root_dir = self.canonical_md_path.parent().unwrap_or_else(|| Path::new("")); 38 | 39 | let re_absolute_url = Regex::new(r"^[a-z]+://").unwrap(); 40 | let re_path_prefix = Regex::new(r"^(/|\./)?").unwrap(); 41 | 42 | let mut options = Options::empty(); 43 | options.insert(Options::ENABLE_TABLES); 44 | options.insert(Options::ENABLE_FOOTNOTES); 45 | options.insert(Options::ENABLE_TASKLISTS); 46 | options.insert(Options::ENABLE_STRIKETHROUGH); 47 | let parser = Parser::new_ext(&markdown, options); 48 | 49 | let mut languages = HashSet::new(); 50 | let parser = parser.map(|mut event| { 51 | use pulldown_cmark::{Tag, CodeBlockKind}; 52 | 53 | match &mut event { 54 | Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(content))) => { 55 | if content.len() > 0 { 56 | languages.insert(content.to_string()); 57 | } 58 | }, 59 | Event::Start(Tag::Image(_, url, _)) if !re_absolute_url.is_match(url) => { 60 | *url = format!("file://{}/{}", root_dir.display(), re_path_prefix.replace(url, "")).into(); 61 | }, 62 | _ => (), 63 | } 64 | 65 | event 66 | }); 67 | 68 | let mut output = String::new(); 69 | html::push_html(&mut output, parser); 70 | 71 | Ok(RenderedContent { 72 | html: output, 73 | code_languages: languages, 74 | }) 75 | } 76 | } 77 | 78 | /// The output of the rendering process. Includes both the rendered HTML and additional metadata 79 | /// used by its clients. 80 | /// 81 | #[derive(Debug, Default)] 82 | pub struct RenderedContent { 83 | /// The rendered HTML. 84 | pub html: String, 85 | 86 | /// All the languages in fenced code blocks from the markdown input. 87 | pub code_languages: HashSet, 88 | } 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at andrey.radev@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /src/ui/browser.rs: -------------------------------------------------------------------------------- 1 | //! A container for the `Browser` struct that wraps the [`webkit2gtk::WebView`]. 2 | 3 | use std::time::Instant; 4 | 5 | use anyhow::anyhow; 6 | use gio::Cancellable; 7 | use gtk::prelude::*; 8 | use log::{debug, warn}; 9 | use webkit2gtk::traits::WebViewExt; 10 | use webkit2gtk::{WebContext, WebView}; 11 | 12 | use crate::assets::PageState; 13 | use crate::input::Config; 14 | 15 | /// A thin layer on top of [`webkit2gtk::WebView`] to put helper methods into. 16 | /// 17 | #[derive(Clone)] 18 | pub struct Browser { 19 | webview: WebView, 20 | config: Config, 21 | } 22 | 23 | impl Browser { 24 | /// Construct a new instance with the provided `Config`. 25 | /// 26 | pub fn new(config: Config) -> anyhow::Result { 27 | let web_context = WebContext::default(). 28 | ok_or_else(|| anyhow!("Couldn't initialize GTK WebContext"))?; 29 | let webview = WebView::with_context(&web_context); 30 | webview.set_zoom_level(config.zoom); 31 | 32 | Ok(Browser { webview, config }) 33 | } 34 | 35 | /// Add this browser instance's webview to the given GTK Window. 36 | /// 37 | pub fn attach_to(&self, window: >k::Window) { 38 | window.add(&self.webview); 39 | } 40 | 41 | /// Delegates to [`webkit2gtk::WebView`] 42 | pub fn load_uri(&self, uri: &str) { 43 | self.webview.load_uri(uri); 44 | } 45 | 46 | /// Delegates to [`webkit2gtk::WebView`] 47 | pub fn reload(&self) { 48 | self.webview.reload(); 49 | } 50 | 51 | /// Increase zoom level by ~10% 52 | /// 53 | pub fn zoom_in(&self) { 54 | let zoom_level = self.webview.zoom_level(); 55 | self.webview.set_zoom_level(zoom_level + 0.1); 56 | debug!("Zoom level set to: {}", zoom_level); 57 | } 58 | 59 | /// Decrease zoom level by ~10%, down until 20% or so. 60 | /// 61 | pub fn zoom_out(&self) { 62 | let zoom_level = self.webview.zoom_level(); 63 | 64 | if zoom_level > 0.2 { 65 | self.webview.set_zoom_level(zoom_level - 0.1); 66 | debug!("Zoom level set to: {}", zoom_level); 67 | } 68 | } 69 | 70 | /// Reset to the base zoom level defined in the config (which defaults to 100%). 71 | /// 72 | pub fn zoom_reset(&self) { 73 | self.webview.set_zoom_level(self.config.zoom); 74 | debug!("Zoom level set to: {}", self.config.zoom); 75 | } 76 | 77 | /// Get the deserialized `PageState` from the current contents of the webview. This is later 78 | /// rendered unchanged into the HTML content. 79 | /// 80 | pub fn get_page_state(&self) -> PageState { 81 | match self.webview.title() { 82 | Some(t) => { 83 | serde_json::from_str(t.as_str()).unwrap_or_else(|e| { 84 | warn!("Failed to get page state from {}: {:?}", t, e); 85 | PageState::default() 86 | }) 87 | }, 88 | None => PageState::default(), 89 | } 90 | } 91 | 92 | /// Execute some (async) javascript code in the webview, without checking the result other than 93 | /// printing a warning if it errors out. 94 | /// 95 | pub fn execute_js(&self, js_code: &'static str) { 96 | let now = Instant::now(); 97 | 98 | self.webview.run_javascript(js_code, None::<&Cancellable>, move |result| { 99 | if let Err(e) = result { 100 | warn!("Javascript execution error: {}", e); 101 | } else { 102 | debug!("Javascript executed in {}ms:\n> {}", now.elapsed().as_millis(), js_code); 103 | } 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/test_assets.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use claim::assert_matches; 3 | 4 | use quickmd::assets::{Assets, PageState}; 5 | use quickmd::markdown::RenderedContent; 6 | 7 | macro_rules! assert_contains { 8 | ($haystack:expr, $needle:expr) => { 9 | if $haystack.contains($needle) { 10 | assert!(true); 11 | } else { 12 | assert!(false, "\n\nExpected:\n\n{}\nto contain:\n\n{}\n\n", $haystack, $needle) 13 | } 14 | } 15 | } 16 | 17 | #[test] 18 | fn test_initialization_works() { 19 | Assets::init(None).unwrap(); 20 | } 21 | 22 | #[test] 23 | fn test_multiple_cleanups_work() { 24 | let mut assets = Assets::init(None).unwrap(); 25 | 26 | assets.clean_up(); 27 | assets.clean_up(); 28 | } 29 | 30 | #[test] 31 | fn test_building_a_file_with_assets_includes_the_given_html() { 32 | let assets = Assets::init(None).unwrap(); 33 | let content = RenderedContent { 34 | html: String::from("

Example

"), 35 | ..RenderedContent::default() 36 | }; 37 | 38 | let path = assets.build(&content, &PageState::default()).unwrap(); 39 | 40 | assert!(fs::read_to_string(&path).unwrap().contains(&content.html)); 41 | assert!(fs::read_to_string(&path).unwrap().contains("main.js")); 42 | assert!(fs::read_to_string(&path).unwrap().contains("main.css")); 43 | } 44 | 45 | #[test] 46 | fn test_building_a_file_with_assets_includes_main_static_files() { 47 | let assets = Assets::init(None).unwrap(); 48 | let path = assets.build(&RenderedContent::default(), &PageState::default()).unwrap(); 49 | 50 | assert!(fs::read_to_string(&path).unwrap().contains("main.js")); 51 | assert!(fs::read_to_string(&path).unwrap().contains("main.css")); 52 | } 53 | 54 | #[test] 55 | fn test_building_a_file_with_assets_includes_scroll_position_as_the_title() { 56 | let assets = Assets::init(None).unwrap(); 57 | let page_state = PageState { scroll_top: 100.5, ..PageState::default() }; 58 | let path = assets.build(&RenderedContent::default(), &page_state).unwrap(); 59 | 60 | // Yes, it's included as the title, it's kind of dumb, but incredibly easy compared to the 61 | // alternative. 62 | assert_contains!( 63 | fs::read_to_string(&path).unwrap(), 64 | r#"{"scroll_top":100.5,"image_widths":{},"image_heights":{}}"# 65 | ); 66 | } 67 | 68 | #[test] 69 | fn test_output_to_a_given_directory() { 70 | let tempdir = tempfile::tempdir().unwrap(); 71 | let path = tempdir.path().to_path_buf(); 72 | let mut assets = Assets::init(Some(path.clone())).unwrap(); 73 | 74 | assert!(!path.join("index.html").exists()); 75 | assets.build(&RenderedContent::default(), &PageState::default()).unwrap(); 76 | assert!(path.join("index.html").exists()); 77 | 78 | // Clearing asset tempdir should not remove explicitly given directory 79 | assets.clean_up(); 80 | assert!(path.join("index.html").exists()); 81 | 82 | // Dropping assets should also not remove directory 83 | drop(assets); 84 | assert!(path.join("index.html").exists()); 85 | } 86 | 87 | #[test] 88 | fn test_will_create_output_dir_if_it_doesnt_exist() { 89 | let tempdir = tempfile::tempdir().unwrap(); 90 | let path = tempdir.path().join("nested").join("output"); 91 | let assets = Assets::init(Some(path.clone())).unwrap(); 92 | 93 | assert!(!path.join("index.html").exists()); 94 | assets.build(&RenderedContent::default(), &PageState::default()).unwrap(); 95 | assert!(path.join("index.html").exists()); 96 | } 97 | 98 | #[test] 99 | fn test_will_fail_if_output_dir_is_not_a_dir() { 100 | let tempfile = tempfile::NamedTempFile::new().unwrap(); 101 | let path = tempfile.path().to_path_buf(); 102 | 103 | assert_matches!(Assets::init(Some(path)), Err(_)); 104 | } 105 | -------------------------------------------------------------------------------- /src/background.rs: -------------------------------------------------------------------------------- 1 | //! Background monitoring for file changes. 2 | //! 3 | //! Whenever a file changes, we want to regenerate the HTML and send it to the UI for rendering to 4 | //! the user. This is done with the `init_update_loop` function. 5 | 6 | use std::sync::mpsc; 7 | use std::thread; 8 | use std::time::Duration; 9 | use std::marker::Send; 10 | 11 | use log::{debug, error, warn}; 12 | use notify::{Watcher, RecursiveMode, DebouncedEvent, watcher}; 13 | 14 | use crate::input::Config; 15 | use crate::markdown; 16 | use crate::ui; 17 | 18 | /// A common trait for `glib::Sender` and `std::mpsc::Sender`. 19 | /// 20 | /// Both of them have the exact same `send` method, down to the error type they use. Still, we need 21 | /// a shared trait to use in the `init_update_loop` function. 22 | /// 23 | /// In practice, we only use `glib::Sender` in "real code", but `std::mpsc::Sender` allows easier 24 | /// testing, so that's why this trait exists. 25 | /// 26 | pub trait Sender { 27 | /// Send a `ui::Event` to the receiver at the other end 28 | fn send(&mut self, event: ui::Event) -> Result<(), mpsc::SendError>; 29 | } 30 | 31 | impl Sender for glib::Sender { 32 | fn send(&mut self, event: ui::Event) -> Result<(), mpsc::SendError> { 33 | glib::Sender::::send(self, event) 34 | } 35 | } 36 | 37 | impl Sender for mpsc::Sender { 38 | fn send(&mut self, event: ui::Event) -> Result<(), mpsc::SendError> { 39 | mpsc::Sender::::send(self, event) 40 | } 41 | } 42 | 43 | /// The main background worker. Spawns a thread and uses the `notify` crate to listen for file changes. 44 | /// 45 | /// Input: 46 | /// 47 | /// - `renderer`: The struct that takes care of rendering the markdown file into HTML. Used to get 48 | /// the filename to monitor and to generate the HTML on update. 49 | /// - `ui_sender`: The channel to send `ui::Event` records to when a change is detected. 50 | /// 51 | /// A change to the main markdown file triggers a rerender and webview refresh. A change to the 52 | /// user-level configuration files is only going to trigger a refresh. 53 | /// 54 | pub fn init_update_loop(renderer: markdown::Renderer, mut ui_sender: S) 55 | where S: Sender + Send + 'static 56 | { 57 | thread::spawn(move || { 58 | let (watcher_sender, watcher_receiver) = mpsc::channel(); 59 | 60 | let mut watcher = match watcher(watcher_sender, Duration::from_millis(200)) { 61 | Ok(w) => w, 62 | Err(e) => { 63 | warn!("Couldn't initialize watcher: {}", e); 64 | return; 65 | } 66 | }; 67 | 68 | // Watch the parent directory so we can catch recreated files 69 | let main_watch_path = renderer.canonical_md_path.parent(). 70 | unwrap_or(&renderer.canonical_md_path). 71 | to_owned(); 72 | 73 | if let Err(e) = watcher.watch(&main_watch_path, RecursiveMode::NonRecursive) { 74 | warn!("Couldn't initialize watcher: {}", e); 75 | return; 76 | } 77 | 78 | let mut extra_watch_paths = vec![]; 79 | let custom_css_path = Config::css_path(); 80 | 81 | if watcher.watch(&custom_css_path, RecursiveMode::NonRecursive).is_ok() { 82 | debug!("Watching {}", custom_css_path.display()); 83 | extra_watch_paths.push(custom_css_path); 84 | } 85 | 86 | loop { 87 | match watcher_receiver.recv() { 88 | Ok(DebouncedEvent::Write(file) | DebouncedEvent::Create(file)) => { 89 | debug!("File update/recreate detected: {}", file.display()); 90 | 91 | if file == renderer.canonical_md_path { 92 | match renderer.run() { 93 | Ok(html) => { 94 | let _ = ui_sender.send(ui::Event::LoadHtml(html)); 95 | }, 96 | Err(e) => { 97 | error! { 98 | "Error rendering markdown ({}): {:?}", 99 | renderer.canonical_md_path.display(), e 100 | }; 101 | } 102 | } 103 | } else if extra_watch_paths.contains(&file) { 104 | let _ = ui_sender.send(ui::Event::Reload); 105 | } else { 106 | debug!("Ignored file update event: {:?}", file) 107 | } 108 | }, 109 | Ok(event) => debug!("Ignored watcher event: {:?}", event), 110 | Err(e) => error!("Error watching file for changes: {:?}", e), 111 | } 112 | } 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Crate](https://img.shields.io/crates/v/quickmd)](https://crates.io/crates/quickmd) 4 | [![Documentation](https://docs.rs/quickmd/badge.svg)](https://docs.rs/quickmd) 5 | [![Maintenance status](https://img.shields.io/badge/maintenance-actively--developed-brightgreen.svg)](https://crates.io/crates/quickmd) 6 | 7 | # QuickMD 8 | 9 | This project is a simple tool that solves a simple problem: I'd like to preview markdown files as they'll show up on Github, so I don't have to push my READMEs before I can tell whether they're formatted well enough. It ends up looking like this: 10 | 11 | ![Demo](http://i.andrewradev.com/ad155fa1a15f27beeb13b74d277212e4.gif) 12 | 13 | It's a Rust app that launches a GtkWebkit window that renders the compiled HTML of the given markdown file. It monitors this file for any changes and reloads. It uses a stylesheet that's literally copied off Github's markdown stylesheet. 14 | 15 | _Note: I have no idea if I'm allowed to use Github's stylesheet. The relevant file is in res/style/github.css, and if I am told I shouldn't be using it I'll go ahead and scrub it from git history._ 16 | 17 | ## Installation 18 | 19 | ### From a release binary 20 | 21 | You should be able to find a compiled 64-bit linux binary in every release since v0.4.1. You can put it in your `$PATH` and launch it, as long as you have GTK3 and Webkit2Gtk installed on your system. On Arch Linux, you can install these like so: 22 | 23 | ``` .sh-session 24 | # pacman -S gtk3 webkit2gtk 25 | ``` 26 | 27 | ### From source 28 | 29 | You'll need to have Rust and the `cargo` tool. The easiest way to get that done is through [rustup.rs](https://rustup.rs/). 30 | 31 | You'll also need the GTK3 and Webkit2Gtk _development_ files to be installed on your system. The Gtk-rs [installation page](https://www.gtk.org/docs/installations/) might be a good start. 32 | 33 | After that, you can build and install the app from `crates.io` using: 34 | 35 | ``` 36 | cargo install quickmd 37 | ``` 38 | 39 | Make sure that `~/.cargo/bin` is in your `PATH` so you can call the `quickmd` executable. 40 | 41 | ## Usage 42 | 43 | Running the app is as simple as: 44 | 45 | ``` 46 | quickmd 47 | ``` 48 | 49 | Pressing CTRL+Q will close the window. Running it with `--help` should provide more info on the available options. Here's how the output looks for me: 50 | 51 | ``` 52 | quickmd 0.6.0 53 | A simple self-contained markdown previewer. 54 | 55 | Code highlighting via highlight.js version 9.18.1 56 | 57 | Edit configuration in: /home/andrew/.config/quickmd/config.yaml 58 | Add custom CSS in: /home/andrew/.config/quickmd/custom.css 59 | 60 | USAGE: 61 | quickmd [FLAGS] [OPTIONS] [input-file.md] 62 | 63 | FLAGS: 64 | -d, --debug 65 | Activates debug logging 66 | 67 | -h, --help 68 | Prints help information 69 | 70 | --install-default-config 71 | Creates a configuration file for later editing if one doesn't exist. Exits when done 72 | 73 | -V, --version 74 | Prints version information 75 | 76 | --no-watch 77 | Disables watching file for changes 78 | 79 | 80 | OPTIONS: 81 | --output 82 | Builds output HTML and other assets in the given directory instead of in a tempdir. Will be created if it 83 | doesn't exist. Not deleted on application exit 84 | 85 | ARGS: 86 | 87 | Markdown file to render. Use "-" to read markdown from STDIN (implies --no-watch). If not provided, the app 88 | will launch a file picker 89 | ``` 90 | 91 | ## Features 92 | 93 | - Github-like rendering, though not guaranteed to be perfectly identical. Relying on whatever [pulldown-cmark](https://crates.io/crates/pulldown-cmark) provides, which is good enough for me. 94 | 95 | - Fast and seamless preview updates on file write. 96 | 97 | - Code highlighting via [highlight.js](https://highlightjs.org/). Currently, the relevant javascript is included via a CDN, which unfortunately means it won't work without an internet connection. 98 | 99 | - Ability to render STDIN, which allows partial rendering of target markdown. Try putting [this bit of Vimscript](https://github.com/AndrewRadev/Vimfiles/blob/f9e0c08dd280d13acb625d3370da399c39e14403/ftplugin/markdown.vim#L11-L15) in your `~/.vim/ftplugin/markdown.vim`, select a few lines and press `!`. 100 | 101 | - Scroll with Vi-like keybindings, Press `e` to spawn an editor, if configured. 102 | 103 | - Customizable keybindings. Check the API documentation or try `--install-default-config` to get an annotated config with examples. 104 | 105 | ## Configuration 106 | 107 | You can change the CSS of the preview HTML by writing a file named "custom.css" in the application's config directory. On a linux machine, it would be: `~/.config/quickmd/`. 108 | 109 | You can also change some configuration options in a config file. Run `quickmd` with `--install-default-config` to create that file with all the defaults and comments. 110 | 111 | Run `--help` to see where the config files will be located on your system, or press `F1` in the interface. 112 | 113 | The built-in CSS that is used is stored in [/res/style](./res/style) and the default config is in [/res/default_config.yaml](./res/default_config.yaml) 114 | -------------------------------------------------------------------------------- /tests/test_ui_actions.rs: -------------------------------------------------------------------------------- 1 | use gdk::ModifierType; 2 | use gdk::keys::Key; 3 | 4 | use quickmd::input::MappingDefinition; 5 | use quickmd::ui::action::{Action, Keymaps}; 6 | 7 | #[test] 8 | fn test_default_keybindings() { 9 | let keymaps = Keymaps::default(); 10 | 11 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('j')), Action::SmallScrollDown); 12 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('k')), Action::SmallScrollUp); 13 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('J')), Action::BigScrollDown); 14 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('K')), Action::BigScrollUp); 15 | 16 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('e')), Action::LaunchEditor); 17 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('E')), Action::ExecEditor); 18 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('+')), Action::ZoomIn); 19 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('-')), Action::ZoomOut); 20 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('=')), Action::ZoomReset); 21 | 22 | assert_eq!(keymaps.get_action(ModifierType::CONTROL_MASK, Key::from_unicode('q')), Action::Quit); 23 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("F1")), Action::ShowHelp); 24 | 25 | } 26 | 27 | #[test] 28 | fn test_shift_and_capital_letter_equivalence_when_getting() { 29 | let keymaps = Keymaps::default(); 30 | 31 | let big_j = keymaps.get_action(ModifierType::empty(), Key::from_name("J")); 32 | assert_eq!(big_j, Action::BigScrollDown); 33 | 34 | let shift_j = keymaps.get_action(ModifierType::SHIFT_MASK, Key::from_name("j")); 35 | assert_eq!(shift_j, Action::BigScrollDown); 36 | } 37 | 38 | #[test] 39 | fn test_setting_custom_mappings() { 40 | let mut keymaps = Keymaps::default(); 41 | 42 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("q")), Action::Noop); 43 | 44 | keymaps.set_action(ModifierType::empty(), Key::from_name("q"), Action::Quit); 45 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("q")), Action::Quit); 46 | 47 | keymaps.set_action(ModifierType::empty(), Key::from_name("q"), Action::ZoomIn); 48 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("q")), Action::ZoomIn); 49 | } 50 | 51 | #[test] 52 | fn test_unsetting_a_default_mapping() { 53 | let mut keymaps = Keymaps::default(); 54 | 55 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("j")), Action::SmallScrollDown); 56 | 57 | keymaps.set_action(ModifierType::empty(), Key::from_name("j"), Action::Noop); 58 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_name("j")), Action::Noop); 59 | } 60 | 61 | #[test] 62 | fn test_setting_non_alphabetic_shift_keys() { 63 | let mut keymaps = Keymaps::default(); 64 | 65 | keymaps.set_action(ModifierType::empty(), Key::from_unicode('+'), Action::ScrollToTop); 66 | assert_eq!(keymaps.get_action(ModifierType::empty(), Key::from_unicode('+')), Action::ScrollToTop); 67 | assert_eq!(keymaps.get_action(ModifierType::SHIFT_MASK, Key::from_unicode('+')), Action::ScrollToTop); 68 | 69 | assert_ne!(keymaps.get_action(ModifierType::SHIFT_MASK, Key::from_unicode('=')), Action::ScrollToTop); 70 | } 71 | 72 | #[test] 73 | fn test_successfully_setting_mappings_from_the_config() { 74 | let mut keymaps = Keymaps::new(); 75 | 76 | let mapping = MappingDefinition { 77 | key_char: Some('q'), 78 | key_name: None, 79 | mods: Vec::new(), 80 | action: Action::Quit, 81 | }; 82 | assert!(keymaps.add_config_mappings(&[mapping]).is_ok()); 83 | 84 | let mapping1 = MappingDefinition { 85 | key_char: None, 86 | key_name: Some(String::from("plus")), 87 | mods: vec![String::from("control")], 88 | action: Action::ScrollToBottom, 89 | }; 90 | let mapping2 = MappingDefinition { 91 | key_char: None, 92 | key_name: Some(String::from("minus")), 93 | mods: vec![String::from("control"), String::from("shift"), String::from("alt")], 94 | action: Action::ScrollToTop, 95 | }; 96 | assert!(keymaps.add_config_mappings(&[mapping1, mapping2]).is_ok()); 97 | 98 | let action = keymaps.get_action(ModifierType::empty(), Key::from_unicode('q')); 99 | assert_eq!(action, Action::Quit); 100 | 101 | let action = keymaps.get_action(ModifierType::CONTROL_MASK, Key::from_unicode('+')); 102 | assert_eq!(action, Action::ScrollToBottom); 103 | 104 | let action = keymaps.get_action( 105 | ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK | ModifierType::MOD1_MASK, 106 | Key::from_unicode('-') 107 | ); 108 | assert_eq!(action, Action::ScrollToTop); 109 | } 110 | 111 | #[test] 112 | fn test_invalid_mapping_from_config() { 113 | let mut keymaps = Keymaps::new(); 114 | 115 | let mapping = MappingDefinition { 116 | key_char: None, 117 | key_name: None, 118 | mods: Vec::new(), 119 | action: Action::Quit, 120 | }; 121 | assert!(keymaps.add_config_mappings(&[mapping]).is_err()); 122 | 123 | let mapping = MappingDefinition { 124 | key_char: Some('q'), 125 | key_name: Some(String::from("q")), 126 | mods: Vec::new(), 127 | action: Action::Quit, 128 | }; 129 | assert!(keymaps.add_config_mappings(&[mapping]).is_err()); 130 | } 131 | -------------------------------------------------------------------------------- /src/ui/action.rs: -------------------------------------------------------------------------------- 1 | //! Actions on the UI triggered by keybindings. 2 | 3 | use std::collections::HashMap; 4 | 5 | use anyhow::anyhow; 6 | use gdk::ModifierType; 7 | use gdk::keys::{self, Key}; 8 | use log::debug; 9 | use serde::{Serialize, Deserialize}; 10 | 11 | use crate::input::MappingDefinition; 12 | 13 | /// Mappable actions 14 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 15 | pub enum Action { 16 | /// Placeholder action to allow unmapping keys 17 | Noop, 18 | 19 | /// Scroll up by a small step. Default: `k` 20 | SmallScrollUp, 21 | /// Scroll down by a small step. Default: `j` 22 | SmallScrollDown, 23 | 24 | /// Scroll up by a large step. Default: `K` 25 | BigScrollUp, 26 | /// Scroll down by a large step. Default: `J` 27 | BigScrollDown, 28 | 29 | /// Scroll to the top of the document. Default: `g` 30 | ScrollToTop, 31 | /// Scroll to the bottom of the document. Default: `G` 32 | ScrollToBottom, 33 | 34 | /// Quit the entire application. Default: `ctrl+q` 35 | Quit, 36 | 37 | /// Launch an editor instance if it's configured. Default: `e` 38 | LaunchEditor, 39 | /// Exec the current process into an editor instance if it's configured (and it's possible on 40 | /// the OS). Default: `E` 41 | ExecEditor, 42 | 43 | /// Zoom the browser in by 10%. Default: `+` 44 | ZoomIn, 45 | /// Zoom the browser out by 10%. Default: `-` 46 | ZoomOut, 47 | /// Reset the zoom level to the configured starting point. Default: `=` 48 | ZoomReset, 49 | 50 | /// Show a help popup. Default: `F1` 51 | ShowHelp, 52 | } 53 | 54 | impl Default for Action { 55 | fn default() -> Self { 56 | Action::Noop 57 | } 58 | } 59 | 60 | /// A mapping from key bindings to all the different UI actions. Initialized with a full set of 61 | /// defaults, which can be overridden by configuration. 62 | /// 63 | #[derive(Clone)] 64 | pub struct Keymaps { 65 | mappings: HashMap<(ModifierType, Key), Action>, 66 | } 67 | 68 | impl Default for Keymaps { 69 | fn default() -> Self { 70 | let mut keymaps = Self::new(); 71 | 72 | // Scroll with j/k, J/K: 73 | keymaps.set_action(ModifierType::empty(), keys::constants::j, Action::SmallScrollDown); 74 | keymaps.set_action(ModifierType::empty(), keys::constants::k, Action::SmallScrollUp); 75 | keymaps.set_action(ModifierType::SHIFT_MASK, keys::constants::j, Action::BigScrollDown); 76 | keymaps.set_action(ModifierType::SHIFT_MASK, keys::constants::k, Action::BigScrollUp); 77 | // Jump to the top/bottom with g/G 78 | keymaps.set_action(ModifierType::empty(), keys::constants::g, Action::ScrollToTop); 79 | keymaps.set_action(ModifierType::SHIFT_MASK, keys::constants::g, Action::ScrollToBottom); 80 | // Ctrl+Q to quit 81 | keymaps.set_action(ModifierType::CONTROL_MASK, keys::constants::q, Action::Quit); 82 | // e, E for editor integration 83 | keymaps.set_action(ModifierType::empty(), keys::constants::e, Action::LaunchEditor); 84 | keymaps.set_action(ModifierType::SHIFT_MASK, keys::constants::e, Action::ExecEditor); 85 | // +/-/= for zoom control 86 | keymaps.set_action(ModifierType::empty(), keys::constants::plus, Action::ZoomIn); 87 | keymaps.set_action(ModifierType::empty(), keys::constants::minus, Action::ZoomOut); 88 | keymaps.set_action(ModifierType::empty(), keys::constants::equal, Action::ZoomReset); 89 | // F1 to show help popup 90 | keymaps.set_action(ModifierType::empty(), keys::constants::F1, Action::ShowHelp); 91 | 92 | keymaps 93 | } 94 | } 95 | 96 | impl Keymaps { 97 | /// Create an empty set of keymaps. 98 | /// 99 | pub fn new() -> Self { 100 | Self { mappings: HashMap::new() } 101 | } 102 | 103 | /// Parse the given mappings as described in [`crate::input::Config`] 104 | /// 105 | pub fn add_config_mappings(&mut self, mappings: &[MappingDefinition]) -> anyhow::Result<()> { 106 | for mapping in mappings { 107 | let mut modifiers = ModifierType::empty(); 108 | for m in &mapping.mods { 109 | match m.as_str() { 110 | "control" => { modifiers |= ModifierType::CONTROL_MASK; } 111 | "shift" => { modifiers |= ModifierType::SHIFT_MASK; } 112 | "alt" => { modifiers |= ModifierType::MOD1_MASK; } 113 | _ => { 114 | { return Err(anyhow!("Unknown modifier: {}", m)); } 115 | }, 116 | } 117 | } 118 | 119 | if mapping.key_char.is_some() && mapping.key_name.is_some() { 120 | return Err( 121 | anyhow!("Both `key_char` or `key_name` given, please pick just one: {:?}", mapping) 122 | ); 123 | } 124 | 125 | let key = 126 | if let Some(c) = mapping.key_char { 127 | Key::from_unicode(c) 128 | } else if let Some(name) = &mapping.key_name { 129 | Key::from_name(name) 130 | } else { 131 | return Err(anyhow!("No `key_char` or `key_name` given: {:?}", mapping)); 132 | }; 133 | 134 | self.set_action(modifiers, key, mapping.action.clone()); 135 | debug!("Defined custom mapping: {:?}", mapping); 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | /// Get the action corresponding to the given modifiers and key. Uppercase unicode letters like 142 | /// are normalized to a lowercase letter + shift. 143 | /// 144 | pub fn get_action(&self, modifiers: ModifierType, key: Key) -> Action { 145 | let (key, modifiers) = Self::normalize_input(key, modifiers); 146 | self.mappings.get(&(modifiers, key)).cloned().unwrap_or(Action::Noop) 147 | } 148 | 149 | /// Set the action corresponding to the given modifiers and key. Could override existing 150 | /// actions. Setting `Action::Noop` is the way to "unmap" a keybinding. Uppercase unicode 151 | /// letters like are normalized to a lowercase letter + shift. 152 | /// 153 | pub fn set_action(&mut self, modifiers: ModifierType, key: Key, action: Action) { 154 | let (key, modifiers) = Self::normalize_input(key, modifiers); 155 | self.mappings.insert((modifiers, key), action); 156 | } 157 | 158 | fn normalize_input(mut key: Key, mut modifiers: ModifierType) -> (Key, ModifierType) { 159 | // If we get something considered an "upper" key, that means shift is being held. This is 160 | // not just for A -> S-a, but also for + -> = (though the + is not transformed). 161 | if key.is_upper() { 162 | key = key.to_lower(); 163 | modifiers.insert(ModifierType::SHIFT_MASK); 164 | } 165 | 166 | (key, modifiers) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /res/style/github.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | main { 4 | font-size: 14px; 5 | line-height: 1.6; 6 | overflow: hidden 7 | } 8 | main>*: first-child { 9 | margin-top: 0 !important 10 | } 11 | main>*: last-child { 12 | margin-bottom: 0 !important 13 | } 14 | main a.absent { 15 | color: #c00 16 | } 17 | main a.anchor { 18 | display: block; 19 | padding-left: 30px; 20 | margin-left: -30px; 21 | cursor: pointer; 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | bottom: 0 26 | } 27 | main h1,main h2,main h3,main h4,main h5,main h6 { 28 | margin: 20px 0 10px; 29 | padding: 0; 30 | font-weight: bold; 31 | -webkit-font-smoothing: antialiased; 32 | cursor: text; 33 | position: relative 34 | } 35 | main h1 .mini-icon-link,main h2 .mini-icon-link,main h3 .mini-icon-link,main h4 .mini-icon-link,main h5 .mini-icon-link,main h6 .mini-icon-link { 36 | display: none; 37 | color: #000 38 | } 39 | main h1: hover a.anchor,main h2: hover a.anchor,main h3: hover a.anchor,main h4: hover a.anchor,main h5: hover a.anchor,main h6: hover a.anchor { 40 | text-decoration: none; 41 | line-height: 1; 42 | padding-left: 0; 43 | margin-left: -22px; 44 | top: 15% 45 | } 46 | main h1: hover a.anchor .mini-icon-link,main h2: hover a.anchor .mini-icon-link,main h3: hover a.anchor .mini-icon-link,main h4: hover a.anchor .mini-icon-link,main h5: hover a.anchor .mini-icon-link,main h6: hover a.anchor .mini-icon-link { 47 | display: inline-block 48 | } 49 | main h1 tt,main h1 code,main h2 tt,main h2 code,main h3 tt,main h3 code,main h4 tt,main h4 code,main h5 tt,main h5 code,main h6 tt,main h6 code { 50 | font-size: inherit 51 | } 52 | main h1 { 53 | font-size: 28px; 54 | color: #000 55 | } 56 | main h2 { 57 | font-size: 24px; 58 | border-bottom: 1px solid #ccc; 59 | color: #000 60 | } 61 | main h3 { 62 | font-size: 18px 63 | } 64 | main h4 { 65 | font-size: 16px 66 | } 67 | main h5 { 68 | font-size: 14px 69 | } 70 | main h6 { 71 | color: #777; 72 | font-size: 14px 73 | } 74 | main p,main blockquote,main ul,main ol,main dl,main table,main pre { 75 | margin: 15px 0 76 | } 77 | main hr { 78 | background: transparent url("https: //a248.e.akamai.net/assets.github.com/assets/primer/markdown/dirty-shade-6ead57f83b0f117a80ba77232aff0673bfd71263.png") repeat-x 0 0; 79 | border: 0 none; 80 | color: #ccc; 81 | height: 4px; 82 | padding: 0 83 | } 84 | main>h2: first-child,main>h1: first-child,main>h1: first-child+h2,main>h3: first-child,main>h4: first-child,main>h5: first-child,main>h6: first-child { 85 | margin-top: 0; 86 | padding-top: 0 87 | } 88 | main a: first-child h1,main a: first-child h2,main a: first-child h3,main a: first-child h4,main a: first-child h5,main a: first-child h6 { 89 | margin-top: 0; 90 | padding-top: 0 91 | } 92 | main h1+p,main h2+p,main h3+p,main h4+p,main h5+p,main h6+p { 93 | margin-top: 0 94 | } 95 | main li p.first { 96 | display: inline-block 97 | } 98 | main ul,main ol { 99 | padding-left: 30px 100 | } 101 | main ul.no-list,main ol.no-list { 102 | list-style-type: none; 103 | padding: 0 104 | } 105 | main ul li>: first-child,main ul li ul: first-of-type,main ol li>: first-child,main ol li ul: first-of-type { 106 | margin-top: 0px 107 | } 108 | main ul ul,main ul ol,main ol ol,main ol ul { 109 | margin-bottom: 0 110 | } 111 | main dl { 112 | padding: 0 113 | } 114 | main dl dt { 115 | font-size: 14px; 116 | font-weight: bold; 117 | font-style: italic; 118 | padding: 0; 119 | margin: 15px 0 5px 120 | } 121 | main dl dt: first-child { 122 | padding: 0 123 | } 124 | main dl dt>: first-child { 125 | margin-top: 0px 126 | } 127 | main dl dt>: last-child { 128 | margin-bottom: 0px 129 | } 130 | main dl dd { 131 | margin: 0 0 15px; 132 | padding: 0 15px 133 | } 134 | main dl dd>: first-child { 135 | margin-top: 0px 136 | } 137 | main dl dd>: last-child { 138 | margin-bottom: 0px 139 | } 140 | main blockquote { 141 | border-left: 4px solid #DDD; 142 | padding: 0 15px; 143 | color: #777 144 | } 145 | main blockquote>: first-child { 146 | margin-top: 0px 147 | } 148 | main blockquote>: last-child { 149 | margin-bottom: 0px 150 | } 151 | main table th { 152 | font-weight: bold 153 | } 154 | main table th,main table td { 155 | border: 1px solid #ccc; 156 | padding: 6px 13px 157 | } 158 | main table tr { 159 | border-top: 1px solid #ccc; 160 | background-color: #fff 161 | } 162 | main table tr: nth-child(2n) { 163 | background-color: #f8f8f8 164 | } 165 | main img { 166 | max-width: 100%; 167 | -moz-box-sizing: border-box; 168 | box-sizing: border-box 169 | } 170 | main span.frame { 171 | display: block; 172 | overflow: hidden 173 | } 174 | main span.frame>span { 175 | border: 1px solid #ddd; 176 | display: block; 177 | float: left; 178 | overflow: hidden; 179 | margin: 13px 0 0; 180 | padding: 7px; 181 | width: auto 182 | } 183 | main span.frame span img { 184 | display: block; 185 | float: left 186 | } 187 | main span.frame span span { 188 | clear: both; 189 | color: #333; 190 | display: block; 191 | padding: 5px 0 0 192 | } 193 | main span.align-center { 194 | display: block; 195 | overflow: hidden; 196 | clear: both 197 | } 198 | main span.align-center>span { 199 | display: block; 200 | overflow: hidden; 201 | margin: 13px auto 0; 202 | text-align: center 203 | } 204 | main span.align-center span img { 205 | margin: 0 auto; 206 | text-align: center 207 | } 208 | main span.align-right { 209 | display: block; 210 | overflow: hidden; 211 | clear: both 212 | } 213 | main span.align-right>span { 214 | display: block; 215 | overflow: hidden; 216 | margin: 13px 0 0; 217 | text-align: right 218 | } 219 | main span.align-right span img { 220 | margin: 0; 221 | text-align: right 222 | } 223 | main span.float-left { 224 | display: block; 225 | margin-right: 13px; 226 | overflow: hidden; 227 | float: left 228 | } 229 | main span.float-left span { 230 | margin: 13px 0 0 231 | } 232 | main span.float-right { 233 | display: block; 234 | margin-left: 13px; 235 | overflow: hidden; 236 | float: right 237 | } 238 | main span.float-right>span { 239 | display: block; 240 | overflow: hidden; 241 | margin: 13px auto 0; 242 | text-align: right 243 | } 244 | main code,main tt { 245 | margin: 0 2px; 246 | padding: 0px 5px; 247 | border: 1px solid #eaeaea; 248 | background-color: #f8f8f8; 249 | border-radius: 3px 250 | } 251 | main code { 252 | white-space: nowrap 253 | } 254 | main pre>code { 255 | margin: 0; 256 | padding: 0; 257 | white-space: pre; 258 | border: none; 259 | background: transparent 260 | } 261 | main .highlight pre,main pre { 262 | background-color: #f8f8f8; 263 | border: 1px solid #ccc; 264 | font-size: 13px; 265 | line-height: 19px; 266 | overflow: auto; 267 | padding: 6px 10px; 268 | border-radius: 3px 269 | } 270 | main pre code,main pre tt { 271 | margin: 0; 272 | padding: 0; 273 | background-color: transparent; 274 | border: none 275 | } 276 | 277 | main a { 278 | color: #4183c4; 279 | text-decoration: none; 280 | } 281 | 282 | main a:hover { 283 | text-decoration: underline; 284 | } 285 | 286 | main pre, 287 | main code { 288 | font-size: 12px; 289 | font-family: Consolas, "Liberation Mono", Courier, monospace; 290 | } 291 | -------------------------------------------------------------------------------- /src/assets.rs: -------------------------------------------------------------------------------- 1 | //! Management of external assets like Javascript and CSS. 2 | //! 3 | //! The files are stored into the binary as strings and written to the filesystem when the 4 | //! application runs. For the HTML file, this allows the webview to load a URL instead of a string 5 | //! body, which makes reloading smoother (update the file, refresh). 6 | //! 7 | //! For the other assets, it means the HTML can refer to local files instead of embedding the 8 | //! contents as `"#, root_url). 133 | unwrap(); 134 | 135 | for language in &content.code_languages { 136 | writeln!( 137 | hl_tags, 138 | r#""#, 139 | root_url, language 140 | ).unwrap(); 141 | } 142 | 143 | writeln!(hl_tags, r#""#).unwrap(); 144 | } 145 | 146 | debug!("Building HTML:"); 147 | debug!(" > custom_css_path = {:?}", custom_css_path); 148 | debug!(" > page_state = {:?}", json_state); 149 | debug!(" > code languages = {:?}", content.code_languages); 150 | 151 | let page = format! { 152 | include_str!("../res/layout.html"), 153 | custom_css_path = custom_css_path.display(), 154 | body = content.html, 155 | hl_tags = hl_tags, 156 | page_state = json_state, 157 | }; 158 | 159 | let html_path = output_path.join("index.html"); 160 | fs::write(&html_path, page.as_bytes())?; 161 | 162 | Ok(html_path) 163 | } 164 | 165 | /// The path on the filesystem where the HTML and other assets go. Could be a temporary 166 | /// directory, or the one given at construction time. 167 | /// 168 | pub fn output_path(&self) -> anyhow::Result { 169 | match (&self.real_dir, &self.temp_dir) { 170 | (Some(path_buf), _) => Ok(path_buf.clone()), 171 | (_, Some(temp_dir)) => Ok(temp_dir.path().to_path_buf()), 172 | _ => Err(anyhow!("Assets don't have an output dir, there might be a synchronization error")) 173 | } 174 | } 175 | 176 | /// Delete the temporary directory used for building assets, if there is one. This should 177 | /// happen automatically on drop, but a GTK-level exit doesn't seem to unroll the stack, so we 178 | /// may need to delete things explicitly. 179 | /// 180 | /// If deletion fails, we quietly print a warning. Multiple (successful or failed) deletions 181 | /// are a noop. 182 | /// 183 | pub fn clean_up(&mut self) { 184 | if let Some(temp_dir) = self.temp_dir.take() { 185 | let path = temp_dir.path(); 186 | fs::remove_dir_all(path).unwrap_or_else(|_| { 187 | warn!("Couldn't delete temporary dir: {}", path.display()); 188 | }); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | //! The GTK user interface. 2 | 3 | pub mod action; 4 | pub mod browser; 5 | pub mod dialogs; 6 | 7 | use std::path::{PathBuf, Path}; 8 | use std::process::Command; 9 | 10 | use gtk::prelude::*; 11 | use log::{debug, warn, error}; 12 | use pathbuftools::PathBufTools; 13 | 14 | use crate::assets::Assets; 15 | use crate::input::{InputFile, Config}; 16 | use crate::markdown::RenderedContent; 17 | use crate::ui::action::{Action, Keymaps}; 18 | use crate::ui::browser::Browser; 19 | use crate::ui::dialogs::open_help_dialog; 20 | 21 | /// The container for all the GTK widgets of the app -- window, webview, etc. 22 | /// All of these are reference-counted, so should be cheap to clone. 23 | /// 24 | #[derive(Clone)] 25 | pub struct App { 26 | window: gtk::Window, 27 | browser: Browser, 28 | assets: Assets, 29 | filename: PathBuf, 30 | config: Config, 31 | } 32 | 33 | impl App { 34 | /// Construct a new app. Input params: 35 | /// 36 | /// - input_file: Used as the window title and for other actions on the file. 37 | /// - assets: Encapsulates the HTML layout that will be wrapping the rendered markdown. 38 | /// 39 | /// Initialization could fail due to a `WebContext` failure. 40 | /// 41 | pub fn init(config: Config, input_file: InputFile, assets: Assets) -> anyhow::Result { 42 | let window = gtk::Window::new(gtk::WindowType::Toplevel); 43 | window.set_default_size(1024, 768); 44 | 45 | if let Ok(asset_path) = assets.output_path() { 46 | if let Ok(icon) = gdk_pixbuf::Pixbuf::from_file(asset_path.join("icon.png")) { 47 | window.set_icon(Some(&icon)); 48 | } 49 | } 50 | 51 | let title = match &input_file { 52 | InputFile::Filesystem(p) => format!("{} - Quickmd", p.short_path().display()), 53 | InputFile::Stdin(_) => String::from("Quickmd"), 54 | }; 55 | window.set_title(&title); 56 | 57 | let browser = Browser::new(config.clone())?; 58 | browser.attach_to(&window); 59 | 60 | Ok(App { window, browser, assets, config, filename: input_file.path().to_path_buf() }) 61 | } 62 | 63 | /// Start listening to events from the `ui_receiver` and trigger the relevant methods on the 64 | /// `App`. Doesn't block. 65 | /// 66 | pub fn init_render_loop(&self, ui_receiver: glib::Receiver) { 67 | let mut app_clone = self.clone(); 68 | 69 | ui_receiver.attach(None, move |event| { 70 | match event { 71 | Event::LoadHtml(content) => { 72 | app_clone.load_content(&content). 73 | unwrap_or_else(|e| warn!("Couldn't update HTML: {}", e)) 74 | }, 75 | Event::Reload => app_clone.reload(), 76 | } 77 | glib::Continue(true) 78 | }); 79 | } 80 | 81 | /// Actually start the UI, blocking the main thread. 82 | /// 83 | pub fn run(&mut self) { 84 | self.connect_events(); 85 | self.window.show_all(); 86 | 87 | gtk::main(); 88 | 89 | self.assets.clean_up(); 90 | } 91 | 92 | fn load_content(&mut self, content: &RenderedContent) -> anyhow::Result<()> { 93 | let page_state = self.browser.get_page_state(); 94 | let output_path = self.assets.build(content, &page_state)?; 95 | 96 | debug!("Loading HTML:"); 97 | debug!(" > output_path = {}", output_path.display()); 98 | 99 | self.browser.load_uri(&format!("file://{}", output_path.display())); 100 | Ok(()) 101 | } 102 | 103 | fn reload(&self) { 104 | self.browser.reload(); 105 | } 106 | 107 | fn connect_events(&self) { 108 | let filename = self.filename.clone(); 109 | let editor_command = self.config.editor_command.clone(); 110 | 111 | let mut keymaps = Keymaps::default(); 112 | keymaps.add_config_mappings(&self.config.mappings).unwrap_or_else(|e| { 113 | error!("Mapping parsing error: {}", e); 114 | }); 115 | 116 | // Key presses mapped to repeatable events: 117 | let browser = self.browser.clone(); 118 | let keymaps_clone = keymaps.clone(); 119 | self.window.connect_key_press_event(move |_window, event| { 120 | let keyval = event.keyval(); 121 | let keystate = event.state(); 122 | 123 | match keymaps_clone.get_action(keystate, keyval) { 124 | Action::SmallScrollDown => browser.execute_js("window.scrollBy(0, 70)"), 125 | Action::BigScrollDown => browser.execute_js("window.scrollBy(0, 250)"), 126 | Action::SmallScrollUp => browser.execute_js("window.scrollBy(0, -70)"), 127 | Action::BigScrollUp => browser.execute_js("window.scrollBy(0, -250)"), 128 | Action::ScrollToTop => browser.execute_js("window.scroll({top: 0})"), 129 | Action::ScrollToBottom => { 130 | browser.execute_js("window.scroll({top: document.body.scrollHeight})") 131 | }, 132 | _ => (), 133 | } 134 | Inhibit(false) 135 | }); 136 | 137 | // Key releases mapped to one-time events: 138 | let browser = self.browser.clone(); 139 | let keymaps_clone = keymaps.clone(); 140 | self.window.connect_key_release_event(move |window, event| { 141 | let keyval = event.keyval(); 142 | let keystate = event.state(); 143 | 144 | match keymaps_clone.get_action(keystate, keyval) { 145 | Action::LaunchEditor => { 146 | debug!("Launching an editor"); 147 | launch_editor(&editor_command, &filename); 148 | }, 149 | Action::ExecEditor => { 150 | debug!("Exec-ing into an editor"); 151 | exec_editor(&editor_command, &filename); 152 | }, 153 | Action::ZoomIn => browser.zoom_in(), 154 | Action::ZoomOut => browser.zoom_out(), 155 | Action::ZoomReset => browser.zoom_reset(), 156 | Action::ShowHelp => { open_help_dialog(window); }, 157 | Action::Quit => gtk::main_quit(), 158 | _ => (), 159 | } 160 | Inhibit(false) 161 | }); 162 | 163 | // On Ctrl+Scroll, zoom: 164 | let browser = self.browser.clone(); 165 | self.window.connect_scroll_event(move |_window, event| { 166 | if event.state().contains(gdk::ModifierType::CONTROL_MASK) { 167 | match event.direction() { 168 | gdk::ScrollDirection::Up => browser.zoom_in(), 169 | gdk::ScrollDirection::Down => browser.zoom_out(), 170 | _ => (), 171 | } 172 | } 173 | 174 | Inhibit(false) 175 | }); 176 | 177 | self.window.connect_delete_event(|_, _| { 178 | gtk::main_quit(); 179 | Inhibit(false) 180 | }); 181 | } 182 | } 183 | 184 | /// Events that trigger UI changes. 185 | /// 186 | #[derive(Debug)] 187 | pub enum Event { 188 | /// Load the given content into the webview. 189 | LoadHtml(RenderedContent), 190 | 191 | /// Refresh the webview. 192 | Reload, 193 | } 194 | 195 | #[cfg(target_family="unix")] 196 | fn exec_editor(editor_command: &[String], file_path: &Path) { 197 | if let Some(mut editor) = build_editor_command(editor_command, file_path) { 198 | gtk::main_quit(); 199 | 200 | use std::os::unix::process::CommandExt; 201 | 202 | let _ = editor.exec(); 203 | } 204 | } 205 | 206 | #[cfg(not(target_family="unix"))] 207 | fn exec_editor(_editor_command: &[String], _filename_string: &Path) { 208 | warn!("Not on a UNIX system, can't exec to a text editor"); 209 | } 210 | 211 | fn launch_editor(editor_command: &[String], file_path: &Path) { 212 | if let Some(mut editor) = build_editor_command(editor_command, file_path) { 213 | if let Err(e) = editor.spawn() { 214 | warn!("Couldn't launch editor ({:?}): {}", editor_command, e); 215 | } 216 | } 217 | } 218 | 219 | fn build_editor_command(editor_command: &[String], file_path: &Path) -> Option { 220 | let executable = editor_command.get(0).or_else(|| { 221 | warn!("No \"editor\" defined in the config ({})", Config::yaml_path().display()); 222 | None 223 | })?; 224 | 225 | let mut command = Command::new(executable); 226 | 227 | for arg in editor_command.iter().skip(1) { 228 | if arg == "{path}" { 229 | command.arg(file_path); 230 | } else { 231 | command.arg(arg); 232 | } 233 | } 234 | 235 | Some(command) 236 | } 237 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | //! Input and config handling. 2 | //! 3 | //! This includes command-line options and settings from the YAML config. Potentially the place to 4 | //! handle any other type of configuration and input to the application. 5 | 6 | use std::fs::File; 7 | use std::io; 8 | use std::path::{PathBuf, Path}; 9 | use std::rc::Rc; 10 | 11 | use anyhow::anyhow; 12 | use directories::ProjectDirs; 13 | use log::{debug, error}; 14 | use serde::{Serialize, Deserialize}; 15 | use structopt::StructOpt; 16 | use tempfile::NamedTempFile; 17 | 18 | use crate::assets::HIGHLIGHT_JS_VERSION; 19 | use crate::ui::action::Action; 20 | 21 | /// Command-line options. Managed by [`structopt`]. 22 | #[derive(Debug, StructOpt)] 23 | pub struct Options { 24 | /// Activates debug logging 25 | #[structopt(short, long)] 26 | pub debug: bool, 27 | 28 | /// Markdown file to render. Use "-" to read markdown from STDIN (implies --no-watch). If not 29 | /// provided, the app will launch a file picker 30 | #[structopt(name = "input-file.md", parse(from_os_str))] 31 | pub input_file: Option, 32 | 33 | /// Disables watching file for changes 34 | #[structopt(long = "no-watch", parse(from_flag = std::ops::Not::not))] 35 | pub watch: bool, 36 | 37 | /// Builds output HTML and other assets in the given directory instead of in a tempdir. 38 | /// Will be created if it doesn't exist. Not deleted on application exit. 39 | #[structopt(long = "output", name = "directory")] 40 | pub output_dir: Option, 41 | 42 | /// Creates a configuration file for later editing if one doesn't exist. Exits when done. 43 | #[structopt(long)] 44 | pub install_default_config: bool, 45 | } 46 | 47 | impl Options { 48 | /// Creates a new instance by parsing input args. Apart from just running [`structopt`]'s 49 | /// initialization, it also adds some additional information to the description that depends on 50 | /// the current environment. 51 | /// 52 | pub fn build() -> Self { 53 | let description = &[ 54 | "A simple self-contained markdown previewer. ", 55 | "", 56 | &format!("Code highlighting via highlight.js version {}", HIGHLIGHT_JS_VERSION), 57 | "", 58 | &format!("Edit configuration in: {}", Config::yaml_path().display()), 59 | &format!("Add custom CSS in: {}", Config::css_path().display()), 60 | ].join("\n"); 61 | 62 | let options_app = Options::clap(). 63 | long_about(description.as_str()); 64 | 65 | Options::from_clap(&options_app.get_matches()) 66 | } 67 | 68 | /// Start logging based on input flags. 69 | /// 70 | /// With `--debug`: 71 | /// - All logs 72 | /// - Timestamps 73 | /// - Module path context 74 | /// 75 | /// Otherwise: 76 | /// - Only warnings and errors 77 | /// - Minimal formatting 78 | /// 79 | pub fn init_logging(&self) { 80 | if self.debug { 81 | env_logger::builder(). 82 | filter_level(log::LevelFilter::Debug). 83 | init(); 84 | } else { 85 | env_logger::builder(). 86 | format_module_path(false). 87 | format_timestamp(None). 88 | filter_level(log::LevelFilter::Warn). 89 | init(); 90 | } 91 | } 92 | } 93 | 94 | /// Configuration that controls the behaviour of the app. Saved in a file in the standard app 95 | /// config directory named "config.yaml". 96 | /// 97 | #[derive(Debug, Clone, Serialize, Deserialize)] 98 | #[serde(default)] 99 | pub struct Config { 100 | /// The zoom level of the page. Defaults to 1.0, but on a HiDPI screen should be set to a 101 | /// higher value. 102 | /// 103 | pub zoom: f64, 104 | 105 | /// The external editor to launch when editing is requested. It defaults to an empty vector, 106 | /// which will produce a command-line warning when it's attempted. 107 | /// 108 | pub editor_command: Vec, 109 | 110 | /// Custom mappings. See documentation of [`MappingDefinition`] for details. 111 | pub mappings: Vec, 112 | } 113 | 114 | /// A single description of a mapping from a keybinding to a UI action. The fields `key_char` and 115 | /// `key_name` are exclusive, which is validated in [`crate::ui::action::Keymaps`]. 116 | /// 117 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 118 | #[serde(default)] 119 | pub struct MappingDefinition { 120 | /// A descriptor, passed along to [`gdk::keys::Key::from_unicode`] 121 | pub key_char: Option, 122 | 123 | /// A descriptor, passed along to [`gdk::keys::Key::from_name`] 124 | pub key_name: Option, 125 | 126 | /// A list of key modifiers, either "control", "shift", or "alt" 127 | pub mods: Vec, 128 | 129 | /// The action mapped to this key combination 130 | pub action: Action, 131 | } 132 | 133 | impl Default for Config { 134 | fn default() -> Self { 135 | Self { zoom: 1.0, editor_command: Vec::new(), mappings: Vec::new() } 136 | } 137 | } 138 | 139 | impl Config { 140 | /// Loads the config from its standard location, or returns None if the file couldn't be found 141 | /// or is invalid. 142 | /// 143 | pub fn load() -> Option { 144 | let yaml_path = Self::yaml_path(); 145 | let config_file = File::open(&yaml_path).or_else(|_| { 146 | // If "config.yaml" is missing, check for "config.yml" just in case 147 | let yml_path = yaml_path.with_extension("yml"); 148 | File::open(&yml_path).map_err(|_| { 149 | debug!("Didn't find config file: {} (or {})", yaml_path.display(), yml_path.display()); 150 | }) 151 | }).ok()?; 152 | 153 | serde_yaml::from_reader(&config_file).map_err(|e| { 154 | error!("Couldn't parse YAML config file: {}", e); 155 | }).ok() 156 | } 157 | 158 | /// Gets the path to the default YAML config in the standard config location. 159 | pub fn yaml_path() -> PathBuf { 160 | ProjectDirs::from("com", "andrewradev", "quickmd"). 161 | map(|pd| pd.config_dir().join("config.yaml")). 162 | unwrap_or_else(|| PathBuf::from("./quickmd.yaml")) 163 | } 164 | 165 | /// Gets the path to the custom CSS config in the standard config location. 166 | pub fn css_path() -> PathBuf { 167 | ProjectDirs::from("com", "andrewradev", "quickmd"). 168 | map(|pd| pd.config_dir().join("custom.css")). 169 | unwrap_or_else(|| PathBuf::from("./quickmd.css")) 170 | } 171 | 172 | /// Attempts to install a config file with defaults. Returns an error if a file already exists 173 | /// in the expected location. 174 | /// 175 | pub fn try_install_default() -> anyhow::Result<()> { 176 | let yaml_path = Config::yaml_path(); 177 | 178 | if yaml_path.exists() { 179 | Err(anyhow!("An existing file was found at: {}\n\ 180 | If you want to replace it, please delete it first", yaml_path.display())) 181 | } else { 182 | yaml_path.parent().map(std::fs::create_dir_all); 183 | Ok(std::fs::write(yaml_path, include_str!("../res/default_config.yaml"))?) 184 | } 185 | } 186 | } 187 | 188 | /// The file used as input to the application. Could be an existing file path or STDIN. 189 | #[derive(Debug, Clone)] 190 | pub enum InputFile { 191 | /// A path representing a file on the filesystem. 192 | Filesystem(PathBuf), 193 | 194 | /// STDIN, written to a named tempfile. It's packaged in an Rc, so we can safely clone the 195 | /// structure. 196 | Stdin(Rc), 197 | } 198 | 199 | impl InputFile { 200 | /// Construct an `InputFile` based on the given path. 201 | /// 202 | /// If the path is "-", the given `contents` are assumed to be STDIN, they're written down in a 203 | /// tempfile and returned. Otherwise, that parameter is ignored. 204 | /// 205 | pub fn from(path: &Path, mut contents: impl io::Read) -> anyhow::Result { 206 | if path == PathBuf::from("-") { 207 | let mut tempfile = NamedTempFile::new()?; 208 | io::copy(&mut contents, &mut tempfile)?; 209 | 210 | Ok(InputFile::Stdin(Rc::new(tempfile))) 211 | } else { 212 | Ok(InputFile::Filesystem(path.to_path_buf())) 213 | } 214 | } 215 | 216 | /// Get the path to a real file on the filesystem. 217 | pub fn path(&self) -> &Path { 218 | match self { 219 | Self::Filesystem(path_buf) => path_buf.as_path(), 220 | Self::Stdin(tempfile) => tempfile.path(), 221 | } 222 | } 223 | 224 | /// Only true if the struct represents an actual file. 225 | pub fn is_real_file(&self) -> bool { 226 | matches!(self, Self::Filesystem(_)) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Andrew Radev 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.96" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" 19 | 20 | [[package]] 21 | name = "arrayref" 22 | version = "0.3.9" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 25 | 26 | [[package]] 27 | name = "arrayvec" 28 | version = "0.5.2" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 31 | 32 | [[package]] 33 | name = "atk" 34 | version = "0.15.1" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd" 37 | dependencies = [ 38 | "atk-sys", 39 | "bitflags 1.3.2", 40 | "glib", 41 | "libc", 42 | ] 43 | 44 | [[package]] 45 | name = "atk-sys" 46 | version = "0.15.1" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6" 49 | dependencies = [ 50 | "glib-sys", 51 | "gobject-sys", 52 | "libc", 53 | "system-deps 6.2.2", 54 | ] 55 | 56 | [[package]] 57 | name = "autocfg" 58 | version = "1.0.1" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 61 | 62 | [[package]] 63 | name = "base64" 64 | version = "0.13.1" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 67 | 68 | [[package]] 69 | name = "bitflags" 70 | version = "1.3.2" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 73 | 74 | [[package]] 75 | name = "bitflags" 76 | version = "2.8.0" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 79 | 80 | [[package]] 81 | name = "blake2b_simd" 82 | version = "0.5.11" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" 85 | dependencies = [ 86 | "arrayref", 87 | "arrayvec", 88 | "constant_time_eq", 89 | ] 90 | 91 | [[package]] 92 | name = "cairo-rs" 93 | version = "0.15.12" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc" 96 | dependencies = [ 97 | "bitflags 1.3.2", 98 | "cairo-sys-rs", 99 | "glib", 100 | "libc", 101 | "thiserror", 102 | ] 103 | 104 | [[package]] 105 | name = "cairo-sys-rs" 106 | version = "0.15.1" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8" 109 | dependencies = [ 110 | "glib-sys", 111 | "libc", 112 | "system-deps 6.2.2", 113 | ] 114 | 115 | [[package]] 116 | name = "cfg-expr" 117 | version = "0.9.1" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7" 120 | dependencies = [ 121 | "smallvec", 122 | ] 123 | 124 | [[package]] 125 | name = "cfg-expr" 126 | version = "0.15.8" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" 129 | dependencies = [ 130 | "smallvec", 131 | "target-lexicon", 132 | ] 133 | 134 | [[package]] 135 | name = "cfg-if" 136 | version = "0.1.10" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 139 | 140 | [[package]] 141 | name = "cfg-if" 142 | version = "1.0.0" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 145 | 146 | [[package]] 147 | name = "claim" 148 | version = "0.5.0" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "f81099d6bb72e1df6d50bb2347224b666a670912bb7f06dbe867a4a070ab3ce8" 151 | dependencies = [ 152 | "autocfg", 153 | ] 154 | 155 | [[package]] 156 | name = "clap" 157 | version = "2.34.0" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" 160 | dependencies = [ 161 | "bitflags 1.3.2", 162 | "textwrap", 163 | "unicode-width", 164 | ] 165 | 166 | [[package]] 167 | name = "constant_time_eq" 168 | version = "0.1.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 171 | 172 | [[package]] 173 | name = "crossbeam-utils" 174 | version = "0.8.21" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 177 | 178 | [[package]] 179 | name = "directories" 180 | version = "4.0.1" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" 183 | dependencies = [ 184 | "dirs-sys", 185 | ] 186 | 187 | [[package]] 188 | name = "dirs" 189 | version = "1.0.5" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" 192 | dependencies = [ 193 | "libc", 194 | "redox_users 0.3.5", 195 | "winapi 0.3.9", 196 | ] 197 | 198 | [[package]] 199 | name = "dirs-sys" 200 | version = "0.3.7" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 203 | dependencies = [ 204 | "libc", 205 | "redox_users 0.4.6", 206 | "winapi 0.3.9", 207 | ] 208 | 209 | [[package]] 210 | name = "env_logger" 211 | version = "0.10.2" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" 214 | dependencies = [ 215 | "log", 216 | ] 217 | 218 | [[package]] 219 | name = "equivalent" 220 | version = "1.0.2" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 223 | 224 | [[package]] 225 | name = "errno" 226 | version = "0.3.10" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 229 | dependencies = [ 230 | "libc", 231 | "windows-sys", 232 | ] 233 | 234 | [[package]] 235 | name = "fastrand" 236 | version = "2.3.0" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 239 | 240 | [[package]] 241 | name = "field-offset" 242 | version = "0.3.6" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" 245 | dependencies = [ 246 | "memoffset", 247 | "rustc_version", 248 | ] 249 | 250 | [[package]] 251 | name = "filetime" 252 | version = "0.2.25" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" 255 | dependencies = [ 256 | "cfg-if 1.0.0", 257 | "libc", 258 | "libredox", 259 | "windows-sys", 260 | ] 261 | 262 | [[package]] 263 | name = "fsevent" 264 | version = "0.4.0" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" 267 | dependencies = [ 268 | "bitflags 1.3.2", 269 | "fsevent-sys", 270 | ] 271 | 272 | [[package]] 273 | name = "fsevent-sys" 274 | version = "2.0.1" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" 277 | dependencies = [ 278 | "libc", 279 | ] 280 | 281 | [[package]] 282 | name = "fuchsia-zircon" 283 | version = "0.3.3" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" 286 | dependencies = [ 287 | "bitflags 1.3.2", 288 | "fuchsia-zircon-sys", 289 | ] 290 | 291 | [[package]] 292 | name = "fuchsia-zircon-sys" 293 | version = "0.3.3" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" 296 | 297 | [[package]] 298 | name = "futures-channel" 299 | version = "0.3.31" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 302 | dependencies = [ 303 | "futures-core", 304 | ] 305 | 306 | [[package]] 307 | name = "futures-core" 308 | version = "0.3.31" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 311 | 312 | [[package]] 313 | name = "futures-executor" 314 | version = "0.3.31" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 317 | dependencies = [ 318 | "futures-core", 319 | "futures-task", 320 | "futures-util", 321 | ] 322 | 323 | [[package]] 324 | name = "futures-io" 325 | version = "0.3.31" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 328 | 329 | [[package]] 330 | name = "futures-task" 331 | version = "0.3.31" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 334 | 335 | [[package]] 336 | name = "futures-util" 337 | version = "0.3.31" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 340 | dependencies = [ 341 | "futures-core", 342 | "futures-task", 343 | "pin-project-lite", 344 | "pin-utils", 345 | "slab", 346 | ] 347 | 348 | [[package]] 349 | name = "gdk" 350 | version = "0.15.4" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8" 353 | dependencies = [ 354 | "bitflags 1.3.2", 355 | "cairo-rs", 356 | "gdk-pixbuf", 357 | "gdk-sys", 358 | "gio", 359 | "glib", 360 | "libc", 361 | "pango", 362 | ] 363 | 364 | [[package]] 365 | name = "gdk-pixbuf" 366 | version = "0.15.11" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a" 369 | dependencies = [ 370 | "bitflags 1.3.2", 371 | "gdk-pixbuf-sys", 372 | "gio", 373 | "glib", 374 | "libc", 375 | ] 376 | 377 | [[package]] 378 | name = "gdk-pixbuf-sys" 379 | version = "0.15.10" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7" 382 | dependencies = [ 383 | "gio-sys", 384 | "glib-sys", 385 | "gobject-sys", 386 | "libc", 387 | "system-deps 6.2.2", 388 | ] 389 | 390 | [[package]] 391 | name = "gdk-sys" 392 | version = "0.15.1" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88" 395 | dependencies = [ 396 | "cairo-sys-rs", 397 | "gdk-pixbuf-sys", 398 | "gio-sys", 399 | "glib-sys", 400 | "gobject-sys", 401 | "libc", 402 | "pango-sys", 403 | "pkg-config", 404 | "system-deps 6.2.2", 405 | ] 406 | 407 | [[package]] 408 | name = "getrandom" 409 | version = "0.1.16" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 412 | dependencies = [ 413 | "cfg-if 1.0.0", 414 | "libc", 415 | "wasi 0.9.0+wasi-snapshot-preview1", 416 | ] 417 | 418 | [[package]] 419 | name = "getrandom" 420 | version = "0.2.15" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 423 | dependencies = [ 424 | "cfg-if 1.0.0", 425 | "libc", 426 | "wasi 0.11.0+wasi-snapshot-preview1", 427 | ] 428 | 429 | [[package]] 430 | name = "getrandom" 431 | version = "0.3.1" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 434 | dependencies = [ 435 | "cfg-if 1.0.0", 436 | "libc", 437 | "wasi 0.13.3+wasi-0.2.2", 438 | "windows-targets", 439 | ] 440 | 441 | [[package]] 442 | name = "gio" 443 | version = "0.15.12" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b" 446 | dependencies = [ 447 | "bitflags 1.3.2", 448 | "futures-channel", 449 | "futures-core", 450 | "futures-io", 451 | "gio-sys", 452 | "glib", 453 | "libc", 454 | "once_cell", 455 | "thiserror", 456 | ] 457 | 458 | [[package]] 459 | name = "gio-sys" 460 | version = "0.15.10" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d" 463 | dependencies = [ 464 | "glib-sys", 465 | "gobject-sys", 466 | "libc", 467 | "system-deps 6.2.2", 468 | "winapi 0.3.9", 469 | ] 470 | 471 | [[package]] 472 | name = "glib" 473 | version = "0.15.12" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d" 476 | dependencies = [ 477 | "bitflags 1.3.2", 478 | "futures-channel", 479 | "futures-core", 480 | "futures-executor", 481 | "futures-task", 482 | "glib-macros", 483 | "glib-sys", 484 | "gobject-sys", 485 | "libc", 486 | "once_cell", 487 | "smallvec", 488 | "thiserror", 489 | ] 490 | 491 | [[package]] 492 | name = "glib-macros" 493 | version = "0.15.13" 494 | source = "registry+https://github.com/rust-lang/crates.io-index" 495 | checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" 496 | dependencies = [ 497 | "anyhow", 498 | "heck 0.4.1", 499 | "proc-macro-crate", 500 | "proc-macro-error", 501 | "proc-macro2", 502 | "quote", 503 | "syn 1.0.109", 504 | ] 505 | 506 | [[package]] 507 | name = "glib-sys" 508 | version = "0.15.10" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" 511 | dependencies = [ 512 | "libc", 513 | "system-deps 6.2.2", 514 | ] 515 | 516 | [[package]] 517 | name = "gobject-sys" 518 | version = "0.15.10" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" 521 | dependencies = [ 522 | "glib-sys", 523 | "libc", 524 | "system-deps 6.2.2", 525 | ] 526 | 527 | [[package]] 528 | name = "gtk" 529 | version = "0.15.5" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0" 532 | dependencies = [ 533 | "atk", 534 | "bitflags 1.3.2", 535 | "cairo-rs", 536 | "field-offset", 537 | "futures-channel", 538 | "gdk", 539 | "gdk-pixbuf", 540 | "gio", 541 | "glib", 542 | "gtk-sys", 543 | "gtk3-macros", 544 | "libc", 545 | "once_cell", 546 | "pango", 547 | "pkg-config", 548 | ] 549 | 550 | [[package]] 551 | name = "gtk-sys" 552 | version = "0.15.3" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84" 555 | dependencies = [ 556 | "atk-sys", 557 | "cairo-sys-rs", 558 | "gdk-pixbuf-sys", 559 | "gdk-sys", 560 | "gio-sys", 561 | "glib-sys", 562 | "gobject-sys", 563 | "libc", 564 | "pango-sys", 565 | "system-deps 6.2.2", 566 | ] 567 | 568 | [[package]] 569 | name = "gtk3-macros" 570 | version = "0.15.6" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" 573 | dependencies = [ 574 | "anyhow", 575 | "proc-macro-crate", 576 | "proc-macro-error", 577 | "proc-macro2", 578 | "quote", 579 | "syn 1.0.109", 580 | ] 581 | 582 | [[package]] 583 | name = "hashbrown" 584 | version = "0.15.2" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 587 | 588 | [[package]] 589 | name = "heck" 590 | version = "0.3.3" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 593 | dependencies = [ 594 | "unicode-segmentation", 595 | ] 596 | 597 | [[package]] 598 | name = "heck" 599 | version = "0.4.1" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 602 | 603 | [[package]] 604 | name = "heck" 605 | version = "0.5.0" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 608 | 609 | [[package]] 610 | name = "indexmap" 611 | version = "2.7.1" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 614 | dependencies = [ 615 | "equivalent", 616 | "hashbrown", 617 | ] 618 | 619 | [[package]] 620 | name = "inotify" 621 | version = "0.7.1" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "4816c66d2c8ae673df83366c18341538f234a26d65a9ecea5c348b453ac1d02f" 624 | dependencies = [ 625 | "bitflags 1.3.2", 626 | "inotify-sys", 627 | "libc", 628 | ] 629 | 630 | [[package]] 631 | name = "inotify-sys" 632 | version = "0.1.5" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" 635 | dependencies = [ 636 | "libc", 637 | ] 638 | 639 | [[package]] 640 | name = "iovec" 641 | version = "0.1.4" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" 644 | dependencies = [ 645 | "libc", 646 | ] 647 | 648 | [[package]] 649 | name = "itoa" 650 | version = "1.0.14" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 653 | 654 | [[package]] 655 | name = "javascriptcore-rs" 656 | version = "0.16.0" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c" 659 | dependencies = [ 660 | "bitflags 1.3.2", 661 | "glib", 662 | "javascriptcore-rs-sys", 663 | ] 664 | 665 | [[package]] 666 | name = "javascriptcore-rs-sys" 667 | version = "0.4.0" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c" 670 | dependencies = [ 671 | "glib-sys", 672 | "gobject-sys", 673 | "libc", 674 | "system-deps 5.0.0", 675 | ] 676 | 677 | [[package]] 678 | name = "kernel32-sys" 679 | version = "0.2.2" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" 682 | dependencies = [ 683 | "winapi 0.2.8", 684 | "winapi-build", 685 | ] 686 | 687 | [[package]] 688 | name = "lazy_static" 689 | version = "1.5.0" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 692 | 693 | [[package]] 694 | name = "lazycell" 695 | version = "1.3.0" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" 698 | 699 | [[package]] 700 | name = "libc" 701 | version = "0.2.169" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 704 | 705 | [[package]] 706 | name = "libredox" 707 | version = "0.1.3" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 710 | dependencies = [ 711 | "bitflags 2.8.0", 712 | "libc", 713 | "redox_syscall 0.5.8", 714 | ] 715 | 716 | [[package]] 717 | name = "linux-raw-sys" 718 | version = "0.4.15" 719 | source = "registry+https://github.com/rust-lang/crates.io-index" 720 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 721 | 722 | [[package]] 723 | name = "log" 724 | version = "0.4.25" 725 | source = "registry+https://github.com/rust-lang/crates.io-index" 726 | checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" 727 | 728 | [[package]] 729 | name = "memchr" 730 | version = "2.7.4" 731 | source = "registry+https://github.com/rust-lang/crates.io-index" 732 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 733 | 734 | [[package]] 735 | name = "memoffset" 736 | version = "0.9.1" 737 | source = "registry+https://github.com/rust-lang/crates.io-index" 738 | checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 739 | dependencies = [ 740 | "autocfg", 741 | ] 742 | 743 | [[package]] 744 | name = "mio" 745 | version = "0.6.23" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" 748 | dependencies = [ 749 | "cfg-if 0.1.10", 750 | "fuchsia-zircon", 751 | "fuchsia-zircon-sys", 752 | "iovec", 753 | "kernel32-sys", 754 | "libc", 755 | "log", 756 | "miow", 757 | "net2", 758 | "slab", 759 | "winapi 0.2.8", 760 | ] 761 | 762 | [[package]] 763 | name = "mio-extras" 764 | version = "2.0.6" 765 | source = "registry+https://github.com/rust-lang/crates.io-index" 766 | checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" 767 | dependencies = [ 768 | "lazycell", 769 | "log", 770 | "mio", 771 | "slab", 772 | ] 773 | 774 | [[package]] 775 | name = "miow" 776 | version = "0.2.2" 777 | source = "registry+https://github.com/rust-lang/crates.io-index" 778 | checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" 779 | dependencies = [ 780 | "kernel32-sys", 781 | "net2", 782 | "winapi 0.2.8", 783 | "ws2_32-sys", 784 | ] 785 | 786 | [[package]] 787 | name = "net2" 788 | version = "0.2.39" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" 791 | dependencies = [ 792 | "cfg-if 0.1.10", 793 | "libc", 794 | "winapi 0.3.9", 795 | ] 796 | 797 | [[package]] 798 | name = "notify" 799 | version = "4.0.18" 800 | source = "registry+https://github.com/rust-lang/crates.io-index" 801 | checksum = "b72dd35279a5dc895a30965e247b0961ba36c233dc48454a2de8ccd459f1afd3" 802 | dependencies = [ 803 | "bitflags 1.3.2", 804 | "filetime", 805 | "fsevent", 806 | "fsevent-sys", 807 | "inotify", 808 | "libc", 809 | "mio", 810 | "mio-extras", 811 | "walkdir", 812 | "winapi 0.3.9", 813 | ] 814 | 815 | [[package]] 816 | name = "once_cell" 817 | version = "1.20.3" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 820 | 821 | [[package]] 822 | name = "pango" 823 | version = "0.15.10" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f" 826 | dependencies = [ 827 | "bitflags 1.3.2", 828 | "glib", 829 | "libc", 830 | "once_cell", 831 | "pango-sys", 832 | ] 833 | 834 | [[package]] 835 | name = "pango-sys" 836 | version = "0.15.10" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa" 839 | dependencies = [ 840 | "glib-sys", 841 | "gobject-sys", 842 | "libc", 843 | "system-deps 6.2.2", 844 | ] 845 | 846 | [[package]] 847 | name = "pathbuftools" 848 | version = "0.1.2" 849 | source = "registry+https://github.com/rust-lang/crates.io-index" 850 | checksum = "00f002a88874c85e30d4e133baae43cf382ceecf025f9493ce23eb381fcc922e" 851 | dependencies = [ 852 | "dirs", 853 | ] 854 | 855 | [[package]] 856 | name = "pin-project-lite" 857 | version = "0.2.16" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 860 | 861 | [[package]] 862 | name = "pin-utils" 863 | version = "0.1.0" 864 | source = "registry+https://github.com/rust-lang/crates.io-index" 865 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 866 | 867 | [[package]] 868 | name = "pkg-config" 869 | version = "0.3.31" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" 872 | 873 | [[package]] 874 | name = "proc-macro-crate" 875 | version = "1.3.1" 876 | source = "registry+https://github.com/rust-lang/crates.io-index" 877 | checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" 878 | dependencies = [ 879 | "once_cell", 880 | "toml_edit 0.19.15", 881 | ] 882 | 883 | [[package]] 884 | name = "proc-macro-error" 885 | version = "1.0.4" 886 | source = "registry+https://github.com/rust-lang/crates.io-index" 887 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 888 | dependencies = [ 889 | "proc-macro-error-attr", 890 | "proc-macro2", 891 | "quote", 892 | "syn 1.0.109", 893 | "version_check", 894 | ] 895 | 896 | [[package]] 897 | name = "proc-macro-error-attr" 898 | version = "1.0.4" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 901 | dependencies = [ 902 | "proc-macro2", 903 | "quote", 904 | "version_check", 905 | ] 906 | 907 | [[package]] 908 | name = "proc-macro2" 909 | version = "1.0.93" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 912 | dependencies = [ 913 | "unicode-ident", 914 | ] 915 | 916 | [[package]] 917 | name = "pulldown-cmark" 918 | version = "0.9.6" 919 | source = "registry+https://github.com/rust-lang/crates.io-index" 920 | checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" 921 | dependencies = [ 922 | "bitflags 2.8.0", 923 | "memchr", 924 | "unicase", 925 | ] 926 | 927 | [[package]] 928 | name = "quickmd" 929 | version = "0.7.1" 930 | dependencies = [ 931 | "anyhow", 932 | "claim", 933 | "directories", 934 | "env_logger", 935 | "gdk", 936 | "gdk-pixbuf", 937 | "gio", 938 | "glib", 939 | "gtk", 940 | "log", 941 | "notify", 942 | "pathbuftools", 943 | "pulldown-cmark", 944 | "regex", 945 | "serde", 946 | "serde_json", 947 | "serde_yaml", 948 | "structopt", 949 | "tempfile", 950 | "webkit2gtk", 951 | ] 952 | 953 | [[package]] 954 | name = "quote" 955 | version = "1.0.38" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 958 | dependencies = [ 959 | "proc-macro2", 960 | ] 961 | 962 | [[package]] 963 | name = "redox_syscall" 964 | version = "0.1.57" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" 967 | 968 | [[package]] 969 | name = "redox_syscall" 970 | version = "0.5.8" 971 | source = "registry+https://github.com/rust-lang/crates.io-index" 972 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 973 | dependencies = [ 974 | "bitflags 2.8.0", 975 | ] 976 | 977 | [[package]] 978 | name = "redox_users" 979 | version = "0.3.5" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" 982 | dependencies = [ 983 | "getrandom 0.1.16", 984 | "redox_syscall 0.1.57", 985 | "rust-argon2", 986 | ] 987 | 988 | [[package]] 989 | name = "redox_users" 990 | version = "0.4.6" 991 | source = "registry+https://github.com/rust-lang/crates.io-index" 992 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 993 | dependencies = [ 994 | "getrandom 0.2.15", 995 | "libredox", 996 | "thiserror", 997 | ] 998 | 999 | [[package]] 1000 | name = "regex" 1001 | version = "1.11.1" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1004 | dependencies = [ 1005 | "aho-corasick", 1006 | "memchr", 1007 | "regex-automata", 1008 | "regex-syntax", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "regex-automata" 1013 | version = "0.4.9" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1016 | dependencies = [ 1017 | "aho-corasick", 1018 | "memchr", 1019 | "regex-syntax", 1020 | ] 1021 | 1022 | [[package]] 1023 | name = "regex-syntax" 1024 | version = "0.8.5" 1025 | source = "registry+https://github.com/rust-lang/crates.io-index" 1026 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1027 | 1028 | [[package]] 1029 | name = "rust-argon2" 1030 | version = "0.8.3" 1031 | source = "registry+https://github.com/rust-lang/crates.io-index" 1032 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" 1033 | dependencies = [ 1034 | "base64", 1035 | "blake2b_simd", 1036 | "constant_time_eq", 1037 | "crossbeam-utils", 1038 | ] 1039 | 1040 | [[package]] 1041 | name = "rustc_version" 1042 | version = "0.4.1" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 1045 | dependencies = [ 1046 | "semver", 1047 | ] 1048 | 1049 | [[package]] 1050 | name = "rustix" 1051 | version = "0.38.44" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1054 | dependencies = [ 1055 | "bitflags 2.8.0", 1056 | "errno", 1057 | "libc", 1058 | "linux-raw-sys", 1059 | "windows-sys", 1060 | ] 1061 | 1062 | [[package]] 1063 | name = "ryu" 1064 | version = "1.0.19" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" 1067 | 1068 | [[package]] 1069 | name = "same-file" 1070 | version = "1.0.6" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1073 | dependencies = [ 1074 | "winapi-util", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "semver" 1079 | version = "1.0.25" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" 1082 | 1083 | [[package]] 1084 | name = "serde" 1085 | version = "1.0.218" 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" 1087 | checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" 1088 | dependencies = [ 1089 | "serde_derive", 1090 | ] 1091 | 1092 | [[package]] 1093 | name = "serde_derive" 1094 | version = "1.0.218" 1095 | source = "registry+https://github.com/rust-lang/crates.io-index" 1096 | checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" 1097 | dependencies = [ 1098 | "proc-macro2", 1099 | "quote", 1100 | "syn 2.0.98", 1101 | ] 1102 | 1103 | [[package]] 1104 | name = "serde_json" 1105 | version = "1.0.139" 1106 | source = "registry+https://github.com/rust-lang/crates.io-index" 1107 | checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" 1108 | dependencies = [ 1109 | "itoa", 1110 | "memchr", 1111 | "ryu", 1112 | "serde", 1113 | ] 1114 | 1115 | [[package]] 1116 | name = "serde_spanned" 1117 | version = "0.6.8" 1118 | source = "registry+https://github.com/rust-lang/crates.io-index" 1119 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 1120 | dependencies = [ 1121 | "serde", 1122 | ] 1123 | 1124 | [[package]] 1125 | name = "serde_yaml" 1126 | version = "0.9.34+deprecated" 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" 1128 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 1129 | dependencies = [ 1130 | "indexmap", 1131 | "itoa", 1132 | "ryu", 1133 | "serde", 1134 | "unsafe-libyaml", 1135 | ] 1136 | 1137 | [[package]] 1138 | name = "slab" 1139 | version = "0.4.9" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 1142 | dependencies = [ 1143 | "autocfg", 1144 | ] 1145 | 1146 | [[package]] 1147 | name = "smallvec" 1148 | version = "1.14.0" 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" 1150 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 1151 | 1152 | [[package]] 1153 | name = "soup2" 1154 | version = "0.2.1" 1155 | source = "registry+https://github.com/rust-lang/crates.io-index" 1156 | checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0" 1157 | dependencies = [ 1158 | "bitflags 1.3.2", 1159 | "gio", 1160 | "glib", 1161 | "libc", 1162 | "once_cell", 1163 | "soup2-sys", 1164 | ] 1165 | 1166 | [[package]] 1167 | name = "soup2-sys" 1168 | version = "0.2.0" 1169 | source = "registry+https://github.com/rust-lang/crates.io-index" 1170 | checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf" 1171 | dependencies = [ 1172 | "bitflags 1.3.2", 1173 | "gio-sys", 1174 | "glib-sys", 1175 | "gobject-sys", 1176 | "libc", 1177 | "system-deps 5.0.0", 1178 | ] 1179 | 1180 | [[package]] 1181 | name = "structopt" 1182 | version = "0.3.26" 1183 | source = "registry+https://github.com/rust-lang/crates.io-index" 1184 | checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" 1185 | dependencies = [ 1186 | "clap", 1187 | "lazy_static", 1188 | "structopt-derive", 1189 | ] 1190 | 1191 | [[package]] 1192 | name = "structopt-derive" 1193 | version = "0.4.18" 1194 | source = "registry+https://github.com/rust-lang/crates.io-index" 1195 | checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" 1196 | dependencies = [ 1197 | "heck 0.3.3", 1198 | "proc-macro-error", 1199 | "proc-macro2", 1200 | "quote", 1201 | "syn 1.0.109", 1202 | ] 1203 | 1204 | [[package]] 1205 | name = "syn" 1206 | version = "1.0.109" 1207 | source = "registry+https://github.com/rust-lang/crates.io-index" 1208 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 1209 | dependencies = [ 1210 | "proc-macro2", 1211 | "quote", 1212 | "unicode-ident", 1213 | ] 1214 | 1215 | [[package]] 1216 | name = "syn" 1217 | version = "2.0.98" 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" 1219 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 1220 | dependencies = [ 1221 | "proc-macro2", 1222 | "quote", 1223 | "unicode-ident", 1224 | ] 1225 | 1226 | [[package]] 1227 | name = "system-deps" 1228 | version = "5.0.0" 1229 | source = "registry+https://github.com/rust-lang/crates.io-index" 1230 | checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e" 1231 | dependencies = [ 1232 | "cfg-expr 0.9.1", 1233 | "heck 0.3.3", 1234 | "pkg-config", 1235 | "toml 0.5.11", 1236 | "version-compare 0.0.11", 1237 | ] 1238 | 1239 | [[package]] 1240 | name = "system-deps" 1241 | version = "6.2.2" 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" 1243 | checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" 1244 | dependencies = [ 1245 | "cfg-expr 0.15.8", 1246 | "heck 0.5.0", 1247 | "pkg-config", 1248 | "toml 0.8.20", 1249 | "version-compare 0.2.0", 1250 | ] 1251 | 1252 | [[package]] 1253 | name = "target-lexicon" 1254 | version = "0.12.16" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" 1257 | 1258 | [[package]] 1259 | name = "tempfile" 1260 | version = "3.17.1" 1261 | source = "registry+https://github.com/rust-lang/crates.io-index" 1262 | checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" 1263 | dependencies = [ 1264 | "cfg-if 1.0.0", 1265 | "fastrand", 1266 | "getrandom 0.3.1", 1267 | "once_cell", 1268 | "rustix", 1269 | "windows-sys", 1270 | ] 1271 | 1272 | [[package]] 1273 | name = "textwrap" 1274 | version = "0.11.0" 1275 | source = "registry+https://github.com/rust-lang/crates.io-index" 1276 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 1277 | dependencies = [ 1278 | "unicode-width", 1279 | ] 1280 | 1281 | [[package]] 1282 | name = "thiserror" 1283 | version = "1.0.69" 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" 1285 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1286 | dependencies = [ 1287 | "thiserror-impl", 1288 | ] 1289 | 1290 | [[package]] 1291 | name = "thiserror-impl" 1292 | version = "1.0.69" 1293 | source = "registry+https://github.com/rust-lang/crates.io-index" 1294 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1295 | dependencies = [ 1296 | "proc-macro2", 1297 | "quote", 1298 | "syn 2.0.98", 1299 | ] 1300 | 1301 | [[package]] 1302 | name = "toml" 1303 | version = "0.5.11" 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" 1305 | checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" 1306 | dependencies = [ 1307 | "serde", 1308 | ] 1309 | 1310 | [[package]] 1311 | name = "toml" 1312 | version = "0.8.20" 1313 | source = "registry+https://github.com/rust-lang/crates.io-index" 1314 | checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" 1315 | dependencies = [ 1316 | "serde", 1317 | "serde_spanned", 1318 | "toml_datetime", 1319 | "toml_edit 0.22.24", 1320 | ] 1321 | 1322 | [[package]] 1323 | name = "toml_datetime" 1324 | version = "0.6.8" 1325 | source = "registry+https://github.com/rust-lang/crates.io-index" 1326 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 1327 | dependencies = [ 1328 | "serde", 1329 | ] 1330 | 1331 | [[package]] 1332 | name = "toml_edit" 1333 | version = "0.19.15" 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" 1335 | checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" 1336 | dependencies = [ 1337 | "indexmap", 1338 | "toml_datetime", 1339 | "winnow 0.5.40", 1340 | ] 1341 | 1342 | [[package]] 1343 | name = "toml_edit" 1344 | version = "0.22.24" 1345 | source = "registry+https://github.com/rust-lang/crates.io-index" 1346 | checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" 1347 | dependencies = [ 1348 | "indexmap", 1349 | "serde", 1350 | "serde_spanned", 1351 | "toml_datetime", 1352 | "winnow 0.7.3", 1353 | ] 1354 | 1355 | [[package]] 1356 | name = "unicase" 1357 | version = "2.8.1" 1358 | source = "registry+https://github.com/rust-lang/crates.io-index" 1359 | checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 1360 | 1361 | [[package]] 1362 | name = "unicode-ident" 1363 | version = "1.0.17" 1364 | source = "registry+https://github.com/rust-lang/crates.io-index" 1365 | checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" 1366 | 1367 | [[package]] 1368 | name = "unicode-segmentation" 1369 | version = "1.12.0" 1370 | source = "registry+https://github.com/rust-lang/crates.io-index" 1371 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1372 | 1373 | [[package]] 1374 | name = "unicode-width" 1375 | version = "0.1.14" 1376 | source = "registry+https://github.com/rust-lang/crates.io-index" 1377 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1378 | 1379 | [[package]] 1380 | name = "unsafe-libyaml" 1381 | version = "0.2.11" 1382 | source = "registry+https://github.com/rust-lang/crates.io-index" 1383 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 1384 | 1385 | [[package]] 1386 | name = "version-compare" 1387 | version = "0.0.11" 1388 | source = "registry+https://github.com/rust-lang/crates.io-index" 1389 | checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b" 1390 | 1391 | [[package]] 1392 | name = "version-compare" 1393 | version = "0.2.0" 1394 | source = "registry+https://github.com/rust-lang/crates.io-index" 1395 | checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" 1396 | 1397 | [[package]] 1398 | name = "version_check" 1399 | version = "0.9.5" 1400 | source = "registry+https://github.com/rust-lang/crates.io-index" 1401 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1402 | 1403 | [[package]] 1404 | name = "walkdir" 1405 | version = "2.5.0" 1406 | source = "registry+https://github.com/rust-lang/crates.io-index" 1407 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1408 | dependencies = [ 1409 | "same-file", 1410 | "winapi-util", 1411 | ] 1412 | 1413 | [[package]] 1414 | name = "wasi" 1415 | version = "0.9.0+wasi-snapshot-preview1" 1416 | source = "registry+https://github.com/rust-lang/crates.io-index" 1417 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 1418 | 1419 | [[package]] 1420 | name = "wasi" 1421 | version = "0.11.0+wasi-snapshot-preview1" 1422 | source = "registry+https://github.com/rust-lang/crates.io-index" 1423 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1424 | 1425 | [[package]] 1426 | name = "wasi" 1427 | version = "0.13.3+wasi-0.2.2" 1428 | source = "registry+https://github.com/rust-lang/crates.io-index" 1429 | checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 1430 | dependencies = [ 1431 | "wit-bindgen-rt", 1432 | ] 1433 | 1434 | [[package]] 1435 | name = "webkit2gtk" 1436 | version = "0.18.2" 1437 | source = "registry+https://github.com/rust-lang/crates.io-index" 1438 | checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370" 1439 | dependencies = [ 1440 | "bitflags 1.3.2", 1441 | "cairo-rs", 1442 | "gdk", 1443 | "gdk-sys", 1444 | "gio", 1445 | "gio-sys", 1446 | "glib", 1447 | "glib-sys", 1448 | "gobject-sys", 1449 | "gtk", 1450 | "gtk-sys", 1451 | "javascriptcore-rs", 1452 | "libc", 1453 | "once_cell", 1454 | "soup2", 1455 | "webkit2gtk-sys", 1456 | ] 1457 | 1458 | [[package]] 1459 | name = "webkit2gtk-sys" 1460 | version = "0.18.0" 1461 | source = "registry+https://github.com/rust-lang/crates.io-index" 1462 | checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3" 1463 | dependencies = [ 1464 | "atk-sys", 1465 | "bitflags 1.3.2", 1466 | "cairo-sys-rs", 1467 | "gdk-pixbuf-sys", 1468 | "gdk-sys", 1469 | "gio-sys", 1470 | "glib-sys", 1471 | "gobject-sys", 1472 | "gtk-sys", 1473 | "javascriptcore-rs-sys", 1474 | "libc", 1475 | "pango-sys", 1476 | "pkg-config", 1477 | "soup2-sys", 1478 | "system-deps 6.2.2", 1479 | ] 1480 | 1481 | [[package]] 1482 | name = "winapi" 1483 | version = "0.2.8" 1484 | source = "registry+https://github.com/rust-lang/crates.io-index" 1485 | checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" 1486 | 1487 | [[package]] 1488 | name = "winapi" 1489 | version = "0.3.9" 1490 | source = "registry+https://github.com/rust-lang/crates.io-index" 1491 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1492 | dependencies = [ 1493 | "winapi-i686-pc-windows-gnu", 1494 | "winapi-x86_64-pc-windows-gnu", 1495 | ] 1496 | 1497 | [[package]] 1498 | name = "winapi-build" 1499 | version = "0.1.1" 1500 | source = "registry+https://github.com/rust-lang/crates.io-index" 1501 | checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" 1502 | 1503 | [[package]] 1504 | name = "winapi-i686-pc-windows-gnu" 1505 | version = "0.4.0" 1506 | source = "registry+https://github.com/rust-lang/crates.io-index" 1507 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1508 | 1509 | [[package]] 1510 | name = "winapi-util" 1511 | version = "0.1.9" 1512 | source = "registry+https://github.com/rust-lang/crates.io-index" 1513 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1514 | dependencies = [ 1515 | "windows-sys", 1516 | ] 1517 | 1518 | [[package]] 1519 | name = "winapi-x86_64-pc-windows-gnu" 1520 | version = "0.4.0" 1521 | source = "registry+https://github.com/rust-lang/crates.io-index" 1522 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1523 | 1524 | [[package]] 1525 | name = "windows-sys" 1526 | version = "0.59.0" 1527 | source = "registry+https://github.com/rust-lang/crates.io-index" 1528 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1529 | dependencies = [ 1530 | "windows-targets", 1531 | ] 1532 | 1533 | [[package]] 1534 | name = "windows-targets" 1535 | version = "0.52.6" 1536 | source = "registry+https://github.com/rust-lang/crates.io-index" 1537 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1538 | dependencies = [ 1539 | "windows_aarch64_gnullvm", 1540 | "windows_aarch64_msvc", 1541 | "windows_i686_gnu", 1542 | "windows_i686_gnullvm", 1543 | "windows_i686_msvc", 1544 | "windows_x86_64_gnu", 1545 | "windows_x86_64_gnullvm", 1546 | "windows_x86_64_msvc", 1547 | ] 1548 | 1549 | [[package]] 1550 | name = "windows_aarch64_gnullvm" 1551 | version = "0.52.6" 1552 | source = "registry+https://github.com/rust-lang/crates.io-index" 1553 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1554 | 1555 | [[package]] 1556 | name = "windows_aarch64_msvc" 1557 | version = "0.52.6" 1558 | source = "registry+https://github.com/rust-lang/crates.io-index" 1559 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1560 | 1561 | [[package]] 1562 | name = "windows_i686_gnu" 1563 | version = "0.52.6" 1564 | source = "registry+https://github.com/rust-lang/crates.io-index" 1565 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1566 | 1567 | [[package]] 1568 | name = "windows_i686_gnullvm" 1569 | version = "0.52.6" 1570 | source = "registry+https://github.com/rust-lang/crates.io-index" 1571 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1572 | 1573 | [[package]] 1574 | name = "windows_i686_msvc" 1575 | version = "0.52.6" 1576 | source = "registry+https://github.com/rust-lang/crates.io-index" 1577 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1578 | 1579 | [[package]] 1580 | name = "windows_x86_64_gnu" 1581 | version = "0.52.6" 1582 | source = "registry+https://github.com/rust-lang/crates.io-index" 1583 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1584 | 1585 | [[package]] 1586 | name = "windows_x86_64_gnullvm" 1587 | version = "0.52.6" 1588 | source = "registry+https://github.com/rust-lang/crates.io-index" 1589 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1590 | 1591 | [[package]] 1592 | name = "windows_x86_64_msvc" 1593 | version = "0.52.6" 1594 | source = "registry+https://github.com/rust-lang/crates.io-index" 1595 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1596 | 1597 | [[package]] 1598 | name = "winnow" 1599 | version = "0.5.40" 1600 | source = "registry+https://github.com/rust-lang/crates.io-index" 1601 | checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" 1602 | dependencies = [ 1603 | "memchr", 1604 | ] 1605 | 1606 | [[package]] 1607 | name = "winnow" 1608 | version = "0.7.3" 1609 | source = "registry+https://github.com/rust-lang/crates.io-index" 1610 | checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" 1611 | dependencies = [ 1612 | "memchr", 1613 | ] 1614 | 1615 | [[package]] 1616 | name = "wit-bindgen-rt" 1617 | version = "0.33.0" 1618 | source = "registry+https://github.com/rust-lang/crates.io-index" 1619 | checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 1620 | dependencies = [ 1621 | "bitflags 2.8.0", 1622 | ] 1623 | 1624 | [[package]] 1625 | name = "ws2_32-sys" 1626 | version = "0.2.1" 1627 | source = "registry+https://github.com/rust-lang/crates.io-index" 1628 | checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" 1629 | dependencies = [ 1630 | "winapi 0.2.8", 1631 | "winapi-build", 1632 | ] 1633 | --------------------------------------------------------------------------------