├── .cargo └── config.toml ├── .envrc ├── .github └── workflows │ ├── build.yml │ ├── gh-pages.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── assets ├── Preview.mp4 ├── cheatsheet.webp ├── ferrishot_with_background.png ├── icons │ ├── ArrowDown.svg │ ├── ArrowLeft.svg │ ├── ArrowRight.svg │ ├── ArrowUp.svg │ ├── Check.svg │ ├── Circle.svg │ ├── Clipboard.svg │ ├── Close.svg │ ├── Cursor.svg │ ├── Ferrishot.svg │ ├── Fullscreen.svg │ ├── Pen.svg │ ├── README.md │ ├── Save.svg │ ├── Spinner.svg │ ├── Square.svg │ ├── Text.svg │ └── Upload.svg ├── image_uploaded_online.webp ├── instant-shot.mp4 ├── logo.png └── qr-code.svg ├── build.rs ├── clippy.toml ├── completions ├── _ferrishot ├── _ferrishot.ps1 ├── ferrishot.1 ├── ferrishot.bash ├── ferrishot.elv ├── ferrishot.fish ├── ferrishot.md ├── ferrishot.nu ├── ferrishot.ts └── ferrishot.yaml ├── default.kdl ├── dist-workspace.toml ├── docgen ├── Cargo.toml └── src │ └── main.rs ├── docs ├── book.toml ├── mdbook-admonish.css ├── src │ ├── SUMMARY.md │ ├── config │ │ ├── README.md │ │ ├── keybindings.md │ │ ├── options.md │ │ └── theme.md │ ├── installation.md │ ├── selecting_initial.mp4 │ └── usage.md └── theme │ ├── index.hbs │ └── theme.css ├── flake.lock ├── flake.nix ├── index.html ├── key.txt ├── rust-toolchain.toml ├── src ├── clipboard.rs ├── config │ ├── cli.rs │ ├── commands.rs │ ├── key_map.rs │ ├── mod.rs │ ├── named_key.rs │ ├── options.rs │ ├── tests │ │ ├── 2025_05_17_ferrishot_v0.3.kdl │ │ └── mod.rs │ └── theme.rs ├── geometry.rs ├── icons.rs ├── image │ ├── action.rs │ ├── mod.rs │ ├── rgba_handle.rs │ ├── screenshot.rs │ └── upload.rs ├── last_region.rs ├── lazy_rect.rs ├── lib.rs ├── logging.rs ├── main.rs ├── message.rs └── ui │ ├── app.rs │ ├── background_image.rs │ ├── debug_overlay.rs │ ├── errors.rs │ ├── grid.rs │ ├── mod.rs │ ├── popup │ ├── image_uploaded.rs │ ├── keybindings_cheatsheet.rs │ ├── letters.rs │ └── mod.rs │ ├── selection.rs │ ├── selection_icons.rs │ ├── size_indicator.rs │ └── welcome_message.rs ├── tests └── lib.rs └── wix └── main.wxs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | docgen = "run --package docgen --" 3 | 4 | [target.aarch64-apple-darwin] 5 | linker = "rust-lld" 6 | # NOTE: `rustdoc` doesn't currently respect the `linker` setting — keep an eye 7 | # on this issue: https://github.com/rust-lang/rust/issues/125657 8 | rustdocflags = ["-Clink-arg=-fuse-ld=lld"] 9 | 10 | # NOTE: Also annoyingly, `target.` doesn't let you set `rustdocflags`, so 11 | # something like `[target.'cfg(target_os = "macos")']` doesn't work here and 12 | # this repetition is needed... 13 | [target.x86_64-apple-darwin] 14 | linker = "rust-lld" 15 | rustdocflags = ["-Clink-arg=-fuse-ld=lld"] 16 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | check: 13 | name: Check 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: dtolnay/rust-toolchain@stable 18 | - uses: Swatinem/rust-cache@v2 19 | - run: cargo check 20 | 21 | nix: 22 | name: Nix Build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Install Nix 27 | uses: cachix/install-nix-action@v27 28 | 29 | - name: Nix build 30 | run: nix build 31 | 32 | format: 33 | name: Format 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: dtolnay/rust-toolchain@stable 38 | - uses: Swatinem/rust-cache@v2 39 | - run: cargo fmt --check 40 | 41 | test: 42 | name: Test Suite 43 | runs-on: ${{ matrix.os }} 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest, macos-latest, windows-latest] 47 | continue-on-error: true 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: dtolnay/rust-toolchain@stable 51 | - uses: Swatinem/rust-cache@v2 52 | 53 | - name: Install linux deps 54 | if: matrix.os == 'ubuntu-latest' 55 | run: | 56 | sudo apt-get update 57 | sudo apt-get install -y libgl-dev libx11-dev libx11-xcb-dev libwayland-dev 58 | 59 | # ld linker fails with `ld` 60 | - if: runner.os == 'macOS' 61 | run: brew install lld 62 | 63 | - run: cargo test --workspace 64 | 65 | lint: 66 | name: Clippy 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4 70 | - uses: dtolnay/rust-toolchain@stable 71 | - uses: Swatinem/rust-cache@v2 72 | - run: cargo clippy --workspace --all-targets -- -D warnings 73 | - run: cargo doc --no-deps --workspace --document-private-items 74 | env: 75 | RUSTDOCFLAGS: -D warnings 76 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v4 14 | 15 | - name: Build mdbook 16 | run: | 17 | mkdir bin 18 | # fetch mdbook binary 19 | curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.48/mdbook-v0.4.48-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=bin 20 | # we want our `./index.html` which is the landing page at the root and served at `ferrishot.com` root 21 | # And `./book/index.html` will be served at `ferrishot.com/docs` 22 | bin/mdbook build docs 23 | mv docs docs-src 24 | mv docs-src/docs . 25 | 26 | - name: Setup Pages 27 | uses: actions/configure-pages@v4 28 | 29 | - name: Upload GitHub Pages artifact 30 | uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: . 33 | 34 | deploy: 35 | runs-on: ubuntu-latest 36 | needs: build 37 | permissions: 38 | pages: write 39 | id-token: write # required for OIDC in deploy-pages action 40 | environment: 41 | name: github-pages 42 | url: ${{ steps.deployment.outputs.page_url }} 43 | steps: 44 | - name: Deploy to GitHub Pages 45 | id: deployment 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/docs 2 | target/ 3 | .direnv/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.3.0 - 4 May 2025 2 | 3 | - Added support for touch inputs 4 | - Ferrishot can now be configured! The config format uses [KDL](https://kdl.dev/). Here is an excerpt from the default config: 5 | 6 | ```kdl 7 | // Show the size indicator 8 | size-indicator #true 9 | // Show icons around the selection 10 | selection-icons #true 11 | 12 | keys { 13 | // Leave the app 14 | exit key= 15 | 16 | // Copies selected region to clipboard, exiting 17 | copy-to-clipboard mod=ctrl key=c 18 | copy-to-clipboard key= 19 | } 20 | 21 | theme { 22 | // color of the frame around the selection 23 | selection-frame 0xab_61_37 24 | 25 | // background color of the region that is not selected 26 | non-selected-region 0x00_00_00 opacity=0.5 27 | } 28 | ``` 29 | 30 | - Image upload. Press `ctrl + u` to upload your screenshot to the internet. You'll get a preview of the image, link to it and a QR Code. 31 | - Vim keybindings! Ferrishot is fully controllable via keyboard, if you want to do that 32 | - `h | j | k | l` to move by 1 pixel the given side 33 | - `H | J | K | L` to extend by 1 pixel the given side 34 | - `ctrl + h | j | k | l` shrinks by 1 pixel the given side 35 | - Use `alt` in combination with any of the above keys to do the movement of `125px` instead of `1px` 36 | - There is also `gg` to move to top-left corner, `G` for bottom-right corner and more. You can view a cheatsheet in-app by pressing `?`. 37 | - All the keybindings can be overridden with a custom config. You are also able to define custom ones, if you want it to be `50px` instead of `125px` for all of them simply do `ferrishot --dump-default-config` and then edit the values `125 -> 50`. 38 | - Super-select mode, press `t` and: 39 | - The screen will be divided into 25 regions each assigned a letter. Press any of the letters, then: 40 | - The chosen region will be further divided into 25 more regions. This will repeat yet again for a 3rd time. 41 | - Allows you to place your cursor into 1 of 15,625 positions on the screen within 4 keystrokes. This will select the top-left corner 42 | - Repeat this with the bottom-right corner by using `b`. Now you have selected your screenshot without using a mouse, in just 8 keystrokes! 43 | - More powerful command line interface 44 | 45 | - `--delay` wait some time before taking a screenshot 46 | - `--region` open program with a custom region 47 | - `--save-path` choose a file to save the image to, instead of opening file picker (when using `ctrl + s`) 48 | - `--accept-on-select` instantly save, upload or copy image to clipboard as soon as you create the first selection. 49 | 50 | This can be cancelled with `ctrl`. It's handy since often you want to instantly copy the selection to clipboard and not do anything fancy. 51 | 52 | So you can therefore set a shell alias like `alias ferrishot=ferrishot --accept-on-select=copy` and that may be satisfying to you 90% of the time. 53 | 54 | In some cases you'd like to save the image, so just hold ctrl to disable this behaviour when releasing the left-mouse button. 55 | 56 | Using this option with `--region` will run ferrishot in "headless mode", without opening a window (as the region created with `--region` _will_ be the first region) 57 | 58 | - Ferrishot now has a website! [ferrishot.com](https://ferrishot.com) 59 | - The command-line interface has completions for several shells. Completions for each shell can be found in `completions/` folder on GitHub: 60 | - PowerShell 61 | - Bash 62 | - Elv 63 | - Fish 64 | - Nushell 65 | 66 | ##### Breaking 67 | 68 | Removed `--instant` flag, use instead: 69 | 70 | ``` 71 | ferrishot --accept-on-select copy 72 | ``` 73 | 74 | # v0.2.0 - 16 April 2025 75 | 76 | - Right-click will snap the closest corner to the current cursor position 77 | - There are now buttons available for these actions: 78 | - `Enter`: Copy to Clipboard 79 | - `F11`: Select entire monitor 80 | - `Ctrl + S`: Save Screenshot 81 | - `Esc`: Exit 82 | - Added an indicator for the width and height of selection in the bottom right corner. This indicator can be edited to set the selection to a concrete size! 83 | - Holding `Shift` while resizing or moving the selection will now do it 10 times slower, to allow being very accurate. 84 | - Improved security on linux as per review from a reddit user: 85 | 86 | # v0.1.0 - 12 April 2025 87 | 88 | The initial release comes with the following features: 89 | 90 | - Select a region on the screen by left clicking and drag 91 | - Resize region by dragging on any of the sides 92 | - Move the region around by dragging in the center 93 | - `Esc` closes the app 94 | - `Enter` or `Ctrl c` copy region to clipboard 95 | - `Ctrl s` save region as an image to path 96 | - Instantly copy region to clipboard with `--instant` flag 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **How it works**: `ferrishot` takes a screenshot of the focused monitor and creates a fullscreen window with background set to the taken screenshot. 4 | 5 | Some pointers: 6 | 7 | - `ui/` defines components that are part of the app. For example the `welcome_message` shows tips when there is no selection 8 | - `ui/app.rs` holds the `App` struct which contains all information about the program 9 | - `App::view` renders the app 10 | - `message.rs` holds `Message` enum which defines all events that can happen which mutate the `App`'s state 11 | - `App::update` responds to a `Message` to mutate itself. This is the **only** place with access to `&mut App` 12 | - `config/options.rs` defines each config option 13 | - `cli.rs` defines the command line interface 14 | 15 | 100% of the code is documented. To take advantage of that you can use `cargo doc --document-private-items --open`. 16 | 17 | ## Building 18 | 19 | On Windows and MacOS there are no dependencies. 20 | 21 | On Linux, you will need these (`apt` package manager names): 22 | 23 | - `libgl-dev` 24 | - `libx11-dev` 25 | - `libxcbcommon-dev` 26 | 27 | If you use wayland you will also need `libwayland-dev` lib. 28 | 29 | For nix users, there is a `flake.nix` which you can use with `nix develop`. 30 | 31 | To run: 32 | 33 | ```sh 34 | cargo run 35 | ``` 36 | 37 | ### Documentation Generation 38 | 39 | The files in `completions/` are all generated by the following command: 40 | 41 | ```sh 42 | cargo docgen 43 | ``` 44 | 45 | ## Debugging 46 | 47 | - Use `F12` (or `ferrishot --debug`) to toggle the debug overlay which contains shows information about recent `Message`s received. 48 | - The `.explain()` method on `Element` provided by the `Explainer` trait will show a red border around an element and all of its children. 49 | - These are the additional CLI options available for development: 50 | 51 | ``` 52 | Debug: 53 | --log-level 54 | Choose a minimum level at which to log. [error, warn, info, debug, trace, off] 55 | 56 | [default: error] 57 | 58 | --log-stderr 59 | Log to standard error instead of file 60 | 61 | --log-file 62 | Path to the log file 63 | 64 | [default: /home/e/.cache/ferrishot.log] 65 | 66 | --log-filter 67 | Filter for specific Rust module or crate, instead of showing logs from all crates 68 | 69 | --debug 70 | Launch in debug mode (F12) 71 | ``` 72 | 73 | ## Website 74 | 75 | - `index.html` is the landing page and served at `ferrishot.com`. You can just open this file in the browser. 76 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ferrishot" 3 | version = "0.2.0" 4 | license = "MIT OR Apache-2.0" 5 | edition = "2024" 6 | description = "A powerful screenshot app" 7 | repository = "https://github.com/nik-rev/ferrishot" 8 | homepage = "https://github.com/nik-rev/ferrishot" 9 | keywords = ["screenshot", "screen-capture", "capture", "flameshot"] 10 | categories = ["visualization", "multimedia"] 11 | authors = ["Nik Revenco"] 12 | 13 | [features] 14 | debug = [] 15 | 16 | [workspace] 17 | members = [".", "docgen"] 18 | 19 | [package.metadata.wix] 20 | upgrade-guid = "338F87CB-7559-4755-9DC7-889308AFCC72" 21 | path-guid = "B2F84121-9832-457D-AD64-A5598BCD4AA7" 22 | license = false 23 | eula = false 24 | 25 | [build-dependencies] 26 | # to generate the RGBA bytes for logo of ferrishot 27 | image = "0.25.6" 28 | 29 | [dependencies] 30 | 31 | # --- core crates --- 32 | # obtain position of the mouse 33 | mouse_position = "0.1.4" 34 | 35 | # cross-platform "take a screenshot of the monitor" UI. 36 | # 37 | # FORK: screenshot on wayland using libwayshot is very fast, 38 | # so it comes before x11 and freedesktop. The reason is that 39 | # freedesktop can block for like 1.5s before giving up. x11 can block for about 30ms 40 | # libwayshot blocks for about 500ns. by "block" I mean how long it takes 41 | # 42 | # on the original xcap, they try libwayshot AFTER trying freedesktop 43 | # which leads to VERY bad performance on wayland (about 1.5 seconds delay for nothing) 44 | xcap = { package = "ferrishot_xcap", version = "0.4.1", features = [ 45 | "image", 46 | "vendored", 47 | ] } 48 | 49 | # Iced is the native UI framework that ferrishot uses 50 | # 51 | # We need iced 0.14 (currently just `master`) for the app to function properly. This is because 52 | # prior to iced 0.14, there is no way to create a full-screen window. That means we'd have to 53 | # - create the window 54 | # - THEN make it full screen 55 | # 56 | # This makes a "flash" for a split second which is very noticable and makes the experience worse 57 | # And we need this iced 0.14 available on crates.io because I'd like to publish ferrishot to crates.io 58 | iced = { package = "ferrishot_iced", version = "0.14.1", features = [ 59 | "canvas", 60 | "image", 61 | "web-colors", 62 | "advanced", 63 | "svg", 64 | "wgpu", 65 | "tokio", 66 | "qr_code", 67 | ] } 68 | # provider a cross-platform clipboard API 69 | arboard = { version = "3.5", features = ["wayland-data-control"] } 70 | # image encoding, transformations and decoding 71 | image = "0.25.6" 72 | # command line argument parser 73 | clap = { version = "4.5.35", features = [ 74 | "derive", 75 | "wrap_help", 76 | "unstable-markdown", 77 | ] } 78 | # file dialog 79 | rfd = "0.15.3" 80 | # cross-platform API to get locations like config directory, cache directory... 81 | etcetera = "0.10.0" 82 | # tempfile for data transmission of the image bytes 83 | tempfile = "3.19.1" 84 | # async runtime 85 | tokio = { version = "1.44.2", features = ["full"] } 86 | # knus is the serde-like derive macro to parse KDL into Rust structs 87 | # 88 | # This is a fork simply so we can publish the branch https://github.com/nik-rev/knus/tree/kdl-v2 89 | # to crates.io, required to be able to use kdl-v2 90 | # 91 | # I'd like to just use kdl-v2 to avoid breaking users' configs in the future when we'll 92 | # migrate from kdl v1 -> kdl v2 anyways 93 | ferrishot_knus = "3.3" 94 | # pretty error messages for KDL derive 95 | miette = { version = "7.5.0", features = ["fancy"] } 96 | 97 | # --- logging --- 98 | env_logger = "0.11.8" 99 | log = "0.4.27" 100 | chrono = "0.4.40" # used only for time stamp 101 | 102 | # --- send web requsts --- 103 | reqwest = { version = "0.12.15", default-features = false, features = [ 104 | # this + `default-features = false` removes dependency on OpenSSL 105 | "rustls-tls", 106 | "json", 107 | "multipart", 108 | "stream", 109 | ] } 110 | serde = { version = "1.0.219", features = ["derive"] } 111 | serde_json = "1.0.140" 112 | 113 | pretty_assertions = "1.4.1" 114 | 115 | # --- helper crates --- 116 | 117 | # typed error enum generation 118 | thiserror = "2.0.12" 119 | # avoid boilerplate when writing methods that just call methods on fields in the same struct 120 | delegate = "0.13" 121 | # Allows implementing methods for external types via extension traits 122 | # this crate allows skipping the definition of the extension trait which means we 123 | # don't have to write signatures of the functions twice which is awesome 124 | easy-ext = "1.0.2" 125 | derive_more = { version = "2.0.1", features = ["is_variant"] } 126 | strum = { version = "0.27.1", features = ["derive"] } 127 | human_bytes = "0.4.3" 128 | # generate builder methods 129 | bon = "3.6.3" 130 | # allows 131 | tap = "1.0.1" 132 | # dedents string literals 133 | indoc = "2.0.6" 134 | 135 | anstyle = "1.0.10" 136 | paste = "1.0.15" 137 | 138 | [lints.rust] 139 | missing_docs = "warn" 140 | unused_qualifications = "warn" 141 | 142 | [lints.clippy] 143 | pedantic = { priority = -1, level = "warn" } 144 | nursery = { priority = -1, level = "warn" } 145 | 146 | # $a * $b + $c is slower and less precise than $a.mul_add($b, $c) but it is more readable 147 | # the gain in speed / precision will be negligible in most situations 148 | suboptimal_flops = "allow" 149 | # arbitrary limit imposes unnecessary restriction and can make code harder to follow 150 | too_many_lines = "allow" 151 | # if we need it const, make it const. no need to make everything that can be const, const 152 | missing_const_for_fn = "allow" 153 | 154 | if_then_some_else_none = "warn" 155 | 156 | # --- casts 157 | # 158 | # casts from floats -> int are common in the code, and in 159 | # most cases we don't care about precision as we are 160 | # dealing with pixels which cannot be float. 161 | cast_sign_loss = "allow" 162 | cast_possible_truncation = "allow" 163 | cast_precision_loss = "allow" 164 | # --- 165 | 166 | missing_errors_doc = "allow" 167 | 168 | # use Trait; => use Trait as _; 169 | unused_trait_names = "warn" 170 | 171 | # #[allow] => #[allow, reason = "why"] 172 | allow_attributes_without_reason = "warn" 173 | # .unwrap() => .expect("why") 174 | unwrap_used = "warn" 175 | # assert!(...) => assert!(..., "why") 176 | missing_assert_message = "warn" 177 | 178 | missing_docs_in_private_items = "warn" 179 | 180 | # --- catch debug remnants 181 | print_stderr = "warn" 182 | print_stdout = "warn" 183 | dbg_macro = "warn" 184 | todo = "warn" 185 | # --- 186 | 187 | # The profile that 'dist' will build with 188 | [profile.dist] 189 | inherits = "release" 190 | codegen-units = 1 191 | lto = true 192 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |

4 | 5 | 6 | 7 |
8 | ferrishot 9 |

10 |

Powerful screenshot app written in Rust, inspired by flameshot.

11 |

12 |

13 | 14 | latest release 15 | 16 | 17 | Chat on Discord 18 | 19 |

20 |
21 | 22 | ## Showcase 23 | 24 | 25 | 26 | ## Features 27 | 28 | Run by typing `ferrishot` on the command line. 29 | 30 | ### Basic usage 31 | 32 | - Select a region on the screen by left clicking and dragging. 33 | - Resize the region by dragging on any of the sides and dragging 34 | - Move the region around by dragging in the center 35 | 36 | The selected region is surrounded by buttons, each with their own keybinding. Most notably: 37 | 38 | - `Enter` copies screenshot to clipboard 39 | - `Ctrl s` saves screenshot to a file. You can choose any valid extension like `.png`, `.webp`, `.jpg` 40 | - `Ctrl u` uploads the screenshot to the internet 41 | 42 | Hold `Shift` while resizing to have much more granular control over the size of the region. 43 | 44 | #### Image Uploaded 45 | 46 | You get a link and a QR Code, so you can easily send it to another device! 47 | 48 | 49 | 50 | ### Size Indicator 51 | 52 | In the bottom-right corner, is a small box showing the height and width of the selection. 53 | You can manually edit it to set a specific size. 54 | 55 | ### Keyboard Control 56 | 57 | Ferrishot can be fully keyboard controlled, with no mouse! You can select any region on the screen in just 58 | 8 keystrokes. Pick a top-left corner by typing `t`, and pick a bottom-right corner by typing `b`: 59 | 60 | 61 | 62 | We also have vim motions! There is a cheatsheet available by pressing `?` to view the motions: 63 | 64 | ![cheatsheet](./assets/cheatsheet.webp) 65 | 66 | You can see all of the keybindings declared in the default config file [`default.kdl`](./default.kdl) 67 | 68 | ### Config 69 | 70 | Ferrishot is very customizable! You have _full_ control over the UI, color scheme and keybindings. 71 | 72 | Create the default config file `ferrishot.kdl` by doing `ferrishot --dump-default-config`. 73 | 74 | For reference, see the [default config file (`default.kdl`)](./default.kdl) which contains comments describing each option. 75 | 76 | ### Command-line interface 77 | 78 | Ferrishot is fantastic for usage in scripts. It can be fully controlled without launching a UI. 79 | 80 | #### `ferrishot` 81 | 82 | A powerful screenshot app 83 | 84 | **Usage:** `ferrishot [OPTIONS]` 85 | 86 | ###### **Options:** 87 | 88 | - `-r`, `--region ` — Open with a region pre-selected 89 | 90 | Format: `x++` 91 | 92 | Each value can be absolute. 93 | 94 | - 550 for `x` means top-left corner starts after 550px 95 | - 100 for `height` means it will be 100px tall 96 | 97 | Each can also be relative to the height (for `y` and `height`) or width (for `width` and `x`) 98 | 99 | - 0.2 for `width` means it region takes up 20% of the width of the image. 100 | - 0.5 for `y` means the top-left corner will be at the vertical center 101 | 102 | The format can also end with 1 or 2 percentages, which shifts the region relative to the region's size 103 | 104 | - If `width` is `250`, end region with `+30%` to move right by 75px or `-40%` to move left by 100px 105 | - Supplying 2 percentage at the end like `+30%-10%`, the 1st affects x-offset and the 2nd affects y-offset 106 | 107 | With the above syntax, you can create all the regions you want. 108 | 109 | - `100x1.0+0.5+0-50%`: Create a 100px wide, full height, horizontally centered region 110 | - `1.0x1.0+0+0`: Create a region that spans the full screen. You can use alias `full` for this 111 | 112 | - `-l`, `--last-region` — Use last region 113 | - `-a`, `--accept-on-select ` — Accept capture and perform the action as soon as a selection is made 114 | 115 | If holding `ctrl` while you are releasing the left mouse button on the first selection, 116 | the behavior is cancelled 117 | 118 | It's quite useful to run ferrishot, select a region and have it instantly be copied to the 119 | clipboard for example. In 90% of situations you won't want to do much post-processing of 120 | the region and this makes that experience twice as fast. You can always opt-out with `ctrl` 121 | 122 | Using this option with `--region` or `--last-region` will run ferrishot in 'headless mode', 123 | without making a new window. 124 | 125 | Possible values: 126 | 127 | - `copy`: 128 | Copy image to the clipboard 129 | - `save`: 130 | Save image to a file 131 | - `upload`: 132 | Upload image to the internet 133 | 134 | - `-d`, `--delay ` — Wait this long before launch 135 | - `-s`, `--save-path ` — Instead of opening a file picker to save the screenshot, save it to this path instead 136 | - `-D`, `--dump-default-config` — Write contents of the default config to /home/e/.config/ferrishot.kdl 137 | - `-C`, `--config-file ` — Use the provided config file 138 | 139 | Default value: `/home/e/.config/ferrishot.kdl` 140 | 141 | - `-S`, `--silent` — Run in silent mode. Do not print anything 142 | - `-j`, `--json` — Print in JSON format 143 | 144 | ## Platform Support 145 | 146 | - [x] Windows 147 | - [x] MacOS 148 | - [x] Linux (X11) 149 | - [x] Linux (Wayland) 150 | 151 | ## Roadmap 152 | 153 | Ferrishot is under heavy development! At the moment the goal is to implement all the features that [flameshot](https://github.com/flameshot-org/flameshot) has, including more than that. 154 | 155 | - [ ] Draw shapes on the image 156 | - Square 157 | - Circle 158 | - Arrow 159 | - Pen 160 | - [ ] Draw text on the image 161 | - [ ] Other effects 162 | - Blur / pixelate 163 | - Numbered circles 164 | - [ ] Pin screenshot 165 | - [ ] Color picker 166 | - [ ] In-app tool editing 167 | 168 | ## Installation 169 | 170 | ### Homebrew 171 | 172 | ```sh 173 | brew install nik-rev/tap/ferrishot 174 | ``` 175 | 176 | ### PowerShell 177 | 178 | ```sh 179 | powershell -ExecutionPolicy Bypass -c "irm https://github.com/nik-rev/ferrishot/releases/latest/download/ferrishot-installer.ps1 | iex" 180 | ``` 181 | 182 | ### Shell 183 | 184 | ```sh 185 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nik-rev/ferrishot/releases/latest/download/ferrishot-installer.sh | sh 186 | ``` 187 | 188 | ### Nix 189 | 190 | Add it to your `flake.nix`: 191 | 192 | ```nix 193 | # add it to your inputs 194 | inputs.ferrishot.url = "github:nik-rev/ferrishot/main"; 195 | # then use it in home-manager for example 196 | inputs.ferrishot.packages.${pkgs.system}.default 197 | ``` 198 | 199 | ### Arch AUR 200 | 201 | ```sh 202 | yay -S ferrishot-bin 203 | ``` 204 | 205 | ### Cargo 206 | 207 | If you use Linux, see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for details on which dependencies you will need. 208 | 209 | ```sh 210 | cargo install ferrishot 211 | ``` 212 | 213 | ## Contributing 214 | 215 | See [`CONTRIBUTING.md`](./CONTRIBUTING.md) 216 | -------------------------------------------------------------------------------- /assets/Preview.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/Preview.mp4 -------------------------------------------------------------------------------- /assets/cheatsheet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/cheatsheet.webp -------------------------------------------------------------------------------- /assets/ferrishot_with_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/ferrishot_with_background.png -------------------------------------------------------------------------------- /assets/icons/ArrowDown.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/ArrowLeft.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/ArrowRight.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/ArrowUp.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Check.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Circle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Clipboard.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Cursor.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Ferrishot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 42 | 46 | 50 | 51 | 55 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /assets/icons/Fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Pen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/README.md: -------------------------------------------------------------------------------- 1 | # Icons 2 | 3 | To add a new icon: 4 | 5 | - Add the `Icon.svg` file in this directory. 6 | - Modify the `load_icons!` macro invocation in `src/icons.rs` to include the `Icon`. 7 | 8 | Use the `Icon` in ferrishot with `crate::icon!(Icon)`, which will expand to an `iced::widget::Svg` on use. 9 | -------------------------------------------------------------------------------- /assets/icons/Save.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Square.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Text.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/Upload.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/image_uploaded_online.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/image_uploaded_online.webp -------------------------------------------------------------------------------- /assets/instant-shot.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/instant-shot.mp4 -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/assets/logo.png -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | //! Create the file representing RGBA bytes of an image 2 | //! This is so that we don't need to do this work at runtime 3 | 4 | fn main() { 5 | println!("cargo:rerun-if-changed=assets/logo.png"); 6 | 7 | let image = image::open(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/logo.png")) 8 | .expect("Failed to get the logo") 9 | .into_rgba8() 10 | .into_raw(); 11 | 12 | let out_dir = std::env::var_os("OUT_DIR").expect("env variable to exist"); 13 | let dest_path = std::path::Path::new(&out_dir).join("logo.bin"); 14 | 15 | std::fs::write(dest_path, image).expect("failed to create image file"); 16 | } 17 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | disallowed-macros = [ 2 | { path = "std::assert_ne", reason = "use `pretty_assertions::assert_ne` instead" }, 3 | { path = "std::assert_eq", reason = "use `pretty_assertions::assert_eq` instead" }, 4 | ] 5 | -------------------------------------------------------------------------------- /completions/_ferrishot: -------------------------------------------------------------------------------- 1 | #compdef ferrishot 2 | 3 | autoload -U is-at-least 4 | 5 | _ferrishot() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" : \ 18 | '-r+[Open with a region pre-selected]:WxH+X+Y:' \ 19 | '--region=[Open with a region pre-selected]:WxH+X+Y:' \ 20 | '-a+[Accept on first selection]:ACTION:((copy\:"Copy image to the clipboard" 21 | save\:"Save image to a file" 22 | upload\:"Upload image to the internet"))' \ 23 | '--accept-on-select=[Accept on first selection]:ACTION:((copy\:"Copy image to the clipboard" 24 | save\:"Save image to a file" 25 | upload\:"Upload image to the internet"))' \ 26 | '-d+[Wait this long before launch]:MILLISECONDS:' \ 27 | '--delay=[Wait this long before launch]:MILLISECONDS:' \ 28 | '-s+[Save image to path]:PATH:_files' \ 29 | '--save-path=[Save image to path]:PATH:_files' \ 30 | '-C+[Use the provided config file]:FILE.KDL:_files' \ 31 | '--config-file=[Use the provided config file]:FILE.KDL:_files' \ 32 | '--log-level=[Choose a miniumum level at which to log]:LEVEL:_default' \ 33 | '--log-file=[Path to the log file]:FILE:_files' \ 34 | '--log-filter=[Filter for specific Rust module or crate, instead of showing logs from all crates]:FILTER:' \ 35 | '(-r --region)-l[Use last region]' \ 36 | '(-r --region)--last-region[Use last region]' \ 37 | '-D[Write the default config to /home/e/.config/ferrishot.kdl]' \ 38 | '--dump-default-config[Write the default config to /home/e/.config/ferrishot.kdl]' \ 39 | '-S[Run in silent mode]' \ 40 | '--silent[Run in silent mode]' \ 41 | '(-S --silent)-j[Print in JSON format]' \ 42 | '(-S --silent)--json[Print in JSON format]' \ 43 | '(-S --silent)--log-stderr[Log to standard error instead of file]' \ 44 | '--debug[Launch in debug mode (F12)]' \ 45 | '-h[Print help (see more with '\''--help'\'')]' \ 46 | '--help[Print help (see more with '\''--help'\'')]' \ 47 | '-V[Print version]' \ 48 | '--version[Print version]' \ 49 | '::file -- Instead of taking a screenshot of the desktop, open this image instead:_files' \ 50 | && ret=0 51 | } 52 | 53 | (( $+functions[_ferrishot_commands] )) || 54 | _ferrishot_commands() { 55 | local commands; commands=() 56 | _describe -t commands 'ferrishot commands' commands "$@" 57 | } 58 | 59 | if [ "$funcstack[1]" = "_ferrishot" ]; then 60 | _ferrishot "$@" 61 | else 62 | compdef _ferrishot ferrishot 63 | fi 64 | -------------------------------------------------------------------------------- /completions/_ferrishot.ps1: -------------------------------------------------------------------------------- 1 | 2 | using namespace System.Management.Automation 3 | using namespace System.Management.Automation.Language 4 | 5 | Register-ArgumentCompleter -Native -CommandName 'ferrishot' -ScriptBlock { 6 | param($wordToComplete, $commandAst, $cursorPosition) 7 | 8 | $commandElements = $commandAst.CommandElements 9 | $command = @( 10 | 'ferrishot' 11 | for ($i = 1; $i -lt $commandElements.Count; $i++) { 12 | $element = $commandElements[$i] 13 | if ($element -isnot [StringConstantExpressionAst] -or 14 | $element.StringConstantType -ne [StringConstantType]::BareWord -or 15 | $element.Value.StartsWith('-') -or 16 | $element.Value -eq $wordToComplete) { 17 | break 18 | } 19 | $element.Value 20 | }) -join ';' 21 | 22 | $completions = @(switch ($command) { 23 | 'ferrishot' { 24 | [CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Open with a region pre-selected') 25 | [CompletionResult]::new('--region', '--region', [CompletionResultType]::ParameterName, 'Open with a region pre-selected') 26 | [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'Accept on first selection') 27 | [CompletionResult]::new('--accept-on-select', '--accept-on-select', [CompletionResultType]::ParameterName, 'Accept on first selection') 28 | [CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'Wait this long before launch') 29 | [CompletionResult]::new('--delay', '--delay', [CompletionResultType]::ParameterName, 'Wait this long before launch') 30 | [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Save image to path') 31 | [CompletionResult]::new('--save-path', '--save-path', [CompletionResultType]::ParameterName, 'Save image to path') 32 | [CompletionResult]::new('-C', '-C ', [CompletionResultType]::ParameterName, 'Use the provided config file') 33 | [CompletionResult]::new('--config-file', '--config-file', [CompletionResultType]::ParameterName, 'Use the provided config file') 34 | [CompletionResult]::new('--log-level', '--log-level', [CompletionResultType]::ParameterName, 'Choose a miniumum level at which to log') 35 | [CompletionResult]::new('--log-file', '--log-file', [CompletionResultType]::ParameterName, 'Path to the log file') 36 | [CompletionResult]::new('--log-filter', '--log-filter', [CompletionResultType]::ParameterName, 'Filter for specific Rust module or crate, instead of showing logs from all crates') 37 | [CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'Use last region') 38 | [CompletionResult]::new('--last-region', '--last-region', [CompletionResultType]::ParameterName, 'Use last region') 39 | [CompletionResult]::new('-D', '-D ', [CompletionResultType]::ParameterName, 'Write the default config to /home/e/.config/ferrishot.kdl') 40 | [CompletionResult]::new('--dump-default-config', '--dump-default-config', [CompletionResultType]::ParameterName, 'Write the default config to /home/e/.config/ferrishot.kdl') 41 | [CompletionResult]::new('-S', '-S ', [CompletionResultType]::ParameterName, 'Run in silent mode') 42 | [CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'Run in silent mode') 43 | [CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'Print in JSON format') 44 | [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Print in JSON format') 45 | [CompletionResult]::new('--log-stderr', '--log-stderr', [CompletionResultType]::ParameterName, 'Log to standard error instead of file') 46 | [CompletionResult]::new('--debug', '--debug', [CompletionResultType]::ParameterName, 'Launch in debug mode (F12)') 47 | [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') 48 | [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') 49 | [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') 50 | [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') 51 | break 52 | } 53 | }) 54 | 55 | $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | 56 | Sort-Object -Property ListItemText 57 | } 58 | -------------------------------------------------------------------------------- /completions/ferrishot.1: -------------------------------------------------------------------------------- 1 | .ie \n(.g .ds Aq \(aq 2 | .el .ds Aq ' 3 | .TH ferrishot 1 "ferrishot 0.2.0" 4 | .SH NAME 5 | ferrishot \- A powerful screenshot app 6 | .SH SYNOPSIS 7 | \fBferrishot\fR [\fB\-r\fR|\fB\-\-region\fR] [\fB\-l\fR|\fB\-\-last\-region\fR] [\fB\-a\fR|\fB\-\-accept\-on\-select\fR] [\fB\-d\fR|\fB\-\-delay\fR] [\fB\-s\fR|\fB\-\-save\-path\fR] [\fB\-D\fR|\fB\-\-dump\-default\-config\fR] [\fB\-C\fR|\fB\-\-config\-file\fR] [\fB\-S\fR|\fB\-\-silent\fR] [\fB\-j\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILE\fR] 8 | .SH DESCRIPTION 9 | A powerful screenshot app 10 | .SH OPTIONS 11 | .TP 12 | \fB\-r\fR, \fB\-\-region\fR=\fIWxH+X+Y\fR 13 | Open with a region pre\-selected 14 | 15 | Format: `x++` 16 | 17 | Each value can be absolute. 18 | \- 550 for `x` means top\-left corner starts after 550px 19 | \- 100 for `height` means it will be 100px tall 20 | 21 | Each can also be relative to the height (for `y` and `height`) or width (for `width` and `x`) 22 | \- 0.2 for `width` means it region takes up 20% of the width of the image. 23 | \- 0.5 for `y` means the top\-left corner will be at the vertical center 24 | 25 | The format can also end with 1 or 2 percentages, which shifts the region relative to the region\*(Aqs size 26 | \- If `width` is `250`, end region with `+30%` to move right by 75px or `\-40%` to move left by 100px 27 | \- Supplying 2 percentage at the end like `+30%\-10%`, the 1st affects x\-offset and the 2nd affects y\-offset 28 | 29 | With the above syntax, you can create all the regions you want. 30 | \- `100x1.0+0.5+0\-50%`: Create a 100px wide, full height, horizontally centered region 31 | \- `1.0x1.0+0+0`: Create a region that spans the full screen. You can use alias `full` for this 32 | .TP 33 | \fB\-l\fR, \fB\-\-last\-region\fR 34 | Use last region 35 | .TP 36 | \fB\-a\fR, \fB\-\-accept\-on\-select\fR=\fIACTION\fR 37 | Accept capture and perform the action as soon as a selection is made 38 | 39 | If holding `ctrl` while you are releasing the left mouse button on the first selection, 40 | the behavior is cancelled 41 | 42 | It\*(Aqs quite useful to run ferrishot, select a region and have it instantly be copied to the 43 | clipboard for example. In 90% of situations you won\*(Aqt want to do much post\-processing of 44 | the region and this makes that experience twice as fast. You can always opt\-out with `ctrl` 45 | 46 | Using this option with `\-\-region` or `\-\-last\-region` will run ferrishot in \*(Aqheadless mode\*(Aq, 47 | without making a new window. 48 | .br 49 | 50 | .br 51 | \fIPossible values:\fR 52 | .RS 14 53 | .IP \(bu 2 54 | copy: Copy image to the clipboard 55 | .IP \(bu 2 56 | save: Save image to a file 57 | .IP \(bu 2 58 | upload: Upload image to the internet 59 | .RE 60 | .TP 61 | \fB\-d\fR, \fB\-\-delay\fR=\fIMILLISECONDS\fR 62 | Wait this long before launch 63 | .TP 64 | \fB\-s\fR, \fB\-\-save\-path\fR=\fIPATH\fR 65 | Instead of opening a file picker to save the screenshot, save it to this path instead 66 | .TP 67 | \fB\-h\fR, \fB\-\-help\fR 68 | Print help (see a summary with \*(Aq\-h\*(Aq) 69 | .TP 70 | \fB\-V\fR, \fB\-\-version\fR 71 | Print version 72 | .SH CONFIG 73 | .TP 74 | \fB\-D\fR, \fB\-\-dump\-default\-config\fR 75 | Write contents of the default config to /home/e/.config/ferrishot.kdl 76 | .TP 77 | \fB\-C\fR, \fB\-\-config\-file\fR=\fIFILE.KDL\fR [default: /home/e/.config/ferrishot.kdl] 78 | Use the provided config file 79 | .SH OUTPUT 80 | .TP 81 | \fB\-S\fR, \fB\-\-silent\fR 82 | Run in silent mode. Do not print anything 83 | .TP 84 | \fB\-j\fR, \fB\-\-json\fR 85 | Print in JSON format 86 | .SH VERSION 87 | v0.2.0 88 | .SH AUTHORS 89 | Nik Revenco 90 | -------------------------------------------------------------------------------- /completions/ferrishot.bash: -------------------------------------------------------------------------------- 1 | _ferrishot() { 2 | local i cur prev opts cmd 3 | COMPREPLY=() 4 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 5 | cur="$2" 6 | else 7 | cur="${COMP_WORDS[COMP_CWORD]}" 8 | fi 9 | prev="$3" 10 | cmd="" 11 | opts="" 12 | 13 | for i in ${COMP_WORDS[@]} 14 | do 15 | case "${cmd},${i}" in 16 | ",$1") 17 | cmd="ferrishot" 18 | ;; 19 | *) 20 | ;; 21 | esac 22 | done 23 | 24 | case "${cmd}" in 25 | ferrishot) 26 | opts="-r -l -a -d -s -D -C -S -j -h -V --region --last-region --accept-on-select --delay --save-path --dump-default-config --config-file --silent --json --log-level --log-stderr --log-file --log-filter --debug --help --version [FILE]" 27 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 28 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 29 | return 0 30 | fi 31 | case "${prev}" in 32 | --region) 33 | COMPREPLY=("${cur}") 34 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 35 | compopt -o nospace 36 | fi 37 | return 0 38 | ;; 39 | -r) 40 | COMPREPLY=("${cur}") 41 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 42 | compopt -o nospace 43 | fi 44 | return 0 45 | ;; 46 | --accept-on-select) 47 | COMPREPLY=($(compgen -W "copy save upload" -- "${cur}")) 48 | return 0 49 | ;; 50 | -a) 51 | COMPREPLY=($(compgen -W "copy save upload" -- "${cur}")) 52 | return 0 53 | ;; 54 | --delay) 55 | COMPREPLY=("${cur}") 56 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 57 | compopt -o nospace 58 | fi 59 | return 0 60 | ;; 61 | -d) 62 | COMPREPLY=("${cur}") 63 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 64 | compopt -o nospace 65 | fi 66 | return 0 67 | ;; 68 | --save-path) 69 | local oldifs 70 | if [ -n "${IFS+x}" ]; then 71 | oldifs="$IFS" 72 | fi 73 | IFS=$'\n' 74 | COMPREPLY=($(compgen -f "${cur}")) 75 | if [ -n "${oldifs+x}" ]; then 76 | IFS="$oldifs" 77 | fi 78 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 79 | compopt -o filenames 80 | fi 81 | return 0 82 | ;; 83 | -s) 84 | local oldifs 85 | if [ -n "${IFS+x}" ]; then 86 | oldifs="$IFS" 87 | fi 88 | IFS=$'\n' 89 | COMPREPLY=($(compgen -f "${cur}")) 90 | if [ -n "${oldifs+x}" ]; then 91 | IFS="$oldifs" 92 | fi 93 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 94 | compopt -o filenames 95 | fi 96 | return 0 97 | ;; 98 | --config-file) 99 | local oldifs 100 | if [ -n "${IFS+x}" ]; then 101 | oldifs="$IFS" 102 | fi 103 | IFS=$'\n' 104 | COMPREPLY=($(compgen -f "${cur}")) 105 | if [ -n "${oldifs+x}" ]; then 106 | IFS="$oldifs" 107 | fi 108 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 109 | compopt -o filenames 110 | fi 111 | return 0 112 | ;; 113 | -C) 114 | local oldifs 115 | if [ -n "${IFS+x}" ]; then 116 | oldifs="$IFS" 117 | fi 118 | IFS=$'\n' 119 | COMPREPLY=($(compgen -f "${cur}")) 120 | if [ -n "${oldifs+x}" ]; then 121 | IFS="$oldifs" 122 | fi 123 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 124 | compopt -o filenames 125 | fi 126 | return 0 127 | ;; 128 | --log-level) 129 | COMPREPLY=($(compgen -f "${cur}")) 130 | return 0 131 | ;; 132 | --log-file) 133 | local oldifs 134 | if [ -n "${IFS+x}" ]; then 135 | oldifs="$IFS" 136 | fi 137 | IFS=$'\n' 138 | COMPREPLY=($(compgen -f "${cur}")) 139 | if [ -n "${oldifs+x}" ]; then 140 | IFS="$oldifs" 141 | fi 142 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 143 | compopt -o filenames 144 | fi 145 | return 0 146 | ;; 147 | --log-filter) 148 | COMPREPLY=("${cur}") 149 | if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then 150 | compopt -o nospace 151 | fi 152 | return 0 153 | ;; 154 | *) 155 | COMPREPLY=() 156 | ;; 157 | esac 158 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 159 | return 0 160 | ;; 161 | esac 162 | } 163 | 164 | if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then 165 | complete -F _ferrishot -o nosort -o bashdefault -o default ferrishot 166 | else 167 | complete -F _ferrishot -o bashdefault -o default ferrishot 168 | fi 169 | -------------------------------------------------------------------------------- /completions/ferrishot.elv: -------------------------------------------------------------------------------- 1 | 2 | use builtin; 3 | use str; 4 | 5 | set edit:completion:arg-completer[ferrishot] = {|@words| 6 | fn spaces {|n| 7 | builtin:repeat $n ' ' | str:join '' 8 | } 9 | fn cand {|text desc| 10 | edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc 11 | } 12 | var command = 'ferrishot' 13 | for word $words[1..-1] { 14 | if (str:has-prefix $word '-') { 15 | break 16 | } 17 | set command = $command';'$word 18 | } 19 | var completions = [ 20 | &'ferrishot'= { 21 | cand -r 'Open with a region pre-selected' 22 | cand --region 'Open with a region pre-selected' 23 | cand -a 'Accept on first selection' 24 | cand --accept-on-select 'Accept on first selection' 25 | cand -d 'Wait this long before launch' 26 | cand --delay 'Wait this long before launch' 27 | cand -s 'Save image to path' 28 | cand --save-path 'Save image to path' 29 | cand -C 'Use the provided config file' 30 | cand --config-file 'Use the provided config file' 31 | cand --log-level 'Choose a miniumum level at which to log' 32 | cand --log-file 'Path to the log file' 33 | cand --log-filter 'Filter for specific Rust module or crate, instead of showing logs from all crates' 34 | cand -l 'Use last region' 35 | cand --last-region 'Use last region' 36 | cand -D 'Write the default config to /home/e/.config/ferrishot.kdl' 37 | cand --dump-default-config 'Write the default config to /home/e/.config/ferrishot.kdl' 38 | cand -S 'Run in silent mode' 39 | cand --silent 'Run in silent mode' 40 | cand -j 'Print in JSON format' 41 | cand --json 'Print in JSON format' 42 | cand --log-stderr 'Log to standard error instead of file' 43 | cand --debug 'Launch in debug mode (F12)' 44 | cand -h 'Print help (see more with ''--help'')' 45 | cand --help 'Print help (see more with ''--help'')' 46 | cand -V 'Print version' 47 | cand --version 'Print version' 48 | } 49 | ] 50 | $completions[$command] 51 | } 52 | -------------------------------------------------------------------------------- /completions/ferrishot.fish: -------------------------------------------------------------------------------- 1 | complete -c ferrishot -s r -l region -d 'Open with a region pre-selected' -r -f 2 | complete -c ferrishot -s a -l accept-on-select -d 'Accept on first selection' -r -f -a "copy\t'Copy image to the clipboard' 3 | save\t'Save image to a file' 4 | upload\t'Upload image to the internet'" 5 | complete -c ferrishot -s d -l delay -d 'Wait this long before launch' -r -f 6 | complete -c ferrishot -s s -l save-path -d 'Save image to path' -r -F 7 | complete -c ferrishot -s C -l config-file -d 'Use the provided config file' -r -F 8 | complete -c ferrishot -l log-level -d 'Choose a miniumum level at which to log' -r 9 | complete -c ferrishot -l log-file -d 'Path to the log file' -r -F 10 | complete -c ferrishot -l log-filter -d 'Filter for specific Rust module or crate, instead of showing logs from all crates' -r -f 11 | complete -c ferrishot -s l -l last-region -d 'Use last region' 12 | complete -c ferrishot -s D -l dump-default-config -d 'Write the default config to /home/e/.config/ferrishot.kdl' 13 | complete -c ferrishot -s S -l silent -d 'Run in silent mode' 14 | complete -c ferrishot -s j -l json -d 'Print in JSON format' 15 | complete -c ferrishot -l log-stderr -d 'Log to standard error instead of file' 16 | complete -c ferrishot -l debug -d 'Launch in debug mode (F12)' 17 | complete -c ferrishot -s h -l help -d 'Print help (see more with \'--help\')' 18 | complete -c ferrishot -s V -l version -d 'Print version' 19 | -------------------------------------------------------------------------------- /completions/ferrishot.md: -------------------------------------------------------------------------------- 1 | # Command-Line Help for `ferrishot` 2 | 3 | This document contains the help content for the `ferrishot` command-line program. 4 | 5 | **Command Overview:** 6 | 7 | - [`ferrishot`↴](#ferrishot) 8 | 9 | ## `ferrishot` 10 | 11 | A powerful screenshot app 12 | 13 | **Usage:** `ferrishot [OPTIONS]` 14 | 15 | ###### **Arguments:** 16 | 17 | - `` — Instead of taking a screenshot of the desktop, open this image instead 18 | 19 | ###### **Options:** 20 | 21 | - `-r`, `--region ` — Open with a region pre-selected 22 | 23 | Format: `x++` 24 | 25 | Each value can be absolute. 26 | 27 | - 550 for `x` means top-left corner starts after 550px 28 | - 100 for `height` means it will be 100px tall 29 | 30 | Each can also be relative to the height (for `y` and `height`) or width (for `width` and `x`) 31 | 32 | - 0.2 for `width` means it region takes up 20% of the width of the image. 33 | - 0.5 for `y` means the top-left corner will be at the vertical center 34 | 35 | The format can also end with 1 or 2 percentages, which shifts the region relative to the region's size 36 | 37 | - If `width` is `250`, end region with `+30%` to move right by 75px or `-40%` to move left by 100px 38 | - Supplying 2 percentage at the end like `+30%-10%`, the 1st affects x-offset and the 2nd affects y-offset 39 | 40 | With the above syntax, you can create all the regions you want. 41 | 42 | - `100x1.0+0.5+0-50%`: Create a 100px wide, full height, horizontally centered region 43 | - `1.0x1.0+0+0`: Create a region that spans the full screen. You can use alias `full` for this 44 | 45 | - `-l`, `--last-region` — Use last region 46 | - `-a`, `--accept-on-select ` — Accept capture and perform the action as soon as a selection is made 47 | 48 | If holding `ctrl` while you are releasing the left mouse button on the first selection, 49 | the behavior is cancelled 50 | 51 | It's quite useful to run ferrishot, select a region and have it instantly be copied to the 52 | clipboard for example. In 90% of situations you won't want to do much post-processing of 53 | the region and this makes that experience twice as fast. You can always opt-out with `ctrl` 54 | 55 | Using this option with `--region` or `--last-region` will run ferrishot in 'headless mode', 56 | without making a new window. 57 | 58 | Possible values: 59 | 60 | - `copy`: 61 | Copy image to the clipboard 62 | - `save`: 63 | Save image to a file 64 | - `upload`: 65 | Upload image to the internet 66 | 67 | - `-d`, `--delay ` — Wait this long before launch 68 | - `-s`, `--save-path ` — Instead of opening a file picker to save the screenshot, save it to this path instead 69 | - `-D`, `--dump-default-config` — Write contents of the default config to /home/e/.config/ferrishot.kdl 70 | - `-C`, `--config-file ` — Use the provided config file 71 | 72 | Default value: `/home/e/.config/ferrishot.kdl` 73 | 74 | - `-S`, `--silent` — Run in silent mode. Do not print anything 75 | - `-j`, `--json` — Print in JSON format 76 | -------------------------------------------------------------------------------- /completions/ferrishot.nu: -------------------------------------------------------------------------------- 1 | module completions { 2 | 3 | def "nu-complete ferrishot accept_on_select" [] { 4 | [ "copy" "save" "upload" ] 5 | } 6 | 7 | # A powerful screenshot app 8 | export extern ferrishot [ 9 | file?: path # Instead of taking a screenshot of the desktop, open this image instead 10 | --region(-r): string # Open with a region pre-selected 11 | --last-region(-l) # Use last region 12 | --accept-on-select(-a): string@"nu-complete ferrishot accept_on_select" # Accept on first selection 13 | --delay(-d): string # Wait this long before launch 14 | --save-path(-s): path # Save image to path 15 | --dump-default-config(-D) # Write the default config to /home/e/.config/ferrishot.kdl 16 | --config-file(-C): path # Use the provided config file 17 | --silent(-S) # Run in silent mode 18 | --json(-j) # Print in JSON format 19 | --log-level: string # Choose a miniumum level at which to log 20 | --log-stderr # Log to standard error instead of file 21 | --log-file: path # Path to the log file 22 | --log-filter: string # Filter for specific Rust module or crate, instead of showing logs from all crates 23 | --debug # Launch in debug mode (F12) 24 | --help(-h) # Print help (see more with '--help') 25 | --version(-V) # Print version 26 | ] 27 | 28 | } 29 | 30 | export use completions * 31 | -------------------------------------------------------------------------------- /completions/ferrishot.ts: -------------------------------------------------------------------------------- 1 | const completion: Fig.Spec = { 2 | name: "ferrishot", 3 | description: "A powerful screenshot app", 4 | options: [ 5 | { 6 | name: ["-r", "--region"], 7 | description: "Open with a region pre-selected", 8 | isRepeatable: true, 9 | args: { 10 | name: "region", 11 | isOptional: true, 12 | }, 13 | }, 14 | { 15 | name: ["-a", "--accept-on-select"], 16 | description: "Accept on first selection", 17 | isRepeatable: true, 18 | args: { 19 | name: "accept_on_select", 20 | isOptional: true, 21 | suggestions: [ 22 | { 23 | name: "copy", 24 | description: "Copy image to the clipboard", 25 | }, 26 | { 27 | name: "save", 28 | description: "Save image to a file", 29 | }, 30 | { 31 | name: "upload", 32 | description: "Upload image to the internet", 33 | }, 34 | ], 35 | }, 36 | }, 37 | { 38 | name: ["-d", "--delay"], 39 | description: "Wait this long before launch", 40 | isRepeatable: true, 41 | args: { 42 | name: "delay", 43 | isOptional: true, 44 | }, 45 | }, 46 | { 47 | name: ["-s", "--save-path"], 48 | description: "Save image to path", 49 | isRepeatable: true, 50 | args: { 51 | name: "save_path", 52 | isOptional: true, 53 | template: "filepaths", 54 | }, 55 | }, 56 | { 57 | name: ["-C", "--config-file"], 58 | description: "Use the provided config file", 59 | isRepeatable: true, 60 | args: { 61 | name: "config_file", 62 | isOptional: true, 63 | template: "filepaths", 64 | }, 65 | }, 66 | { 67 | name: "--log-level", 68 | description: "Choose a miniumum level at which to log", 69 | hidden: true, 70 | isRepeatable: true, 71 | args: { 72 | name: "log_level", 73 | isOptional: true, 74 | }, 75 | }, 76 | { 77 | name: "--log-file", 78 | description: "Path to the log file", 79 | hidden: true, 80 | isRepeatable: true, 81 | args: { 82 | name: "log_file", 83 | isOptional: true, 84 | template: "filepaths", 85 | }, 86 | }, 87 | { 88 | name: "--log-filter", 89 | description: "Filter for specific Rust module or crate, instead of showing logs from all crates", 90 | hidden: true, 91 | isRepeatable: true, 92 | args: { 93 | name: "log_filter", 94 | isOptional: true, 95 | }, 96 | }, 97 | { 98 | name: ["-l", "--last-region"], 99 | description: "Use last region", 100 | exclusiveOn: [ 101 | "-r", 102 | "--region", 103 | ], 104 | }, 105 | { 106 | name: ["-D", "--dump-default-config"], 107 | description: "Write the default config to /home/e/.config/ferrishot.kdl", 108 | }, 109 | { 110 | name: ["-S", "--silent"], 111 | description: "Run in silent mode", 112 | }, 113 | { 114 | name: ["-j", "--json"], 115 | description: "Print in JSON format", 116 | exclusiveOn: [ 117 | "-S", 118 | "--silent", 119 | ], 120 | }, 121 | { 122 | name: "--log-stderr", 123 | description: "Log to standard error instead of file", 124 | exclusiveOn: [ 125 | "-S", 126 | "--silent", 127 | ], 128 | }, 129 | { 130 | name: "--debug", 131 | description: "Launch in debug mode (F12)", 132 | }, 133 | { 134 | name: ["-h", "--help"], 135 | description: "Print help (see more with '--help')", 136 | }, 137 | { 138 | name: ["-V", "--version"], 139 | description: "Print version", 140 | }, 141 | ], 142 | args: { 143 | name: "file", 144 | isOptional: true, 145 | template: "filepaths", 146 | }, 147 | }; 148 | 149 | export default completion; 150 | -------------------------------------------------------------------------------- /completions/ferrishot.yaml: -------------------------------------------------------------------------------- 1 | name: ferrishot 2 | description: A powerful screenshot app 3 | flags: 4 | -a, --accept-on-select=: Accept on first selection 5 | -C, --config-file=: Use the provided config file 6 | -d, --delay=: Wait this long before launch 7 | -D, --dump-default-config: Write the default config to /home/e/.config/ferrishot.kdl 8 | -h, --help: Print help (see more with '--help') 9 | -j, --json: Print in JSON format 10 | -l, --last-region: Use last region 11 | -r, --region=: Open with a region pre-selected 12 | -s, --save-path=: Save image to path 13 | -S, --silent: Run in silent mode 14 | -V, --version: Print version 15 | completion: 16 | flag: 17 | accept-on-select: 18 | - "copy\tCopy image to the clipboard" 19 | - "save\tSave image to a file" 20 | - "upload\tUpload image to the internet" 21 | config-file: 22 | - $files 23 | save-path: 24 | - $files 25 | positional: 26 | - - $files 27 | -------------------------------------------------------------------------------- /default.kdl: -------------------------------------------------------------------------------- 1 | // Default config for ferrishot 2 | // 3 | // Create this file in the appropriate place with `ferrishot --dump-default-config` 4 | // 5 | // You can remove all of the defaults, and just keep your overrides 6 | // if you want to do that 7 | 8 | // Show the size indicator 9 | size-indicator #true 10 | // Show icons around the selection 11 | selection-icons #true 12 | 13 | keys { 14 | // Leave the app 15 | exit key= 16 | 17 | // Copies selected region to clipboard, exiting 18 | copy-to-clipboard mod=ctrl key=c 19 | copy-to-clipboard key= 20 | 21 | // Save to a file 22 | save-screenshot mod=ctrl key=s 23 | 24 | // Upload and make a link 25 | upload-screenshot mod=ctrl key=u 26 | 27 | // Set selection to be the entire screen 28 | // You can use the syntax of `ferrishot --region` here (see `--help` for more info) 29 | select-region "full" key= 30 | 31 | // Remove the selection 32 | clear-selection mod=ctrl key=x 33 | 34 | // These 2 commands let you pick any area on the screen in 8 keystrokes 35 | pick-top-left-corner key=t 36 | pick-bottom-right-corner key=b 37 | 38 | open-keybindings-cheatsheet key=? 39 | 40 | // Set width/height to whatever is the current count. 41 | // You can change the count by just writing numbers. e.g. type `100X` to set 42 | // the width to 100px 43 | set-width key=X 44 | set-height key=Y 45 | 46 | // move the selection in a direction by 1px 47 | move left 1 key=h 48 | move left 1 key= 49 | move down 1 key=j 50 | move down 1 key= 51 | move up 1 key=k 52 | move up 1 key= 53 | move right 1 key=l 54 | move right 1 key= 55 | 56 | // extend a side by 1px 57 | extend left 1 key=H 58 | extend left 1 mod=shift key= 59 | extend down 1 key=J 60 | extend down 1 mod=shift key= 61 | extend up 1 key=K 62 | extend up 1 mod=shift key= 63 | extend right 1 key=L 64 | extend right 1 mod=shift key= 65 | 66 | // shrink a side by 1px 67 | shrink left 1 mod=ctrl key=h 68 | shrink left 1 mod=ctrl key= 69 | shrink down 1 mod=ctrl key=j 70 | shrink down 1 mod=ctrl key= 71 | shrink up 1 mod=ctrl key=k 72 | shrink up 1 mod=ctrl key= 73 | shrink right 1 mod=ctrl key=l 74 | shrink right 1 mod=ctrl key= 75 | 76 | // move rectangle in direction by 125px 77 | move left 125 mod=alt key=h 78 | move left 125 mod=alt key= 79 | move down 125 mod=alt key=j 80 | move down 125 mod=alt key= 81 | move up 125 mod=alt key=k 82 | move up 125 mod=alt key= 83 | move right 125 mod=alt key=l 84 | move right 125 mod=alt key= 85 | 86 | // extend a side by 125px 87 | extend left 125 mod=alt key=H 88 | extend left 125 mod=alt+shift key= 89 | extend down 125 mod=alt key=J 90 | extend down 125 mod=alt+shift key= 91 | extend up 125 mod=alt key=K 92 | extend up 125 mod=alt+shift key= 93 | extend right 125 mod=alt key=L 94 | extend right 125 mod=alt+shift key= 95 | 96 | // shrink a side by 125px 97 | shrink left 125 mod=ctrl+alt key=h 98 | shrink left 125 mod=ctrl+alt key= 99 | shrink down 125 mod=ctrl+alt key=j 100 | shrink down 125 mod=ctrl+alt key= 101 | shrink up 125 mod=ctrl+alt key=k 102 | shrink up 125 mod=ctrl+alt key= 103 | shrink right 125 mod=ctrl+alt key=l 104 | shrink right 125 mod=ctrl+alt key= 105 | 106 | // move selection as far as it can go 107 | move left key=gh 108 | move left key=g 109 | move down key=gj 110 | move down key=g 111 | move up key=gk 112 | move up key=g 113 | move right key=gl 114 | move right key=g 115 | 116 | // teleport the selection to a place 117 | goto top-left key=gg 118 | goto bottom-right key=G 119 | goto center key=gc 120 | goto x-center key=gx 121 | goto y-center key=gy 122 | 123 | // for debugging / development 124 | toggle-debug-overlay key= 125 | } 126 | 127 | // editing the `theme` section allows you to fully customize the appearance of ferrishot 128 | 129 | theme { 130 | // Backslash `\` lets you split it the palette over multiple lines 131 | palette \ 132 | accent = 0xab_61_37 \ 133 | fg = 0xff_ff_ff \ 134 | bg = 0x00_00_00 135 | 136 | // color of the frame around the selection 137 | // 138 | // Uses the `accent` color from the `palette` 139 | selection-frame accent 140 | 141 | // background color of the region that is not selected 142 | non-selected-region bg opacity=0.5 143 | 144 | // small drop shadow used, an example is around the selection and also 145 | // around icons surrounding the selection 146 | drop-shadow bg opacity=0.5 147 | 148 | // selected text, for instance when editing the size indicator 149 | text-selection accent opacity=0.3 150 | 151 | size-indicator-fg fg 152 | size-indicator-bg bg opacity=0.5 153 | 154 | tooltip-fg fg 155 | tooltip-bg bg 156 | 157 | error-fg fg 158 | // Use a custom hex color 159 | error-bg 0xff_00_00 opacity=0.6 160 | 161 | info-box-fg fg 162 | info-box-border fg 163 | info-box-bg accent opacity=0.95 164 | 165 | icon-fg fg 166 | icon-bg accent 167 | 168 | // letters let you pick any region of the screen in 8 clicks 169 | // keys: t (top left corner), b (bottom right corner) 170 | letters-lines fg 171 | letters-bg bg opacity=0.6 172 | letters-fg fg 173 | 174 | // image uploaded popup (ctrl + U) 175 | image-uploaded-fg fg 176 | image-uploaded-bg bg opacity=0.9 177 | 178 | // for example, the checkmark when you copy to clipboard 179 | success 0x00_ff_00 180 | 181 | cheatsheet-bg bg 182 | cheatsheet-fg fg 183 | 184 | popup-close-icon-bg bg opacity=0.0 185 | popup-close-icon-fg fg 186 | 187 | // debug menu, for development (F12) 188 | debug-fg fg 189 | debug-label 0xff_00_00 190 | debug-bg bg opacity=0.9 191 | } 192 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "powershell", "homebrew", "msi"] 12 | # A GitHub repo to push Homebrew formulas to 13 | tap = "nik-rev/homebrew-tap" 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 16 | # Path that installers should place binaries in 17 | install-path = "CARGO_HOME" 18 | # Publish jobs to run in CI 19 | publish-jobs = ["homebrew"] 20 | # Whether to install an updater program 21 | install-updater = true 22 | # Which actions to run on pull requests 23 | pr-run-mode = "skip" 24 | 25 | [dist.github-custom-runners] 26 | x86_64-unknown-linux-gnu = "ubuntu-24.04" 27 | aarch64-unknown-linux-gnu = "ubuntu-24.04" 28 | global = "ubuntu-latest" 29 | 30 | [dist.dependencies.homebrew] 31 | lld = "*" 32 | 33 | [dist.dependencies.apt] 34 | libgl-dev = "*" 35 | libx11-dev = "*" 36 | libx11-xcb-dev = "*" 37 | libwayland-dev = "*" 38 | -------------------------------------------------------------------------------- /docgen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "docgen" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | ferrishot = { path = ".." } 8 | clap = { version = "4.5.35", features = ["derive"] } 9 | clap_complete = "4.5.49" 10 | clap_complete_nushell = "4.5.5" 11 | clap-markdown = "0.1.5" 12 | carapace_spec_clap = "1.1.0" 13 | clap_complete_fig = "4.5.2" 14 | clap_mangen = "0.2.26" 15 | -------------------------------------------------------------------------------- /docgen/src/main.rs: -------------------------------------------------------------------------------- 1 | //! Generate completions, docs, etc 2 | use std::{fs::File, io::Write, path::PathBuf}; 3 | 4 | use clap::{CommandFactory, ValueEnum}; 5 | use clap_complete::generate_to; 6 | use clap_markdown::MarkdownOptions; 7 | use ferrishot::Cli; 8 | 9 | fn main() { 10 | let mut cmd = Cli::command(); 11 | let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 12 | .join("..") 13 | .join("completions"); 14 | 15 | std::fs::create_dir_all(&out_dir).unwrap(); 16 | 17 | // shell completions 18 | for shell in clap_complete::Shell::value_variants() { 19 | generate_to(*shell, &mut cmd, "ferrishot", &out_dir).unwrap(); 20 | } 21 | generate_to( 22 | clap_complete_nushell::Nushell, 23 | &mut cmd, 24 | "ferrishot", 25 | &out_dir, 26 | ) 27 | .unwrap(); 28 | generate_to(carapace_spec_clap::Spec, &mut cmd, "ferrishot", &out_dir).unwrap(); 29 | generate_to(clap_complete_fig::Fig, &mut cmd, "ferrishot", &out_dir).unwrap(); 30 | 31 | // markdown help 32 | File::create(out_dir.join("ferrishot.md")) 33 | .unwrap() 34 | .write_all( 35 | clap_markdown::help_markdown_custom::(&MarkdownOptions::new().show_footer(false)) 36 | .as_bytes(), 37 | ) 38 | .unwrap(); 39 | 40 | // man page 41 | clap_mangen::generate_to(Cli::command(), out_dir).unwrap(); 42 | } 43 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Nik Revenco"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "ferrishot" 7 | 8 | [output.html] 9 | cname = "ferrishot.com" 10 | additional-css = ["./theme/theme.css", "./mdbook-admonish.css"] 11 | default-theme = "light" 12 | preferred-dark-theme = "light" 13 | git-repository-url = "https://github.com/nik-rev/ferrishot" 14 | edit-url-template = "https://github.com/nik-rev/ferrishot/edit/main/docs/{path}" 15 | 16 | [build] 17 | build-dir = "docs" 18 | 19 | [preprocessor.admonish] 20 | command = "mdbook-admonish" 21 | assets_version = "3.0.3" # do not edit: managed by `mdbook-admonish install` 22 | [preprocessor.alerts] 23 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Installation](./installation.md) 4 | - [Usage](./usage.md) 5 | - [Config](./config/README.md) 6 | - [Options](./config/options.md) 7 | - [Keybindings](./config/keybindings.md) 8 | - [Theme](./config/theme.md) 9 | -------------------------------------------------------------------------------- /docs/src/config/README.md: -------------------------------------------------------------------------------- 1 | # Config 2 | -------------------------------------------------------------------------------- /docs/src/config/keybindings.md: -------------------------------------------------------------------------------- 1 | # Keybindings 2 | -------------------------------------------------------------------------------- /docs/src/config/options.md: -------------------------------------------------------------------------------- 1 | # Options 2 | -------------------------------------------------------------------------------- /docs/src/config/theme.md: -------------------------------------------------------------------------------- 1 | # Theme 2 | -------------------------------------------------------------------------------- /docs/src/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Ferrishot is available on Windows, Linux and MacOS. 4 | 5 | ## Homebrew 6 | 7 | ```sh 8 | brew install nik-rev/tap/ferrishot 9 | ``` 10 | 11 | ## PowerShell 12 | 13 | ```sh 14 | powershell -ExecutionPolicy Bypass -c "irm https://github.com/nik-rev/ferrishot/releases/latest/download/ferrishot-installer.ps1 | iex" 15 | ``` 16 | 17 | ## Shell 18 | 19 | ```sh 20 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nik-rev/ferrishot/releases/latest/download/ferrishot-installer.sh | sh 21 | ``` 22 | 23 | ## Nix 24 | 25 | Add it to your `flake.nix`: 26 | 27 | ```nix 28 | # add it to your inputs 29 | inputs.ferrishot.url = "github:nik-rev/ferrishot/main"; 30 | # then use it in home-manager for example 31 | inputs.ferrishot.packages.${pkgs.system}.default 32 | ``` 33 | 34 | ## Cargo 35 | 36 | If you use Linux, see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for details on which dependencies you will need. 37 | 38 | ```sh 39 | cargo install ferrishot 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/src/selecting_initial.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nik-rev/ferrishot/05cc226dfc960dfc782fa1fce19d9da89dfc948e/docs/src/selecting_initial.mp4 -------------------------------------------------------------------------------- /docs/src/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Launch ferrishot by writing `ferrishot` on the command line. 4 | 5 | Select an area of the screen 6 | 7 | 10 | 11 | This selection is the _screenshot_ which you will take. 12 | 13 | There are several actions you can do by pressing buttons. Notably:: 14 | 15 | - Save screenshot to a file: **Ctrl + S**. 16 | - Upload screenshot to the internet: **Ctrl + U** 17 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "id": "flake-utils", 17 | "type": "indirect" 18 | } 19 | }, 20 | "nixpkgs": { 21 | "locked": { 22 | "lastModified": 1743964447, 23 | "narHash": "sha256-nEo1t3Q0F+0jQ36HJfbJtiRU4OI+/0jX/iITURKe3EE=", 24 | "owner": "NixOS", 25 | "repo": "nixpkgs", 26 | "rev": "063dece00c5a77e4a0ea24e5e5a5bd75232806f8", 27 | "type": "github" 28 | }, 29 | "original": { 30 | "owner": "NixOS", 31 | "ref": "nixos-unstable", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "root": { 37 | "inputs": { 38 | "flake-utils": "flake-utils", 39 | "nixpkgs": "nixpkgs" 40 | } 41 | }, 42 | "systems": { 43 | "locked": { 44 | "lastModified": 1681028828, 45 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 46 | "owner": "nix-systems", 47 | "repo": "default", 48 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "nix-systems", 53 | "repo": "default", 54 | "type": "github" 55 | } 56 | } 57 | }, 58 | "root": "root", 59 | "version": 7 60 | } 61 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | }; 5 | 6 | outputs = 7 | { 8 | nixpkgs, 9 | flake-utils, 10 | ... 11 | }: 12 | flake-utils.lib.eachDefaultSystem ( 13 | system: 14 | let 15 | pkgs = import nixpkgs { 16 | inherit system; 17 | }; 18 | manifest = pkgs.lib.importTOML ./Cargo.toml; 19 | buildInputs = with pkgs; [ 20 | # required for the derivation 21 | makeWrapper 22 | 23 | # makes it more performant 24 | libGL 25 | 26 | # required with wayland 27 | wayland 28 | # required on Linux 29 | xorg.libxcb 30 | xorg.libX11 31 | libxkbcommon 32 | ]; 33 | in 34 | { 35 | devShells.default = pkgs.mkShell { 36 | buildInputs = 37 | buildInputs 38 | ++ (with pkgs; [ 39 | cargo 40 | rustc 41 | rustfmt 42 | rustPackages.clippy 43 | rust-analyzer 44 | bacon 45 | ]); 46 | LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs; 47 | }; 48 | packages.default = pkgs.rustPlatform.buildRustPackage { 49 | pname = manifest.package.name; 50 | version = manifest.package.version; 51 | 52 | src = pkgs.lib.cleanSource ./.; 53 | cargoLock.lockFile = ./Cargo.lock; 54 | 55 | inherit buildInputs; 56 | 57 | postFixup = '' 58 | wrapProgram $out/bin/ferrishot \ 59 | --suffix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath buildInputs} 60 | ''; 61 | }; 62 | formatter = pkgs.alejandra; 63 | } 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /key.txt: -------------------------------------------------------------------------------- 1 | ctrl j 2 | 3 | ```rs 4 | [src/canvas.rs:115:13] event = Keyboard( 5 | KeyPressed { 6 | key: Character( 7 | "j", 8 | ), 9 | modified_key: Character( 10 | "j", 11 | ), 12 | physical_key: Code( 13 | KeyJ, 14 | ), 15 | location: Standard, 16 | modifiers: Modifiers( 17 | CTRL, 18 | ), 19 | text: Some( 20 | "\n", 21 | ), 22 | }, 23 | ) 24 | ``` 25 | 26 | ctrl alt j 27 | 28 | ```rs 29 | [src/canvas.rs:115:13] event = Keyboard( 30 | KeyPressed { 31 | key: Character( 32 | "j", 33 | ), 34 | modified_key: Character( 35 | "j", 36 | ), 37 | physical_key: Code( 38 | KeyJ, 39 | ), 40 | location: Standard, 41 | modifiers: Modifiers( 42 | CTRL | ALT, 43 | ), 44 | text: Some( 45 | "\n", 46 | ), 47 | }, 48 | ) 49 | ``` 50 | 51 | ctrl alt J 52 | 53 | ```rs 54 | [src/canvas.rs:115:13] event = Keyboard( 55 | KeyPressed { 56 | key: Character( 57 | "j", 58 | ), 59 | modified_key: Character( 60 | "J", 61 | ), 62 | physical_key: Code( 63 | KeyJ, 64 | ), 65 | location: Standard, 66 | modifiers: Modifiers( 67 | SHIFT | CTRL | ALT, 68 | ), 69 | text: Some( 70 | "\n", 71 | ), 72 | }, 73 | ) 74 | ``` 75 | 76 | < 77 | 78 | ```rs 79 | [src/canvas.rs:115:13] event = Keyboard( 80 | KeyPressed { 81 | key: Character( 82 | ",", 83 | ), 84 | modified_key: Character( 85 | "<", 86 | ), 87 | physical_key: Code( 88 | Comma, 89 | ), 90 | location: Standard, 91 | modifiers: Modifiers( 92 | SHIFT, 93 | ), 94 | text: Some( 95 | "<", 96 | ), 97 | }, 98 | ) 99 | ``` 100 | 101 | ctrl < 102 | 103 | ```rs 104 | [src/canvas.rs:115:13] event = Keyboard( 105 | KeyPressed { 106 | key: Character( 107 | ",", 108 | ), 109 | modified_key: Character( 110 | "<", 111 | ), 112 | physical_key: Code( 113 | Comma, 114 | ), 115 | location: Standard, 116 | modifiers: Modifiers( 117 | SHIFT | CTRL, 118 | ), 119 | text: Some( 120 | "<", 121 | ), 122 | }, 123 | ) 124 | ``` 125 | 126 | ctrl alt < 127 | 128 | ```rs 129 | [src/canvas.rs:115:13] event = Keyboard( 130 | KeyPressed { 131 | key: Character( 132 | ",", 133 | ), 134 | modified_key: Character( 135 | "<", 136 | ), 137 | physical_key: Code( 138 | Comma, 139 | ), 140 | location: Standard, 141 | modifiers: Modifiers( 142 | SHIFT | CTRL | ALT, 143 | ), 144 | text: Some( 145 | "<", 146 | ), 147 | }, 148 | ) 149 | ``` 150 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | toolchain.channel = "1.86" 2 | toolchain.components = ["rust-analyzer", "cargo", "rustfmt", "clippy"] 3 | -------------------------------------------------------------------------------- /src/clipboard.rs: -------------------------------------------------------------------------------- 1 | //! Set clipboard to either: 2 | //! 3 | //! - PNG image 4 | //! - Text 5 | //! 6 | //! This module includes a small daemon for Linux that runs in the background, 7 | //! providing clipboard access. 8 | 9 | /// An argument that can be passed into the program to signal that it should daemonize itself. This 10 | /// can be anything as long as it is unlikely to be passed in by the user by mistake. 11 | #[cfg(target_os = "linux")] 12 | pub const CLIPBOARD_DAEMON_ID: &str = "__ferrishot_clipboard_daemon"; 13 | 14 | use std::{fs::File, io::Write as _}; 15 | 16 | /// Error with the clipboard 17 | #[derive(thiserror::Error, miette::Diagnostic, Debug)] 18 | pub enum ClipboardError { 19 | /// Arboard Error 20 | #[error(transparent)] 21 | Arboard(#[from] arboard::Error), 22 | /// IO Error 23 | #[error(transparent)] 24 | Io(#[from] std::io::Error), 25 | } 26 | 27 | /// Set the text content of the clipboard 28 | pub fn set_text(text: &str) -> Result<(), ClipboardError> { 29 | #[cfg(target_os = "linux")] 30 | { 31 | use std::process; 32 | process::Command::new(std::env::current_exe()?) 33 | .arg(CLIPBOARD_DAEMON_ID) 34 | .arg("text") 35 | .arg(text) 36 | .stdin(process::Stdio::null()) 37 | .stdout(process::Stdio::null()) 38 | .stderr(process::Stdio::null()) 39 | .current_dir("/") 40 | .spawn()?; 41 | } 42 | #[cfg(not(target_os = "linux"))] 43 | { 44 | arboard::Clipboard::new()?.set_text(text)?; 45 | } 46 | 47 | Ok(()) 48 | } 49 | 50 | /// Set the image content of the clipboard 51 | /// 52 | /// # Returns 53 | /// 54 | /// Temporary file of the saved image 55 | #[cfg_attr( 56 | target_os = "linux", 57 | expect( 58 | clippy::needless_pass_by_value, 59 | reason = "on non-linux it is passed by value" 60 | ) 61 | )] 62 | pub fn set_image(image_data: arboard::ImageData) -> Result { 63 | let clipboard_buffer_path = tempfile::Builder::new().keep(true).tempfile()?; 64 | let mut clipboard_buffer_file = File::create(&clipboard_buffer_path)?; 65 | clipboard_buffer_file.write_all(&image_data.bytes)?; 66 | 67 | #[cfg(target_os = "linux")] 68 | { 69 | use std::process; 70 | process::Command::new(std::env::current_exe()?) 71 | .arg(CLIPBOARD_DAEMON_ID) 72 | .arg("image") 73 | .arg(image_data.width.to_string()) 74 | .arg(image_data.height.to_string()) 75 | .arg(clipboard_buffer_path.path()) 76 | .stdin(process::Stdio::null()) 77 | .stdout(process::Stdio::null()) 78 | .stderr(process::Stdio::inherit()) 79 | .current_dir("/") 80 | .spawn()?; 81 | } 82 | #[cfg(not(target_os = "linux"))] 83 | { 84 | arboard::Clipboard::new()?.set_image(image_data)?; 85 | } 86 | 87 | Ok(clipboard_buffer_path.path().to_path_buf()) 88 | } 89 | 90 | /// Runs a process in the background that provides clipboard access, 91 | /// until the user copies something else into their clipboard. 92 | /// 93 | /// # Errors 94 | /// 95 | /// - Could not create a clipboard 96 | /// - Could not set the clipboard text 97 | /// 98 | /// # Panics 99 | /// 100 | /// Will panic if the daemon was invoked incorrectly. That's fine because 101 | /// it should only be invoked from this app, never from the outside. 102 | /// 103 | /// We expect that the daemon receives 4 arguments: 104 | /// 105 | /// 1. ID of the daemon 106 | /// 2. copy type: "image" or "text" 107 | /// 108 | /// if copy type is "image" we expect: 109 | /// 3. width of image 110 | /// 4. height of image 111 | /// 5. path to bytes of the image 112 | /// 113 | /// The image must be of valid width, height and byte amount 114 | /// if copy type is "text" we expect: 115 | /// 3. text content which should be copied to the clipboard 116 | #[cfg(target_os = "linux")] 117 | pub fn run_clipboard_daemon() -> Result<(), arboard::Error> { 118 | use arboard::SetExtLinux as _; 119 | use pretty_assertions::assert_eq; 120 | use std::fs; 121 | 122 | log::info!( 123 | "Spawned clipboard daemon with arguments: {:?}", 124 | std::env::args().collect::>() 125 | ); 126 | 127 | // skip program name 128 | let mut args = std::env::args().skip(1); 129 | 130 | assert_eq!( 131 | args.next().as_deref(), 132 | Some(CLIPBOARD_DAEMON_ID), 133 | "this function must be invoked from a daemon process" 134 | ); 135 | 136 | match args.next().expect("has copy type").as_str() { 137 | "image" => { 138 | let width = args 139 | .next() 140 | .expect("width") 141 | .parse::() 142 | .expect("valid image width"); 143 | let height = args 144 | .next() 145 | .expect("height") 146 | .parse::() 147 | .expect("valid image height"); 148 | let path = args.next().expect("image path"); 149 | let bytes: std::borrow::Cow<[u8]> = fs::read(&path).expect("image contents").into(); 150 | 151 | assert_eq!(args.next(), None, "unexpected extra args"); 152 | assert_eq!( 153 | width * height * 4, 154 | bytes.len(), 155 | "every 4 bytes in `bytes` represents a single RGBA pixel" 156 | ); 157 | 158 | arboard::Clipboard::new()? 159 | .set() 160 | .wait() 161 | .image(arboard::ImageData { 162 | width, 163 | height, 164 | bytes, 165 | })?; 166 | 167 | fs::remove_file(path).expect("failed to remove file"); 168 | } 169 | "text" => { 170 | let text = args.next().expect("text"); 171 | assert_eq!(args.next(), None, "unexpected extra args"); 172 | arboard::Clipboard::new()?.set().wait().text(text)?; 173 | } 174 | _ => panic!("invalid copy type, expected `image` or `text`"), 175 | } 176 | Ok(()) 177 | } 178 | -------------------------------------------------------------------------------- /src/config/cli.rs: -------------------------------------------------------------------------------- 1 | //! Parse the command line arguments passed to ferrishot 2 | use std::time::Duration; 3 | use std::{path::PathBuf, sync::LazyLock}; 4 | 5 | use clap::{Parser, ValueHint}; 6 | use etcetera::BaseStrategy as _; 7 | use indoc::indoc; 8 | 9 | use crate::lazy_rect::LazyRectangle; 10 | 11 | use anstyle::{AnsiColor, Effects}; 12 | 13 | /// Styles for the CLI 14 | const STYLES: clap::builder::Styles = clap::builder::Styles::styled() 15 | .header(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) 16 | .usage(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD)) 17 | .literal(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) 18 | .placeholder(AnsiColor::BrightCyan.on_default()) 19 | .error(AnsiColor::BrightRed.on_default().effects(Effects::BOLD)) 20 | .valid(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD)) 21 | .invalid(AnsiColor::BrightYellow.on_default().effects(Effects::BOLD)); 22 | 23 | /// Ferrishot is a powerful screenshot app written in Rust 24 | #[derive(Parser, Debug)] 25 | #[command(version, styles = STYLES, long_about = None)] 26 | #[expect(clippy::struct_excessive_bools, reason = "normal for CLIs")] 27 | pub struct Cli { 28 | /// Instead of taking a screenshot of the desktop, open this image instead 29 | // 30 | // NOTE: Currently disabled because if the screenshot is not the same size as the desktop, 31 | // it will cause bugs as we consider 0,0 in the Canvas to be the origin but it is not necessarily, 32 | // when the desktop and the image are not the same size 33 | // 34 | // TODO: Fix this argument 35 | // 36 | #[arg(hide = true, value_hint = ValueHint::FilePath)] 37 | pub file: Option, 38 | 39 | // 40 | // --- Options --- 41 | // 42 | /// Open with a region pre-selected 43 | /// 44 | /// Format: `x++` 45 | /// 46 | /// Each value can be absolute. 47 | /// - 550 for `x` means top-left corner starts after 550px 48 | /// - 100 for `height` means it will be 100px tall 49 | /// 50 | /// Each can also be relative to the height (for `y` and `height`) or width (for `width` and `x`) 51 | /// - 0.2 for `width` means it region takes up 20% of the width of the image. 52 | /// - 0.5 for `y` means the top-left corner will be at the vertical center 53 | /// 54 | /// The format can also end with 1 or 2 percentages, which shifts the region relative to the region's size 55 | /// - If `width` is `250`, end region with `+30%` to move right by 75px or `-40%` to move left by 100px 56 | /// - Supplying 2 percentage at the end like `+30%-10%`, the 1st affects x-offset and the 2nd affects y-offset 57 | /// 58 | /// With the above syntax, you can create all the regions you want. 59 | /// - `100x1.0+0.5+0-50%`: Create a 100px wide, full height, horizontally centered region 60 | /// - `1.0x1.0+0+0`: Create a region that spans the full screen. You can use alias `full` for this 61 | #[arg( 62 | short, 63 | long, 64 | value_name = "WxH+X+Y", 65 | value_hint = ValueHint::Other 66 | )] 67 | pub region: Option, 68 | 69 | /// Use last region 70 | #[arg(short, long, conflicts_with = "region")] 71 | pub last_region: bool, 72 | 73 | /// Accept capture and perform the action as soon as a selection is made 74 | /// 75 | /// If holding `ctrl` while you are releasing the left mouse button on the first selection, 76 | /// the behavior is cancelled 77 | /// 78 | /// It's quite useful to run ferrishot, select a region and have it instantly be copied to the 79 | /// clipboard for example. 80 | /// 81 | /// In 90% of situations you won't want to do much post-processing of 82 | /// the region and this makes that experience twice as fast. You can always opt-out with `ctrl` 83 | /// 84 | /// Using this option with `--region` or `--last-region` will run ferrishot in 'headless mode', 85 | /// without making a new window. 86 | #[arg(short, long, value_name = "ACTION")] 87 | pub accept_on_select: Option, 88 | 89 | /// Wait this long before launch 90 | #[arg( 91 | short, 92 | long, 93 | value_name = "MILLISECONDS", 94 | value_parser = |s: &str| s.parse().map(Duration::from_millis), 95 | value_hint = ValueHint::Other 96 | )] 97 | pub delay: Option, 98 | 99 | /// Save image to path 100 | #[arg( 101 | short, 102 | long, 103 | value_name = "PATH", 104 | long_help = "Instead of opening a file picker to save the screenshot, save it to this path instead", 105 | value_hint = ValueHint::FilePath 106 | )] 107 | pub save_path: Option, 108 | 109 | // 110 | // --- Config --- 111 | // 112 | /// Dump default config 113 | #[arg( 114 | help_heading = "Config", 115 | short = 'D', 116 | long, 117 | help = format!("Write the default config to {}", DEFAULT_CONFIG_FILE_PATH.display()), 118 | long_help = format!("Write contents of the default config to {}", DEFAULT_CONFIG_FILE_PATH.display()), 119 | )] 120 | pub dump_default_config: bool, 121 | 122 | /// Use the provided config file 123 | #[arg( 124 | help_heading = "Config", 125 | short = 'C', 126 | long, 127 | value_name = "FILE.KDL", 128 | default_value_t = DEFAULT_CONFIG_FILE_PATH.to_string_lossy().to_string(), 129 | value_hint = ValueHint::FilePath 130 | )] 131 | pub config_file: String, 132 | 133 | // 134 | // --- Output 135 | // 136 | /// Run in silent mode 137 | #[arg( 138 | help_heading = "Output", 139 | short = 'S', 140 | long, 141 | long_help = "Run in silent mode. Do not print anything" 142 | )] 143 | pub silent: bool, 144 | 145 | /// Print in JSON format 146 | #[arg(help_heading = "Output", short, long, conflicts_with = "silent")] 147 | pub json: bool, 148 | 149 | // 150 | // --- Debug --- 151 | // 152 | // Requires ferrishot to be compiled with `debug` for them to show up in the CLI help 153 | // 154 | /// Choose a miniumum level at which to log 155 | #[arg( 156 | help_heading = "Debug", 157 | long, 158 | value_name = "LEVEL", 159 | default_value = "error", 160 | long_help = "Choose a miniumum level at which to log. [error, warn, info, debug, trace, off]", 161 | hide = !cfg!(feature = "debug") 162 | )] 163 | pub log_level: log::LevelFilter, 164 | 165 | /// Log to standard error instead of file 166 | #[arg( 167 | help_heading = "Debug", 168 | long, 169 | conflicts_with = "silent", 170 | hide = !cfg!(feature = "debug") 171 | )] 172 | pub log_stderr: bool, 173 | 174 | /// Path to the log file 175 | #[arg( 176 | help_heading = "Debug", 177 | long, 178 | value_name = "FILE", 179 | default_value_t = DEFAULT_LOG_FILE_PATH.to_string_lossy().to_string(), 180 | value_hint = ValueHint::FilePath, 181 | hide = !cfg!(feature = "debug") 182 | )] 183 | pub log_file: String, 184 | 185 | /// Filter for specific Rust module or crate, instead of showing logs from all crates 186 | #[arg( 187 | help_heading = "Debug", 188 | long, 189 | value_name = "FILTER", 190 | value_hint = ValueHint::Other, 191 | hide = !cfg!(feature = "debug") 192 | )] 193 | pub log_filter: Option, 194 | 195 | /// Launch in debug mode (F12) 196 | #[arg( 197 | help_heading = "Debug", 198 | long, 199 | hide = !cfg!(feature = "debug") 200 | )] 201 | pub debug: bool, 202 | } 203 | 204 | /// Represents the default location of the config file 205 | static DEFAULT_CONFIG_FILE_PATH: LazyLock = LazyLock::new(|| { 206 | etcetera::choose_base_strategy().map_or_else( 207 | |err| { 208 | log::warn!("Could not determine the config directory: {err}"); 209 | PathBuf::from("ferrishot.kdl") 210 | }, 211 | |strategy| strategy.config_dir().join("ferrishot.kdl"), 212 | ) 213 | }); 214 | 215 | /// Represents the default location of the config file 216 | pub static DEFAULT_LOG_FILE_PATH: LazyLock = LazyLock::new(|| { 217 | etcetera::choose_base_strategy().map_or_else( 218 | |err| { 219 | log::warn!("Could not determine the config directory: {err}"); 220 | PathBuf::from("ferrishot.log") 221 | }, 222 | |strategy| strategy.cache_dir().join("ferrishot.log"), 223 | ) 224 | }); 225 | -------------------------------------------------------------------------------- /src/config/commands.rs: -------------------------------------------------------------------------------- 1 | //! This module contains macros that define the different `Command`s that are available. 2 | //! 3 | //! # Adding new commands 4 | //! 5 | //! - Invoke the `declare_commands!` macro from any module to generate commands which 6 | //! will affect the `&mut App`. 7 | //! - Add the module to the `declare_global_commands!` macro invocation below 8 | //! - Implement the [`crate::command::Handler`] trait for the `Command` generated by `declare_commands!`. 9 | 10 | use super::key_map::{KeyMods, KeySequence}; 11 | use crate::ui; 12 | use ui::popup::keybindings_cheatsheet; 13 | 14 | /// Handles commands which mutate state of the application. 15 | /// 16 | /// A `Command` is a subset of a `Message` which can be bound to a keybinding, and 17 | /// can therefore receive a `count`. 18 | /// 19 | /// You should use this as `crate::command::Handler`. 20 | pub trait CommandHandler { 21 | /// Handle the invoked command, mutating the `App`. 22 | /// 23 | /// Some commands will behave differently depending on the value of `count`. 24 | /// `count` represents a number that the user has typed. 25 | /// 26 | /// If the `j` key is bound to move down by 1px, typing `200j` will execute 27 | /// whatever `j` is bound to 200 times, so move down by 200px. 28 | fn handle(self, app: &mut crate::App, count: u32) -> iced::Task; 29 | } 30 | 31 | /// Create keybindings, specifying the arguments it receives as named fields. 32 | /// Each keybind is declared like this: 33 | /// 34 | /// ```text 35 | /// Keybind { 36 | /// a: u32 37 | /// b: bool 38 | /// c: f32 39 | /// d: String 40 | /// } 41 | /// ``` 42 | /// 43 | /// The above creates a new keybind that will take 4 arguments in order, of the respective types. 44 | /// It can be used in the `config.kdl` file like so: 45 | /// 46 | /// ```kdl 47 | /// keys { 48 | /// keybind 10 #false 0.8 hello key=g mods=ctrl 49 | /// } 50 | /// ``` 51 | /// 52 | /// Which generates a structure like so, when parsed: 53 | /// 54 | /// ```no_compile 55 | /// Key::Keybind(10, false, 0.8, "hello", KeySequence("g", None), KeyMods::CTRL) 56 | /// ``` 57 | #[macro_export] 58 | macro_rules! declare_commands { 59 | ( 60 | $(#[$Command_Attr:meta])* 61 | enum $Command:ident { 62 | $( 63 | $(#[$Keymappable_Command_Attr:meta])* 64 | $Keymappable_Command:ident $({$( 65 | $(#[$Command_Argument_Attr:meta])* 66 | $Command_Argument:ident: $Command_Argument_Ty:ty $(= $Command_Argument_Default:expr)?, 67 | )+})? 68 | ),* $(,)? 69 | } 70 | ) => { 71 | $( 72 | $(#[$Keymappable_Command_Attr])* 73 | #[derive(ferrishot_knus::Decode, Debug, Clone)] 74 | pub struct $Keymappable_Command { 75 | $($( 76 | $(#[$Command_Argument_Attr])* 77 | $(#[ferrishot_knus(default = $Command_Argument_Default)])? 78 | #[ferrishot_knus(argument)] 79 | $Command_Argument: $Command_Argument_Ty, 80 | )+)? 81 | #[ferrishot_knus(property(name = "key"), str)] 82 | keys: $crate::config::key_map::KeySequence, 83 | #[ferrishot_knus(default, property(name = "mod"), str)] 84 | mods: $crate::config::key_map::KeyMods, 85 | } 86 | )* 87 | 88 | /// Contains a couple of commands, which are specific to this module. 89 | /// 90 | /// See [`Command`](crate::config::commands::Command) for more info. 91 | #[allow(clippy::derive_partial_eq_without_eq, reason = "f32 cannot derive `Eq`")] 92 | #[derive(Debug, Clone, PartialEq, Copy)] 93 | $(#[$Command_Attr])* 94 | pub enum $Command { 95 | $( 96 | $(#[$Keymappable_Command_Attr])* 97 | $Keymappable_Command $( 98 | { 99 | $( 100 | $Command_Argument: $Command_Argument_Ty, 101 | )* 102 | } 103 | )?, 104 | )* 105 | } 106 | 107 | /// Parses the corresponding commands in the KDL file. 108 | /// 109 | /// See [`KeymappableCommand`](crate::config::commands::KeymappableCommand) for more info. 110 | #[derive(ferrishot_knus::Decode, Debug, Clone)] 111 | pub enum KeymappableCommand { 112 | $( 113 | $Keymappable_Command($Keymappable_Command), 114 | )* 115 | } 116 | 117 | impl KeymappableCommand { 118 | /// # Returns 119 | /// 120 | /// The keys necessary to trigger the `Command`, as well as the `Command` itself. 121 | /// This is a key-value pair which will be stored in the `KeyMap`. 122 | pub fn action(self) -> (($crate::config::key_map::KeySequence, $crate::config::key_map::KeyMods), Command) { 123 | match self { 124 | $( 125 | Self::$Keymappable_Command($Keymappable_Command { 126 | $( 127 | $($Command_Argument,)* 128 | )? 129 | keys, 130 | mods 131 | }) => { 132 | ( 133 | (keys, mods), 134 | Command::$Keymappable_Command$({ 135 | $($Command_Argument),* 136 | })? 137 | ) 138 | }, 139 | )* 140 | } 141 | } 142 | } 143 | } 144 | } 145 | 146 | /// Declare commands for the entire app 147 | /// 148 | /// The commands compose commands from everywhere across the app, collected into a single place. 149 | macro_rules! declare_global_commands { 150 | ( 151 | $(#[$CommandAttr:meta])* 152 | enum $CommandIdent:ident, 153 | 154 | $(#[$EnumAttr:meta])* 155 | enum $EnumIdent:ident { 156 | $( 157 | $(#[doc = $VariantDoc:literal])* 158 | $EnumVariant:ident($($InnerCommand:ident)::+) 159 | ),* $(,)? 160 | } 161 | ) => { 162 | $(#[$CommandAttr])* 163 | pub enum $CommandIdent { 164 | $( 165 | $(#[doc = $VariantDoc])* 166 | $EnumVariant($($InnerCommand)::+::Command), 167 | )* 168 | } 169 | 170 | impl $crate::command::Handler for $CommandIdent { 171 | fn handle(self, app: &mut $crate::App, count: u32) -> iced::Task<$crate::Message> { 172 | match self { 173 | $( 174 | Self::$EnumVariant(cmd) => cmd.handle(app, count), 175 | )* 176 | } 177 | } 178 | } 179 | 180 | $(#[$EnumAttr])* 181 | pub enum $EnumIdent { 182 | $( 183 | $(#[doc = $VariantDoc])* 184 | #[ferrishot_knus(transparent)] 185 | $EnumVariant($($InnerCommand)::+::KeymappableCommand), 186 | )* 187 | } 188 | 189 | impl $EnumIdent { 190 | /// Key sequence required for this command 191 | pub fn action(self) -> ((KeySequence, KeyMods), Command) { 192 | match self { 193 | $( 194 | Self::$EnumVariant(cmd) => { 195 | let (keys, cmd) = cmd.action(); 196 | (keys, $CommandIdent::$EnumVariant(cmd)) 197 | }, 198 | )* 199 | } 200 | } 201 | } 202 | }; 203 | } 204 | 205 | declare_global_commands! { 206 | /// The `Command` is triggered by a series of key presses. 207 | /// 208 | /// We store a map from key press to the `Command` on the `App`. 209 | /// 210 | /// When the `Command` is obtained, we send a `Message::Command` which contains 211 | /// payload representing the `Command` that we invoked, as well as the curretn `count` 212 | /// which lets the user input a number before running a command, which will execute it 213 | /// that many times. For instance, `200j` executes whatever is bound to `j` 200 times. 214 | #[derive(Debug, Clone)] 215 | enum Command, 216 | 217 | /// This is the "raw" command, we get a `Vec` of it when we read the KDL config file. 218 | /// 219 | /// ```kdl 220 | /// keys { 221 | /// // contains all of the possible `KeymappableCommand` variants 222 | /// } 223 | /// ``` 224 | /// 225 | /// A `Vec` gets collected into a `Map`. 226 | /// Which gets stored on the `App`. 227 | #[derive(Debug, Clone, ferrishot_knus::Decode)] 228 | enum KeymappableCommand { 229 | /// Image Upload 230 | ImageUpload(crate::image::action), 231 | /// App 232 | App(ui::app), 233 | /// Debug overlay 234 | DebugOverlay(ui::debug_overlay), 235 | /// Keybindings Cheatsheet 236 | KeybindingsCheatsheet(keybindings_cheatsheet), 237 | /// Letters 238 | Letters(ui::popup::letters), 239 | /// Selection 240 | Selection(ui::selection), 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | //! Configuration of ferrishot 2 | //! 3 | //! Uses KDL as the configuration language 4 | //! 5 | //! The user's config (`UserKdlConfig`) is merged into the default Kdl configuration 6 | //! (`DefaultKdlConfig`). Both of these structs and more are created in this file using 7 | //! macros found in `macros.rs`. The macros are necessary to avoid a lot of boilerplate. 8 | //! 9 | //! The `DefaultKdlConfig` is then transformed into a `Config` by doing a little bit of 10 | //! extra processing for things that could not be trivially determined during deserialization. 11 | //! 12 | //! Such as: 13 | //! - Converting the list of keybindings into a structured `KeyMap` which can be indexed `O(1)` to 14 | //! obtain the `Message` to execute for that action. 15 | //! - Adding opacity to colors 16 | 17 | #[cfg(test)] 18 | mod tests; 19 | 20 | pub mod cli; 21 | pub mod commands; 22 | pub mod key_map; 23 | mod named_key; 24 | mod options; 25 | mod theme; 26 | 27 | use crate::config::key_map::KeyMap; 28 | pub use crate::config::theme::{Color, Theme}; 29 | 30 | pub use cli::Cli; 31 | use miette::miette; 32 | 33 | use std::fs; 34 | use std::path::PathBuf; 35 | 36 | use options::{DefaultKdlConfig, UserKdlConfig}; 37 | 38 | pub use cli::DEFAULT_LOG_FILE_PATH; 39 | pub use options::Config; 40 | 41 | /// The default configuration for ferrishot, to be merged with the user's config 42 | /// 43 | /// When modifying any of the config options, this will also need to be updated 44 | pub const DEFAULT_KDL_CONFIG_STR: &str = include_str!("../../default.kdl"); 45 | 46 | impl Config { 47 | /// # Errors 48 | /// 49 | /// Default config, or the user's config is invalid 50 | pub fn parse(user_config: &str) -> Result { 51 | let config_file_path = PathBuf::from(user_config); 52 | 53 | let default_config = 54 | ferrishot_knus::parse::("", DEFAULT_KDL_CONFIG_STR)?; 55 | 56 | let user_config = ferrishot_knus::parse::( 57 | &user_config, 58 | // if there is no config file, act as if it's simply empty 59 | &fs::read_to_string(&config_file_path).unwrap_or_default(), 60 | )?; 61 | 62 | default_config 63 | .merge_user_config(user_config) 64 | .try_into() 65 | .map_err(|err| miette!("{err}")) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/config/named_key.rs: -------------------------------------------------------------------------------- 1 | //! Named keys 2 | 3 | /// Parse user key named keys. 4 | /// 5 | /// Define an enum which maps to iced keys. Allows to change 6 | /// how certain variants serialize 7 | macro_rules! named_keys { 8 | ( 9 | $(#[$NamedAttr:meta])* 10 | enum $Named:ident { 11 | $($Key:ident $(= $renamed:literal)?),* $(,)? 12 | } 13 | ) => { 14 | $(#[$NamedAttr])* 15 | pub enum $Named { 16 | $( 17 | #[doc = concat!("The ", stringify!($Key), " key")] 18 | $(#[strum(serialize = $renamed)])? 19 | $Key 20 | ),* 21 | } 22 | impl $Named { 23 | /// Convert this key to an Iced instance 24 | pub const fn to_iced(self) -> iced::keyboard::key::Named { 25 | match self { 26 | $(Self::$Key => iced::keyboard::key::Named::$Key),* 27 | } 28 | } 29 | } 30 | }; 31 | } 32 | 33 | named_keys! { 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, strum::EnumString, strum::EnumIter)] 35 | #[strum(serialize_all = "kebab-case")] 36 | #[expect( 37 | clippy::upper_case_acronyms, 38 | reason = "do not deviate from Iced's name" 39 | )] 40 | enum Named { 41 | ArrowDown = "down", 42 | ArrowLeft = "left", 43 | ArrowRight = "right", 44 | ArrowUp = "up", 45 | Escape = "esc", 46 | Alt, 47 | AltGraph, 48 | CapsLock, 49 | Control, 50 | Fn, 51 | FnLock, 52 | NumLock, 53 | ScrollLock, 54 | Shift, 55 | Symbol, 56 | SymbolLock, 57 | Meta, 58 | Hyper, 59 | Super, 60 | Enter, 61 | Tab, 62 | Space, 63 | End, 64 | Home, 65 | PageDown, 66 | PageUp, 67 | Backspace, 68 | Clear, 69 | Copy, 70 | CrSel, 71 | Cut, 72 | Delete, 73 | EraseEof, 74 | ExSel, 75 | Insert, 76 | Paste, 77 | Redo, 78 | Undo, 79 | Accept, 80 | Again, 81 | Attn, 82 | Cancel, 83 | ContextMenu, 84 | Execute, 85 | Find, 86 | Help, 87 | Pause, 88 | Play, 89 | Props, 90 | Select, 91 | ZoomIn, 92 | ZoomOut, 93 | BrightnessDown, 94 | BrightnessUp, 95 | Eject, 96 | LogOff, 97 | Power, 98 | PowerOff, 99 | PrintScreen, 100 | Hibernate, 101 | Standby, 102 | WakeUp, 103 | AllCandidates, 104 | Alphanumeric, 105 | CodeInput, 106 | Compose, 107 | Convert, 108 | FinalMode, 109 | GroupFirst, 110 | GroupLast, 111 | GroupNext, 112 | GroupPrevious, 113 | ModeChange, 114 | NextCandidate, 115 | NonConvert, 116 | PreviousCandidate, 117 | Process, 118 | SingleCandidate, 119 | HangulMode, 120 | HanjaMode, 121 | JunjaMode, 122 | Eisu, 123 | Hankaku, 124 | Hiragana, 125 | HiraganaKatakana, 126 | KanaMode, 127 | KanjiMode, 128 | Katakana, 129 | Romaji, 130 | Zenkaku, 131 | ZenkakuHankaku, 132 | Soft1, 133 | Soft2, 134 | Soft3, 135 | Soft4, 136 | ChannelDown, 137 | ChannelUp, 138 | Close, 139 | MailForward, 140 | MailReply, 141 | MailSend, 142 | MediaClose, 143 | MediaFastForward, 144 | MediaPause, 145 | MediaPlay, 146 | MediaPlayPause, 147 | MediaRecord, 148 | MediaRewind, 149 | MediaStop, 150 | MediaTrackNext, 151 | MediaTrackPrevious, 152 | New, 153 | Open, 154 | Print, 155 | Save, 156 | SpellCheck, 157 | Key11, 158 | Key12, 159 | AudioBalanceLeft, 160 | AudioBalanceRight, 161 | AudioBassBoostDown, 162 | AudioBassBoostToggle, 163 | AudioBassBoostUp, 164 | AudioFaderFront, 165 | AudioFaderRear, 166 | AudioSurroundModeNext, 167 | AudioTrebleDown, 168 | AudioTrebleUp, 169 | AudioVolumeDown, 170 | AudioVolumeUp, 171 | AudioVolumeMute, 172 | MicrophoneToggle, 173 | MicrophoneVolumeDown, 174 | MicrophoneVolumeUp, 175 | MicrophoneVolumeMute, 176 | SpeechCorrectionList, 177 | SpeechInputToggle, 178 | LaunchApplication1, 179 | LaunchApplication2, 180 | LaunchCalendar, 181 | LaunchContacts, 182 | LaunchMail, 183 | LaunchMediaPlayer, 184 | LaunchMusicPlayer, 185 | LaunchPhone, 186 | LaunchScreenSaver, 187 | LaunchSpreadsheet, 188 | LaunchWebBrowser, 189 | LaunchWebCam, 190 | LaunchWordProcessor, 191 | BrowserBack, 192 | BrowserFavorites, 193 | BrowserForward, 194 | BrowserHome, 195 | BrowserRefresh, 196 | BrowserSearch, 197 | BrowserStop, 198 | AppSwitch, 199 | Call, 200 | Camera, 201 | CameraFocus, 202 | EndCall, 203 | GoBack, 204 | GoHome, 205 | HeadsetHook, 206 | LastNumberRedial, 207 | Notification, 208 | MannerMode, 209 | VoiceDial, 210 | TV, 211 | TV3DMode, 212 | TVAntennaCable, 213 | TVAudioDescription, 214 | TVAudioDescriptionMixDown, 215 | TVAudioDescriptionMixUp, 216 | TVContentsMenu, 217 | TVDataService, 218 | TVInput, 219 | TVInputComponent1, 220 | TVInputComponent2, 221 | TVInputComposite1, 222 | TVInputComposite2, 223 | TVInputHDMI1, 224 | TVInputHDMI2, 225 | TVInputHDMI3, 226 | TVInputHDMI4, 227 | TVInputVGA1, 228 | TVMediaContext, 229 | TVNetwork, 230 | TVNumberEntry, 231 | TVPower, 232 | TVRadioService, 233 | TVSatellite, 234 | TVSatelliteBS, 235 | TVSatelliteCS, 236 | TVSatelliteToggle, 237 | TVTerrestrialAnalog, 238 | TVTerrestrialDigital, 239 | TVTimer, 240 | AVRInput, 241 | AVRPower, 242 | ColorF0Red, 243 | ColorF1Green, 244 | ColorF2Yellow, 245 | ColorF3Blue, 246 | ColorF4Grey, 247 | ColorF5Brown, 248 | ClosedCaptionToggle, 249 | Dimmer, 250 | DisplaySwap, 251 | DVR, 252 | Exit, 253 | FavoriteClear0, 254 | FavoriteClear1, 255 | FavoriteClear2, 256 | FavoriteClear3, 257 | FavoriteRecall0, 258 | FavoriteRecall1, 259 | FavoriteRecall2, 260 | FavoriteRecall3, 261 | FavoriteStore0, 262 | FavoriteStore1, 263 | FavoriteStore2, 264 | FavoriteStore3, 265 | Guide, 266 | GuideNextDay, 267 | GuidePreviousDay, 268 | Info, 269 | InstantReplay, 270 | Link, 271 | ListProgram, 272 | LiveContent, 273 | Lock, 274 | MediaApps, 275 | MediaAudioTrack, 276 | MediaLast, 277 | MediaSkipBackward, 278 | MediaSkipForward, 279 | MediaStepBackward, 280 | MediaStepForward, 281 | MediaTopMenu, 282 | NavigateIn, 283 | NavigateNext, 284 | NavigateOut, 285 | NavigatePrevious, 286 | NextFavoriteChannel, 287 | NextUserProfile, 288 | OnDemand, 289 | Pairing, 290 | PinPDown, 291 | PinPMove, 292 | PinPToggle, 293 | PinPUp, 294 | PlaySpeedDown, 295 | PlaySpeedReset, 296 | PlaySpeedUp, 297 | RandomToggle, 298 | RcLowBattery, 299 | RecordSpeedNext, 300 | RfBypass, 301 | ScanChannelsToggle, 302 | ScreenModeNext, 303 | Settings, 304 | SplitScreenToggle, 305 | STBInput, 306 | STBPower, 307 | Subtitle, 308 | Teletext, 309 | VideoModeNext, 310 | Wink, 311 | ZoomToggle, 312 | F1, 313 | F2, 314 | F3, 315 | F4, 316 | F5, 317 | F6, 318 | F7, 319 | F8, 320 | F9, 321 | F10, 322 | F11, 323 | F12, 324 | F13, 325 | F14, 326 | F15, 327 | F16, 328 | F17, 329 | F18, 330 | F19, 331 | F20, 332 | F21, 333 | F22, 334 | F23, 335 | F24, 336 | F25, 337 | F26, 338 | F27, 339 | F28, 340 | F29, 341 | F30, 342 | F31, 343 | F32, 344 | F33, 345 | F34, 346 | F35, 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/config/options.rs: -------------------------------------------------------------------------------- 1 | //! Declare the flat, top-level config options of ferrishot 2 | //! 3 | //! This module touches 4 | //! 5 | //! ```kdl 6 | //! 7 | //! ``` 8 | 9 | /// Declare config options 10 | /// 11 | /// `UserKdlConfig` is merged into `DefaultKdlConfig` before being processed 12 | /// into a `Config` 13 | #[macro_export] 14 | macro_rules! declare_config_options { 15 | ( 16 | $(#[$ConfigAttr:meta])* 17 | struct $Config:ident { 18 | $(#[$keys_doc:meta])* 19 | $keys:ident: $Keys:ty, 20 | $(#[$theme_doc:meta])* 21 | $theme:ident: $Theme:ty, 22 | $( 23 | $(#[$doc:meta])* 24 | $key:ident: $typ:ty 25 | ),* $(,)? 26 | } 27 | ) => { 28 | $(#[$ConfigAttr])* 29 | pub struct $Config { 30 | $(#[$theme_doc])* 31 | pub $theme: $Theme, 32 | $(#[$keys_doc])* 33 | pub $keys: $Keys, 34 | $( 35 | $(#[$doc])* 36 | pub $key: $typ, 37 | )* 38 | } 39 | 40 | /// The default config as read from the default config file, included as a static string in the binary. 41 | /// All values are required and must be specified 42 | #[derive(ferrishot_knus::Decode, Debug)] 43 | pub struct DefaultKdlConfig { 44 | /// The default keybindings of ferrishot 45 | #[ferrishot_knus(child)] 46 | pub $keys: $crate::config::key_map::Keys, 47 | /// The default theme of ferrishot 48 | #[ferrishot_knus(child)] 49 | pub $theme: super::theme::DefaultKdlTheme, 50 | $( 51 | $(#[$doc])* 52 | #[ferrishot_knus(child, unwrap(argument))] 53 | pub $key: $typ, 54 | )* 55 | } 56 | 57 | impl DefaultKdlConfig { 58 | /// Merge the user's top-level config options with the default options. 59 | /// User config options take priority. 60 | pub fn merge_user_config(mut self, user_config: UserKdlConfig) -> Self { 61 | $( 62 | self.$key = user_config.$key.unwrap_or(self.$key); 63 | )* 64 | // merge keybindings 65 | // 66 | // If the same keybinding is defined in the default theme and 67 | // the user theme, e.g. 68 | // 69 | // default: 70 | // 71 | // ```kdl 72 | // keys { 73 | // goto top-left key=gg 74 | // } 75 | // ``` 76 | // 77 | // user: 78 | // 79 | // ```kdl 80 | // keys { 81 | // goto bottom-right key=gg 82 | // } 83 | // ``` 84 | // 85 | // The user's keybinding will come after. Since we are iterating over the 86 | // keys sequentially, and inserting into the `KeyMap` one-by-one, the default keybinding 87 | // will be inserted into the `KeyMap`, but it will be overridden by the user keybinding. 88 | // 89 | // Essentially what we want to make sure is that if the same key is defined twice, 90 | // the user keybinding takes priority. 91 | self 92 | .keys 93 | .keys 94 | .extend(user_config.keys.unwrap_or_default().keys); 95 | 96 | if let Some(user_theme) = user_config.theme { 97 | self.theme = self.theme.merge_user_theme(user_theme); 98 | }; 99 | 100 | self 101 | } 102 | } 103 | 104 | impl TryFrom for $Config { 105 | type Error = String; 106 | 107 | fn try_from(value: DefaultKdlConfig) -> Result { 108 | Ok(Self { 109 | $( 110 | $key: value.$key, 111 | )* 112 | theme: value.theme.try_into()?, 113 | keys: value.keys.keys.into_iter().collect::<$crate::config::KeyMap>(), 114 | }) 115 | } 116 | } 117 | 118 | /// User's config. Everything is optional. Values will be merged with `DefaultKdlConfig`. 119 | /// And will take priority over the default values. 120 | #[derive(ferrishot_knus::Decode, Debug)] 121 | pub struct UserKdlConfig { 122 | /// User-defined keybindings 123 | #[ferrishot_knus(child)] 124 | pub keys: Option<$crate::config::key_map::Keys>, 125 | /// User-defined colors 126 | #[ferrishot_knus(child)] 127 | pub theme: Option, 128 | $( 129 | $(#[$doc])* 130 | #[ferrishot_knus(child, unwrap(argument))] 131 | pub $key: Option<$typ>, 132 | )* 133 | } 134 | } 135 | } 136 | 137 | crate::declare_config_options! { 138 | /// Configuration for ferrishot. 139 | #[derive(Debug)] 140 | struct Config { 141 | /// Ferrishot's keybindings 142 | keys: super::key_map::KeyMap, 143 | /// Ferrishot's theme and colors 144 | theme: super::Theme, 145 | /// Renders a size indicator in the bottom left corner. 146 | /// It shows the current height and width of the selection. 147 | /// 148 | /// You can manually enter a value to change the selection by hand. 149 | size_indicator: bool, 150 | /// Render icons around the selection 151 | selection_icons: bool, 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/config/tests/2025_05_17_ferrishot_v0.3.kdl: -------------------------------------------------------------------------------- 1 | // This file is from the first version that the config became stable 2 | // We should never make a change that causes this file to fail to parse 3 | 4 | // Default config for ferrishot 5 | // 6 | // Create this file in the appropriate place with `ferrishot --dump-default-config` 7 | // 8 | // You can remove all of the defaults, and just keep your overrides 9 | // if you want to do that 10 | 11 | // Show the size indicator 12 | size-indicator #true 13 | // Show icons around the selection 14 | selection-icons #true 15 | 16 | keys { 17 | // Leave the app 18 | exit key= 19 | 20 | // Copies selected region to clipboard, exiting 21 | copy-to-clipboard mod=ctrl key=c 22 | copy-to-clipboard key= 23 | 24 | // Save to a file 25 | save-screenshot mod=ctrl key=s 26 | 27 | // Upload and make a link 28 | upload-screenshot mod=ctrl key=u 29 | 30 | // Set selection to be the entire screen 31 | // You can use the syntax of `ferrishot --region` here (see `--help` for more info) 32 | select-region "full" key= 33 | 34 | // Remove the selection 35 | clear-selection mod=ctrl key=x 36 | 37 | // These 2 commands let you pick any area on the screen in 8 keystrokes 38 | pick-top-left-corner key=t 39 | pick-bottom-right-corner key=b 40 | 41 | open-keybindings-cheatsheet key=? 42 | 43 | // Set width/height to whatever is the current count. 44 | // You can change the count by just writing numbers. e.g. type `100X` to set 45 | // the width to 100px 46 | set-width key=X 47 | set-height key=Y 48 | 49 | // move the selection in a direction by 1px 50 | move left 1 key=h 51 | move left 1 key= 52 | move down 1 key=j 53 | move down 1 key= 54 | move up 1 key=k 55 | move up 1 key= 56 | move right 1 key=l 57 | move right 1 key= 58 | 59 | // extend a side by 1px 60 | extend left 1 key=H 61 | extend left 1 mod=shift key= 62 | extend down 1 key=J 63 | extend down 1 mod=shift key= 64 | extend up 1 key=K 65 | extend up 1 mod=shift key= 66 | extend right 1 key=L 67 | extend right 1 mod=shift key= 68 | 69 | // shrink a side by 1px 70 | shrink left 1 mod=ctrl key=h 71 | shrink left 1 mod=ctrl key= 72 | shrink down 1 mod=ctrl key=j 73 | shrink down 1 mod=ctrl key= 74 | shrink up 1 mod=ctrl key=k 75 | shrink up 1 mod=ctrl key= 76 | shrink right 1 mod=ctrl key=l 77 | shrink right 1 mod=ctrl key= 78 | 79 | // move rectangle in direction by 125px 80 | move left 125 mod=alt key=h 81 | move left 125 mod=alt key= 82 | move down 125 mod=alt key=j 83 | move down 125 mod=alt key= 84 | move up 125 mod=alt key=k 85 | move up 125 mod=alt key= 86 | move right 125 mod=alt key=l 87 | move right 125 mod=alt key= 88 | 89 | // extend a side by 125px 90 | extend left 125 mod=alt key=H 91 | extend left 125 mod=alt+shift key= 92 | extend down 125 mod=alt key=J 93 | extend down 125 mod=alt+shift key= 94 | extend up 125 mod=alt key=K 95 | extend up 125 mod=alt+shift key= 96 | extend right 125 mod=alt key=L 97 | extend right 125 mod=alt+shift key= 98 | 99 | // shrink a side by 125px 100 | shrink left 125 mod=ctrl+alt key=h 101 | shrink left 125 mod=ctrl+alt key= 102 | shrink down 125 mod=ctrl+alt key=j 103 | shrink down 125 mod=ctrl+alt key= 104 | shrink up 125 mod=ctrl+alt key=k 105 | shrink up 125 mod=ctrl+alt key= 106 | shrink right 125 mod=ctrl+alt key=l 107 | shrink right 125 mod=ctrl+alt key= 108 | 109 | // move selection as far as it can go 110 | move left key=gh 111 | move left key=g 112 | move down key=gj 113 | move down key=g 114 | move up key=gk 115 | move up key=g 116 | move right key=gl 117 | move right key=g 118 | 119 | // teleport the selection to a place 120 | goto top-left key=gg 121 | goto bottom-right key=G 122 | goto center key=gc 123 | goto x-center key=gx 124 | goto y-center key=gy 125 | 126 | // for debugging / development 127 | toggle-debug-overlay key= 128 | } 129 | 130 | // editing the `theme` section allows you to fully customize the appearance of ferrishot 131 | 132 | theme { 133 | // Backslash `\` lets you split it the palette over multiple lines 134 | palette \ 135 | accent = 0xab_61_37 \ 136 | fg = 0xff_ff_ff \ 137 | bg = 0x00_00_00 138 | 139 | // color of the frame around the selection 140 | // 141 | // Uses the `accent` color from the `palette` 142 | selection-frame accent 143 | 144 | // background color of the region that is not selected 145 | non-selected-region bg opacity=0.5 146 | 147 | // small drop shadow used, an example is around the selection and also 148 | // around icons surrounding the selection 149 | drop-shadow bg opacity=0.5 150 | 151 | // selected text, for instance when editing the size indicator 152 | text-selection accent opacity=0.3 153 | 154 | size-indicator-fg fg 155 | size-indicator-bg bg opacity=0.5 156 | 157 | tooltip-fg fg 158 | tooltip-bg bg 159 | 160 | error-fg fg 161 | // Use a custom hex color 162 | error-bg 0xff_00_00 opacity=0.6 163 | 164 | info-box-fg fg 165 | info-box-border fg 166 | info-box-bg accent opacity=0.95 167 | 168 | icon-fg fg 169 | icon-bg accent 170 | 171 | // letters let you pick any region of the screen in 8 clicks 172 | // keys: t (top left corner), b (bottom right corner) 173 | letters-lines fg 174 | letters-bg bg opacity=0.6 175 | letters-fg fg 176 | 177 | // image uploaded popup (ctrl + U) 178 | image-uploaded-fg fg 179 | image-uploaded-bg bg opacity=0.9 180 | 181 | // for example, the checkmark when you copy to clipboard 182 | success 0x00_ff_00 183 | 184 | cheatsheet-bg bg 185 | cheatsheet-fg fg 186 | 187 | popup-close-icon-bg bg opacity=0.0 188 | popup-close-icon-fg fg 189 | 190 | // debug menu, for development (F12) 191 | debug-fg fg 192 | debug-label 0xff_00_00 193 | debug-bg bg opacity=0.9 194 | } 195 | 196 | -------------------------------------------------------------------------------- /src/config/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | mod kdl_config_backward_compatibility { 4 | #[test] 5 | fn v0_3() { 6 | super::Config::parse(concat!( 7 | env!("CARGO_MANIFEST_DIR"), 8 | "/src/config/tests/2025_05_17_ferrishot_v0.3.kdl" 9 | )) 10 | .expect("ferrishot v0.3: The first released version of the config must never break"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/config/theme.rs: -------------------------------------------------------------------------------- 1 | //! This module declares all of the theme keys that can be used in the app 2 | //! 3 | //! All theme keys are stored in a flat format for ease of use. 4 | 5 | use std::collections::HashMap; 6 | 7 | use ferrishot_knus::{DecodeScalar, ast::Literal, errors::DecodeError, traits::ErrorSpan}; 8 | 9 | /// A color can either be a hex, or it can reference a hex in the `palette` field 10 | /// 11 | /// ```kdl 12 | /// theme { 13 | /// palette { 14 | /// black 0x00_00_00 15 | /// } 16 | /// color @black 17 | /// } 18 | /// ``` 19 | #[derive(Debug)] 20 | pub enum ColorValue { 21 | /// A hex color like `0xff_00_00` 22 | Hex(u32), 23 | /// References a value in the hashmap by its `name` 24 | Palette(String), 25 | } 26 | 27 | impl DecodeScalar for ColorValue { 28 | fn type_check( 29 | _type_name: &Option>, 30 | _ctx: &mut ferrishot_knus::decode::Context, 31 | ) { 32 | } 33 | 34 | fn raw_decode( 35 | value: &ferrishot_knus::span::Spanned, 36 | ctx: &mut ferrishot_knus::decode::Context, 37 | ) -> Result> { 38 | match &**value { 39 | Literal::Int(int) => match int.try_into() { 40 | Ok(v) => Ok(Self::Hex(v)), 41 | Err(err) => { 42 | ctx.emit_error(DecodeError::conversion(value, err)); 43 | Ok(Self::Hex(0)) 44 | } 45 | }, 46 | Literal::String(s) => Ok(Self::Palette(s.to_string())), 47 | _ => { 48 | ctx.emit_error(DecodeError::scalar_kind( 49 | ferrishot_knus::decode::Kind::String, 50 | value, 51 | )); 52 | Ok(Self::Hex(0)) 53 | } 54 | } 55 | } 56 | } 57 | 58 | /// Represents the color node used in the KDL config, to be parsed into 59 | /// this structure. 60 | /// 61 | /// # Examples 62 | /// 63 | /// ```kdl 64 | /// theme { 65 | /// // an opaque white color 66 | /// background ffffff 67 | /// // black color with 50% opacity 68 | /// foreground 000000 opacity=0.5 69 | /// } 70 | /// ``` 71 | #[derive(ferrishot_knus::Decode, Debug)] 72 | pub struct Color { 73 | /// Hex color. Examples: 74 | /// 75 | /// - `ff0000`: Red 76 | /// - `000000`: Black 77 | #[ferrishot_knus(argument)] 78 | pub color: ColorValue, 79 | /// The opacity for this color. 80 | /// - `1.0`: Opaque 81 | /// - `0.0`: Transparent 82 | #[ferrishot_knus(default = 1.0, property)] 83 | pub opacity: f32, 84 | } 85 | 86 | /// Declare theme keys 87 | /// 88 | /// `UserKdlTheme` is merged into `DefaultKdlTheme` before being processed 89 | /// into a `Theme` 90 | #[macro_export] 91 | macro_rules! declare_theme_options { 92 | ( 93 | $( 94 | $(#[$doc:meta])* 95 | $key:ident 96 | ),* $(,)? 97 | ) => { 98 | /// Theme and colors of ferrishot 99 | #[derive(Debug, Copy, Clone)] 100 | pub struct Theme { 101 | $( 102 | $(#[$doc])* 103 | pub $key: iced::Color, 104 | )* 105 | } 106 | 107 | /// Ferrishot's default theme and colors 108 | #[derive(ferrishot_knus::Decode, Debug)] 109 | pub struct DefaultKdlTheme { 110 | /// Palette 111 | #[ferrishot_knus(child, unwrap(properties))] 112 | palette: HashMap, 113 | $( 114 | $(#[$doc])* 115 | #[ferrishot_knus(child)] 116 | pub $key: Color, 117 | )* 118 | } 119 | 120 | /// The user's custom theme and color overrides 121 | /// All values are optional and will override whatever is the default 122 | #[derive(ferrishot_knus::Decode, Debug)] 123 | pub struct UserKdlTheme { 124 | /// Palette 125 | #[ferrishot_knus(child, unwrap(properties))] 126 | palette: Option>, 127 | $( 128 | $(#[$doc])* 129 | #[ferrishot_knus(child)] 130 | pub $key: Option<$crate::config::Color>, 131 | )* 132 | } 133 | 134 | impl DefaultKdlTheme { 135 | /// If the user theme specifies a color, it will override the color in the 136 | /// default theme. 137 | pub fn merge_user_theme(mut self, user_theme: UserKdlTheme) -> Self { 138 | // merge the palette 139 | if let Some(palette) = user_theme.palette { 140 | self.palette.extend(palette.into_iter()); 141 | } 142 | // merge rest of the keys 143 | $( 144 | self.$key = user_theme.$key.unwrap_or(self.$key); 145 | )* 146 | self 147 | } 148 | } 149 | 150 | impl TryFrom for Theme { 151 | type Error = String; 152 | 153 | fn try_from(value: DefaultKdlTheme) -> Result { 154 | Ok(Self { 155 | $( 156 | $key: { 157 | let hex = match value.$key.color { 158 | ColorValue::Hex(hex) => hex, 159 | ColorValue::Palette(key) => 160 | *value.palette 161 | .get(&key) 162 | .ok_or_else( 163 | || format!("This palette item does not exist: {key}") 164 | )? 165 | }; 166 | let [.., r, g, b] = hex.to_be_bytes(); 167 | 168 | iced::Color::from_rgba( 169 | f32::from(r) / 255.0, 170 | f32::from(g) / 255.0, 171 | f32::from(b) / 255.0, 172 | value.$key.opacity 173 | ) 174 | }, 175 | )* 176 | }) 177 | } 178 | } 179 | } 180 | } 181 | 182 | crate::declare_theme_options! { 183 | /// Cheatsheet background 184 | cheatsheet_bg, 185 | /// Cheatsheet text color 186 | cheatsheet_fg, 187 | 188 | /// Close the popup 189 | popup_close_icon_bg, 190 | /// Cheatsheet text color 191 | popup_close_icon_fg, 192 | 193 | /// Color of the border around the selection 194 | selection_frame, 195 | /// Color of the region outside of the selected area 196 | non_selected_region, 197 | /// Color of drop shadow, used for stuff like: 198 | /// 199 | /// - drop shadow of icons 200 | /// - drop shadow of selection rectangle 201 | /// - drop shadow around error box 202 | drop_shadow, 203 | /// Background color of selected text 204 | text_selection, 205 | 206 | // 207 | // --- Side Indicator --- 208 | // 209 | /// Foreground color of the size indicator 210 | size_indicator_fg, 211 | /// Background color of the size indicator 212 | size_indicator_bg, 213 | 214 | // 215 | // --- Tooltip --- 216 | // 217 | /// Text color of the tooltip 218 | tooltip_fg, 219 | /// Background color of the tooltip 220 | tooltip_bg, 221 | 222 | // 223 | // --- Errors --- 224 | // 225 | /// Color of the text on errors 226 | error_fg, 227 | /// Background color of the error boxes 228 | error_bg, 229 | 230 | // 231 | // --- Info Box --- 232 | // 233 | /// Background color of the info box, which shows various tips 234 | info_box_bg, 235 | /// Text color of the info box, which shows various tips 236 | info_box_fg, 237 | /// Color of the border of the info box 238 | info_box_border, 239 | 240 | // 241 | // --- Selection Icons --- 242 | // 243 | /// Background color of the icons around the selection 244 | icon_bg, 245 | /// Color of icons around the selection 246 | icon_fg, 247 | 248 | // 249 | // --- Debug Menu --- 250 | // 251 | /// Color of the labels in the debug menu (F12) 252 | debug_label, 253 | /// Foreground color of debug menu (F12) 254 | debug_fg, 255 | /// Background color of debug menu (F12) 256 | debug_bg, 257 | 258 | // 259 | // --- Letters --- 260 | // 261 | /// Color of lines 262 | letters_lines, 263 | /// Color of letters 264 | letters_fg, 265 | /// Background color of letters 266 | letters_bg, 267 | 268 | // 269 | // --- Image uploaded popup --- 270 | // 271 | /// Foreground color of the image_uploaded popup 272 | image_uploaded_fg, 273 | /// Background color of the image_uploaded popup 274 | image_uploaded_bg, 275 | 276 | /// Color of success, e.g. green check mark when copying text to clipboard 277 | success, 278 | } 279 | -------------------------------------------------------------------------------- /src/icons.rs: -------------------------------------------------------------------------------- 1 | //! Icons for ferrishot 2 | //! 3 | //! - Icons are stored in the `assets/icons/` directory. 4 | //! - Icons are declared at the invocation of the `icons!` macro. 5 | //! - Each `Icon` must have a corresponding `icons/Icon.svg` file. 6 | 7 | /// Generates handles for macros and automatically includes all the icons 8 | macro_rules! load_icons { 9 | ( 10 | $( 11 | #[$doc:meta] 12 | $icon:ident 13 | ),* $(,)? 14 | ) => { 15 | /// Icons for ferrishot 16 | #[expect(dead_code, reason = "not all icons are used at the moment")] 17 | #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] 18 | pub enum Icon { 19 | $( 20 | #[$doc] 21 | $icon 22 | ),* 23 | } 24 | 25 | /// Private module so we don't leak implementation detail of the static icons 26 | mod __static_icons { 27 | $( 28 | #[expect(nonstandard_style, reason = "handy for creating statics")] 29 | pub(super) static $icon: std::sync::LazyLock = std::sync::LazyLock::new(|| { 30 | iced::widget::svg::Handle::from_memory(include_bytes!(concat!( 31 | "../assets/icons/", 32 | stringify!($icon), 33 | ".svg" 34 | ))) 35 | }); 36 | )* 37 | 38 | } 39 | 40 | impl Icon { 41 | /// Obtain this icon's svg handle 42 | pub fn svg(self) -> iced::widget::svg::Handle { 43 | match self { 44 | $(Self::$icon => __static_icons::$icon.clone()),* 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | load_icons! { 52 | /// Arrow pointing up 53 | ArrowUp, 54 | /// Arrow pointing right 55 | ArrowRight, 56 | /// Arrow pointing down 57 | ArrowDown, 58 | /// Arrow pointing left 59 | ArrowLeft, 60 | /// Save the image to a path by opening the file dialog 61 | Save, 62 | /// Drawing a circle 63 | Circle, 64 | /// Copy the image to clipboard 65 | Clipboard, 66 | /// Close the app 67 | Close, 68 | /// Switch to Cursor tool, allows resizing and dragging the selection around 69 | Cursor, 70 | /// Select the entire image 71 | Fullscreen, 72 | /// Draw on the image 73 | Pen, 74 | /// Draw a square 75 | Square, 76 | /// Add text 77 | Text, 78 | /// Upload image to the internet 79 | Upload, 80 | /// Indicate success 81 | Check, 82 | /// Loading... 83 | Spinner, 84 | } 85 | 86 | /// Expands to an SVG by reading from the `icons/` directory 87 | #[macro_export] 88 | macro_rules! icon { 89 | ($icon:ident) => {{ iced::widget::svg($crate::icons::Icon::$icon.svg()) }}; 90 | } 91 | -------------------------------------------------------------------------------- /src/image/action.rs: -------------------------------------------------------------------------------- 1 | //! One of 3 actions: 2 | //! 3 | //! - Upload image 4 | //! - Copy image 5 | //! - Save image 6 | use std::path::PathBuf; 7 | 8 | use iced::Rectangle; 9 | use iced::Task; 10 | use image::DynamicImage; 11 | 12 | use crate::image::upload::ImageUploaded; 13 | use crate::{App, geometry::RectangleExt as _, ui::popup::image_uploaded}; 14 | use iced::widget; 15 | 16 | // INFO: Documentation comments for the enum are used in `--help` 17 | crate::declare_commands! { 18 | #[derive(clap::ValueEnum)] 19 | /// Action to take with the image 20 | enum Command { 21 | /// Copy image to the clipboard 22 | UploadScreenshot, 23 | /// Save image to a file 24 | CopyToClipboard, 25 | /// Upload image to the internet 26 | SaveScreenshot, 27 | } 28 | } 29 | 30 | impl crate::command::Handler for Command { 31 | fn handle(self, app: &mut App, _count: u32) -> Task { 32 | let Some(rect) = app.selection.map(|sel| sel.rect.norm()) else { 33 | app.errors.push(match self { 34 | Self::CopyToClipboard => "There is no selection to copy", 35 | Self::UploadScreenshot => "There is no selection to upload", 36 | Self::SaveScreenshot => "There is no selection to save", 37 | }); 38 | return Task::none(); 39 | }; 40 | 41 | if self == Self::UploadScreenshot { 42 | app.is_uploading_image = true; 43 | } 44 | 45 | let image = App::process_image(rect, &app.image); 46 | 47 | Task::future(async move { 48 | match self.execute(image, rect).await { 49 | Ok((Output::Saved | Output::Copied, _)) => crate::message::Message::Exit, 50 | Ok(( 51 | Output::Uploaded { 52 | path, 53 | data, 54 | file_size, 55 | }, 56 | ImageData { height, width }, 57 | )) => crate::Message::ImageUploaded(image_uploaded::Message::ImageUploaded( 58 | image_uploaded::ImageUploadedData { 59 | image_uploaded: data, 60 | uploaded_image: widget::image::Handle::from_path(&path), 61 | height, 62 | width, 63 | file_size, 64 | }, 65 | )), 66 | Err(err) => crate::Message::Error(err.to_string()), 67 | } 68 | }) 69 | } 70 | } 71 | 72 | /// Data about the image 73 | pub struct ImageData { 74 | /// Height of the image (pixels) 75 | pub height: u32, 76 | /// Width of the image (pixels) 77 | pub width: u32, 78 | } 79 | 80 | /// The output of an image action 81 | pub enum Output { 82 | /// Copied to the clipboard 83 | Copied, 84 | /// Saved to a path 85 | /// 86 | /// We don't know the path yet. We'll find out at the end of `main`. 87 | Saved, 88 | /// Uploaded to the internet 89 | Uploaded { 90 | /// information about the uploaded image 91 | data: ImageUploaded, 92 | /// file size in bytes 93 | file_size: u64, 94 | /// Path to the uploaded image 95 | path: PathBuf, 96 | }, 97 | } 98 | 99 | /// Image action error 100 | #[derive(thiserror::Error, miette::Diagnostic, Debug)] 101 | pub enum Error { 102 | /// Clipboard error 103 | #[error("failed to copy the image: {0}")] 104 | Clipboard(#[from] crate::clipboard::ClipboardError), 105 | /// IO error 106 | #[error(transparent)] 107 | Io(#[from] std::io::Error), 108 | /// Image upload error 109 | #[error("failed to upload the image: {0}")] 110 | ImageUpload(String), 111 | /// Image error 112 | #[error(transparent)] 113 | SaveImage(#[from] image::ImageError), 114 | /// Could not get the image 115 | #[error(transparent)] 116 | GetImage(#[from] crate::image::GetImageError), 117 | } 118 | 119 | impl Command { 120 | /// Convert this into a key action 121 | pub fn into_key_action(self) -> crate::Command { 122 | match self { 123 | Self::CopyToClipboard => crate::Command::ImageUpload(Self::CopyToClipboard), 124 | Self::SaveScreenshot => crate::Command::ImageUpload(Self::SaveScreenshot), 125 | Self::UploadScreenshot => crate::Command::ImageUpload(Self::UploadScreenshot), 126 | } 127 | } 128 | 129 | /// Execute the action 130 | pub async fn execute( 131 | self, 132 | image: DynamicImage, 133 | region: Rectangle, 134 | ) -> Result<(Output, ImageData), Error> { 135 | let image_data = ImageData { 136 | height: image.height(), 137 | width: image.width(), 138 | }; 139 | 140 | // NOTE: Not a hard error, so no need to abort the main action 141 | if let Err(failed_to_write) = crate::last_region::write(region) { 142 | log::error!( 143 | "Failed to save the current rectangle selection, for possible re-use: {failed_to_write}" 144 | ); 145 | } 146 | 147 | let out = match self { 148 | Self::CopyToClipboard => crate::clipboard::set_image(arboard::ImageData { 149 | width: image.width() as usize, 150 | height: image.height() as usize, 151 | bytes: std::borrow::Cow::Borrowed(image.as_bytes()), 152 | }) 153 | .map(|_| (Output::Copied, image_data))?, 154 | Self::SaveScreenshot => { 155 | let _ = SAVED_IMAGE.set(image); 156 | (Output::Saved, image_data) 157 | } 158 | Self::UploadScreenshot => { 159 | let path = tempfile::TempDir::new()? 160 | .into_path() 161 | .join("ferrishot-screenshot.png"); 162 | 163 | // TODO: allow configuring the upload format 164 | // in-app 165 | image.save_with_format(&path, image::ImageFormat::Png)?; 166 | 167 | ( 168 | Output::Uploaded { 169 | data: crate::image::upload::upload(&path).await.map_err(|err| { 170 | err.into_iter() 171 | .next() 172 | .map(Error::ImageUpload) 173 | .expect("at least 1 image upload provider") 174 | })?, 175 | file_size: path.metadata().map(|meta| meta.len()).unwrap_or(0), 176 | path, 177 | }, 178 | image_data, 179 | ) 180 | } 181 | }; 182 | 183 | Ok(out) 184 | } 185 | } 186 | 187 | /// The image to save to a file, chosen by the user in a file picker. 188 | /// 189 | /// Unfortunately, there is simply no way to communicate something from 190 | /// the inside of an iced application to the outside: i.e. "Return" something 191 | /// from an iced program exiting. So we have to use a global variable for this. 192 | /// 193 | /// This global is mutated just *once* at the end of the application's lifetime, 194 | /// when the window closes. 195 | /// 196 | /// It is then accessed just *once* to open the file dialog and let the user pick 197 | /// where they want to save their image. 198 | /// 199 | /// Yes, at the moment we want this when using Ctrl + S to save as file: 200 | /// 1. Close the application to save the file and generate the image we'll save 201 | /// 2. Open the file explorer, and save the image to the specified path 202 | /// 203 | /// When the file explorer is spawned from the inside of an iced window, closing 204 | /// this window will then also close the file explorer. It means that we can't 205 | /// close the window and then spawn an explorer. 206 | /// 207 | /// The other option is to have both windows open at the same time. But this 208 | /// would be really odd. First of all, we will need to un-fullscreen the App 209 | /// because the file explorer can spawn under the app. 210 | /// 211 | /// This is going to be sub-optimal. Currently, we give off the illusion of 212 | /// drawing shapes and rectangles on top of the desktop. It is not immediately 213 | /// obvious that the app is just another window which is full-screen. 214 | /// Doing the above would break that illusion. 215 | /// 216 | /// Ideally, we would be able to spawn a file explorer *above* the window without 217 | /// having to close this. But this seems to not be possible. Perhaps in the 218 | /// future there will be some kind of file explorer Iced widget that we 219 | /// can use instead of the native file explorer. 220 | pub static SAVED_IMAGE: std::sync::OnceLock = std::sync::OnceLock::new(); 221 | -------------------------------------------------------------------------------- /src/image/mod.rs: -------------------------------------------------------------------------------- 1 | //! Contains ways of opening the image / uploading / saving / copying it 2 | 3 | pub mod action; 4 | 5 | pub mod upload; 6 | 7 | mod screenshot; 8 | use std::path::PathBuf; 9 | 10 | use image::ImageReader; 11 | 12 | mod rgba_handle; 13 | pub use rgba_handle::RgbaHandle; 14 | use tap::Pipe as _; 15 | 16 | /// Failed to get the image 17 | #[derive(thiserror::Error, miette::Diagnostic, Debug)] 18 | pub enum GetImageError { 19 | /// IO error 20 | #[error(transparent)] 21 | Io(#[from] std::io::Error), 22 | /// Image error 23 | #[error(transparent)] 24 | Image(#[from] image::ImageError), 25 | /// Screenshot error 26 | #[error(transparent)] 27 | Screenshot(#[from] screenshot::ScreenshotError), 28 | } 29 | 30 | /// Returns handle of the image that will be edited 31 | /// 32 | /// If path is passed, use that as the image to edit. 33 | /// Otherwise take a screenshot of the desktop and use that to edit. 34 | pub fn get_image(file: Option<&PathBuf>) -> Result { 35 | file.map(ImageReader::open) 36 | .transpose()? 37 | .map(ImageReader::decode) 38 | .transpose()? 39 | .map_or_else( 40 | // no path passed = take image of the monitor 41 | screenshot::take, 42 | |img| RgbaHandle::new(img.width(), img.height(), img.into_rgba8().into_raw()).pipe(Ok), 43 | )? 44 | .pipe(Ok) 45 | } 46 | -------------------------------------------------------------------------------- /src/image/rgba_handle.rs: -------------------------------------------------------------------------------- 1 | //! Wrapper around `iced::widget::image::Handle` to guarantee that it is an RGBA handle 2 | 3 | use iced::{Rectangle, advanced::image::Bytes, widget::image::Handle}; 4 | 5 | /// The `RgbaHandle` is a wrapper for a handle pointing to decoded image pixels in RGBA format. 6 | /// 7 | /// This is a more specialized version of `iced::widget::image::Handle` 8 | #[derive(Debug, Clone)] 9 | pub struct RgbaHandle(Handle); 10 | 11 | impl RgbaHandle { 12 | /// Create handle to an image represented in RGBA format 13 | pub fn new(width: u32, height: u32, pixels: impl Into) -> Self { 14 | Self(Handle::from_rgba(width, height, pixels.into())) 15 | } 16 | 17 | /// Get the bounds of this image 18 | pub fn bounds(&self) -> Rectangle { 19 | Rectangle { 20 | x: 0.0, 21 | y: 0.0, 22 | width: self.width() as f32, 23 | height: self.height() as f32, 24 | } 25 | } 26 | 27 | /// Width of the image 28 | pub fn width(&self) -> u32 { 29 | self.raw().0 30 | } 31 | 32 | /// Height of the image 33 | pub fn height(&self) -> u32 { 34 | self.raw().1 35 | } 36 | 37 | /// RGBA bytes of the image 38 | pub fn bytes(&self) -> &Bytes { 39 | self.raw().2 40 | } 41 | 42 | /// Returns the width, height and RGBA pixels 43 | fn raw(&self) -> (u32, u32, &Bytes) { 44 | let Handle::Rgba { 45 | width, 46 | height, 47 | ref pixels, 48 | .. 49 | } = self.0 50 | else { 51 | unreachable!("handle is guaranteed to be Rgba") 52 | }; 53 | (width, height, pixels) 54 | } 55 | } 56 | 57 | impl From for Handle { 58 | fn from(value: RgbaHandle) -> Self { 59 | value.0 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/image/screenshot.rs: -------------------------------------------------------------------------------- 1 | //! Take screenshot of the current monitor 2 | 3 | /// Could not retrieve the screenshot 4 | #[derive(thiserror::Error, Debug)] 5 | pub enum ScreenshotError { 6 | /// The position of the mouse is unavailable 7 | #[error("Could not get position of the mouse")] 8 | MousePosition, 9 | #[error("Could not get the active monitor: {0}")] 10 | /// There is no active monitor 11 | Monitor(xcap::XCapError), 12 | /// Could not capture the screenshot for some reason 13 | #[error("Could not take a screenshot: {0}")] 14 | Screenshot(xcap::XCapError), 15 | } 16 | 17 | /// Take a screenshot and return a handle to the image 18 | pub fn take() -> Result { 19 | let mouse_position::mouse_position::Mouse::Position { x, y } = 20 | mouse_position::mouse_position::Mouse::get_mouse_position() 21 | else { 22 | return Err(ScreenshotError::MousePosition); 23 | }; 24 | 25 | let monitor = xcap::Monitor::from_point(x, y).map_err(ScreenshotError::Monitor)?; 26 | 27 | let screenshot = monitor 28 | .capture_image() 29 | .map_err(ScreenshotError::Screenshot)?; 30 | 31 | Ok(super::RgbaHandle::new( 32 | screenshot.width(), 33 | screenshot.height(), 34 | screenshot.into_raw(), 35 | )) 36 | } 37 | -------------------------------------------------------------------------------- /src/image/upload.rs: -------------------------------------------------------------------------------- 1 | //! Upload images to free services 2 | 3 | use std::path::Path; 4 | 5 | use ferrishot_knus::DecodeScalar; 6 | use iced::futures::future::join_all; 7 | use reqwest::multipart::{Form, Part}; 8 | use serde::{Deserialize, Serialize}; 9 | use strum::{EnumCount as _, IntoEnumIterator as _}; 10 | use tokio::sync::oneshot; 11 | 12 | /// A single client for HTTP requests 13 | static HTTP_CLIENT: std::sync::LazyLock = 14 | std::sync::LazyLock::new(reqwest::Client::new); 15 | 16 | /// Upload an image to multiple services. As soon as the first service succeeds, 17 | /// cancel the other uploads. 18 | /// 19 | /// # Returns 20 | /// 21 | /// Link to the uploaded image 22 | /// 23 | /// # Errors 24 | /// 25 | /// If none succeed, return error for all the services 26 | pub async fn upload(file_path: &Path) -> Result> { 27 | let mut handles = Vec::new(); 28 | 29 | // Channel for results 30 | // Each uploader sends either Ok(url) or Err(err), tagged with index of the uploader 31 | let (tx, mut rx) = 32 | tokio::sync::mpsc::unbounded_channel::<(usize, Result)>(); 33 | 34 | // Channel for cancellation 35 | // Sending end `cancel_tx` triggered by the first successful uploader 36 | let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); 37 | let cancel_rx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(cancel_rx))); 38 | 39 | // launch an Upload task for each service 40 | for (i, service) in ImageUploadService::iter().enumerate() { 41 | let tx = tx.clone(); 42 | let path = file_path.to_path_buf(); 43 | let cancel_rx = cancel_rx.clone(); 44 | 45 | handles.push(tokio::spawn(async move { 46 | let cancel = { 47 | let mut rx_lock = cancel_rx.lock().await; 48 | rx_lock.take() 49 | }; 50 | 51 | tokio::select! { 52 | biased; 53 | 54 | () = async { 55 | if let Some(rx) = cancel { 56 | let _ = rx.await; 57 | } 58 | } => { 59 | // cancelled, do nothing 60 | } 61 | 62 | response = service.upload_image(&path) => { 63 | let result = response.map_err(|e| e.to_string()); 64 | let _ = tx.send((i, result)); 65 | } 66 | }; 67 | })); 68 | } 69 | 70 | // receiver stops waiting if no senders remain 71 | drop(tx); 72 | 73 | let mut errors = vec![None; ImageUploadService::COUNT]; 74 | 75 | while let Some((i, result)) = rx.recv().await { 76 | match result { 77 | Ok(url) => { 78 | // cancel other Upload tasks 79 | let _ = cancel_tx.send(()); 80 | 81 | join_all(handles).await; 82 | return Ok(url); 83 | } 84 | Err(err) => { 85 | errors[i] = Some(err); 86 | } 87 | } 88 | 89 | if errors.iter().all(Option::is_some) { 90 | break; 91 | } 92 | } 93 | 94 | join_all(handles).await; 95 | 96 | Err(errors.into_iter().flatten().collect()) 97 | } 98 | 99 | #[derive( 100 | Copy, 101 | Clone, 102 | PartialEq, 103 | Debug, 104 | Eq, 105 | PartialOrd, 106 | Ord, 107 | Serialize, 108 | Deserialize, 109 | DecodeScalar, 110 | strum::EnumIter, 111 | strum::EnumCount, 112 | )] 113 | #[serde(rename_all = "kebab-case")] 114 | /// Choose which image upload service should be used by default when pressing "Upload Online" 115 | pub enum ImageUploadService { 116 | /// - Website: `https://litterbox.catbox.moe` 117 | /// - Max upload size: 1 GB 118 | Litterbox, 119 | /// - Website: `https://catbox.moe` 120 | /// - Max upload size: 200 MB 121 | Catbox, 122 | /// - Website: `https://0x0.st` 123 | /// - Max upload size: 512 MiB 124 | TheNullPointer, 125 | /// - Website: `https://uguu.se` 126 | /// - Max upload size: 128 Mib 127 | Uguu, 128 | } 129 | 130 | /// Data of the uploaded image 131 | #[derive(Debug, Clone)] 132 | pub struct ImageUploaded { 133 | /// Link to the uploaded image 134 | pub link: String, 135 | /// How long until the image expires (rough estimate - purely for visualization) 136 | pub expires_in: &'static str, 137 | } 138 | 139 | /// Image upload error 140 | #[derive(thiserror::Error, miette::Diagnostic, Debug)] 141 | pub enum Error { 142 | /// IO error 143 | #[error(transparent)] 144 | Io(#[from] std::io::Error), 145 | /// Reqwest error 146 | #[error(transparent)] 147 | Reqwest(#[from] reqwest::Error), 148 | /// Invalid response. serde could not parse 149 | #[error("invalid response: {0}")] 150 | InvalidResponse(String), 151 | } 152 | 153 | impl ImageUploadService { 154 | /// Conservative estimate for how long until images expire 155 | fn expires_in(self) -> &'static str { 156 | match self { 157 | Self::Litterbox => "3 days", 158 | Self::Catbox => "2 weeks", 159 | Self::TheNullPointer => "30 days", 160 | Self::Uguu => "3 hours", 161 | } 162 | } 163 | 164 | /// The base URL where image files should be uploaded 165 | fn post_url(self) -> &'static str { 166 | match self { 167 | Self::TheNullPointer => "https://0x0.st", 168 | Self::Uguu => "https://uguu.se/upload", 169 | Self::Catbox => "https://catbox.moe/user/api.php", 170 | Self::Litterbox => "https://litterbox.catbox.moe/resources/internals/api.php", 171 | } 172 | } 173 | 174 | /// Upload the image to the given upload service 175 | pub async fn upload_image(self, file_path: &Path) -> Result { 176 | let request = HTTP_CLIENT 177 | .request(reqwest::Method::POST, self.post_url()) 178 | .header( 179 | "User-Agent", 180 | format!("ferrishot/{:?}", env!("CARGO_PKG_VERSION")), 181 | ); 182 | 183 | let link = match self { 184 | Self::TheNullPointer => { 185 | request 186 | .multipart(Form::new().file("file", file_path).await?) 187 | .send() 188 | .await? 189 | .text() 190 | .await? 191 | } 192 | Self::Uguu => { 193 | #[derive(Serialize, Deserialize)] 194 | struct UguuResponse { 195 | /// Array of uploaded files 196 | files: Vec, 197 | } 198 | 199 | #[derive(Serialize, Deserialize)] 200 | struct UguuFile { 201 | /// Link to the uploaded image 202 | url: String, 203 | } 204 | 205 | request 206 | .multipart(Form::new().file("files[]", file_path).await?) 207 | .send() 208 | .await? 209 | .json::() 210 | .await? 211 | .files 212 | .into_iter() 213 | .next() 214 | .ok_or(Error::InvalidResponse( 215 | "Expected uguu to return an array with 1 file".to_string(), 216 | ))? 217 | .url 218 | } 219 | Self::Catbox => { 220 | request 221 | .multipart( 222 | Form::new() 223 | .part("reqtype", Part::text("fileupload")) 224 | .file("fileToUpload", file_path) 225 | .await?, 226 | ) 227 | .send() 228 | .await? 229 | .text() 230 | .await? 231 | } 232 | Self::Litterbox => { 233 | request 234 | .multipart( 235 | Form::new() 236 | .part("reqtype", Part::text("fileupload")) 237 | .part("time", Part::text("72h")) 238 | .file("fileToUpload", file_path) 239 | .await?, 240 | ) 241 | .send() 242 | .await? 243 | .text() 244 | .await? 245 | } 246 | }; 247 | 248 | Ok(ImageUploaded { 249 | link, 250 | expires_in: self.expires_in(), 251 | }) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/last_region.rs: -------------------------------------------------------------------------------- 1 | //! Read and write the last region of a rectangle 2 | use crate::{ 3 | geometry::RectangleExt as _, 4 | lazy_rect::{LazyRectangle, ParseRectError}, 5 | }; 6 | use etcetera::BaseStrategy as _; 7 | use iced::Rectangle; 8 | use std::{fs, io::Write as _, str::FromStr as _}; 9 | use tap::Pipe as _; 10 | 11 | /// Could not get the last region 12 | #[derive(thiserror::Error, miette::Diagnostic, Debug)] 13 | pub enum Error { 14 | /// Can't find home dir 15 | #[error(transparent)] 16 | HomeDir(#[from] etcetera::HomeDirError), 17 | /// Failed to parse as rectangle 18 | #[error(transparent)] 19 | ParseRect(#[from] ParseRectError), 20 | /// Failed to read the last region file 21 | #[error(transparent)] 22 | Io(#[from] std::io::Error), 23 | } 24 | /// Name of the file used to read the last region 25 | pub const LAST_REGION_FILENAME: &str = "ferrishot-last-region.txt"; 26 | 27 | /// Read the last region used 28 | pub fn read(image_bounds: Rectangle) -> Result, Error> { 29 | etcetera::choose_base_strategy()? 30 | .cache_dir() 31 | .join(LAST_REGION_FILENAME) 32 | .pipe(fs::read_to_string)? 33 | .pipe_deref(LazyRectangle::from_str)? 34 | .pipe(|lazy_rect| lazy_rect.init(image_bounds)) 35 | .pipe(Some) 36 | .pipe(Ok) 37 | } 38 | 39 | /// Write the last used region 40 | pub(crate) fn write(region: Rectangle) -> Result<(), Error> { 41 | etcetera::choose_base_strategy()? 42 | .cache_dir() 43 | .join(LAST_REGION_FILENAME) 44 | .pipe(fs::File::create)? 45 | .write_all(region.as_str().as_bytes())? 46 | .pipe(Ok) 47 | } 48 | 49 | #[cfg(not(target_os = "linux"))] 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use pretty_assertions::assert_eq; 54 | 55 | #[test] 56 | fn write_and_read_last_region() { 57 | let region = Rectangle { 58 | x: 42.0, 59 | y: 24.0, 60 | width: 800.0, 61 | height: 600.0, 62 | }; 63 | 64 | write(region).unwrap(); 65 | assert_eq!( 66 | read(Rectangle { 67 | x: 0.0, 68 | y: 0.0, 69 | width: 3440.0, 70 | height: 1440.00 71 | }) 72 | .unwrap(), 73 | Some(region) 74 | ); 75 | let another_region = Rectangle { 76 | x: 900.0, 77 | y: 400.0, 78 | width: 800.0, 79 | height: 150.0, 80 | }; 81 | 82 | write(another_region).unwrap(); 83 | assert_eq!( 84 | read(Rectangle { 85 | x: 0.0, 86 | y: 0.0, 87 | width: 3440.0, 88 | height: 1440.00 89 | }) 90 | .unwrap(), 91 | Some(another_region) 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The ferrishot app 2 | #![cfg_attr( 3 | test, 4 | allow( 5 | clippy::unwrap_used, 6 | clippy::needless_pass_by_value, 7 | reason = "relaxed lints in tests" 8 | ) 9 | )] 10 | 11 | /// See [`CommandHandler`](crate::config::commands::CommandHandler) for more info 12 | mod command { 13 | pub use super::config::commands::CommandHandler as Handler; 14 | } 15 | 16 | mod clipboard; 17 | mod config; 18 | mod geometry; 19 | mod icons; 20 | mod image; 21 | mod lazy_rect; 22 | mod message; 23 | mod ui; 24 | 25 | use config::commands::Command; 26 | 27 | use config::Theme; 28 | use message::Message; 29 | 30 | pub mod last_region; 31 | pub mod logging; 32 | 33 | #[cfg(target_os = "linux")] 34 | pub use clipboard::{CLIPBOARD_DAEMON_ID, run_clipboard_daemon}; 35 | 36 | pub use config::{Cli, Config, DEFAULT_KDL_CONFIG_STR, DEFAULT_LOG_FILE_PATH}; 37 | pub use image::action::SAVED_IMAGE; 38 | pub use image::get_image; 39 | pub use ui::App; 40 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | //! Initialize ferrishot logging to file or stderr 2 | 3 | /// Uses the `log` crate to log either to the standard output or the log file. 4 | /// 5 | /// See `CONTRIBUTING.md` for info on which params ferrishot takes 6 | /// for logging specifically that are normally hidden. 7 | pub fn initialize(cli: &crate::Cli) { 8 | if cli.log_stderr { 9 | env_logger::builder() 10 | .filter_module(cli.log_filter.as_deref().unwrap_or(""), cli.log_level) 11 | .init(); 12 | } else { 13 | use std::io::Write as _; 14 | 15 | match std::fs::File::create(std::path::PathBuf::from(&*cli.log_file)) { 16 | Ok(file) => env_logger::Builder::new() 17 | .format(|buf, record| { 18 | writeln!( 19 | buf, 20 | "[{time} {level} {module}] {message}", 21 | time = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), 22 | level = record.level(), 23 | module = record.module_path().unwrap_or("unknown"), 24 | message = record.args(), 25 | ) 26 | }) 27 | .target(env_logger::Target::Pipe(Box::new(file))) 28 | .filter(cli.log_filter.as_deref(), cli.log_level) 29 | .init(), 30 | Err(err) => { 31 | env_logger::builder().filter_level(cli.log_level).init(); 32 | log::error!("Failed to create log file: {err}"); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! The ferrishot app 2 | 3 | use std::sync::Arc; 4 | 5 | use clap::Parser as _; 6 | use ferrishot::Cli; 7 | use miette::IntoDiagnostic as _; 8 | use miette::miette; 9 | 10 | use ferrishot::App; 11 | use tap::Pipe as _; 12 | 13 | /// RGBA bytes for the Logo of ferrishot. Generated with `build.rs` 14 | const LOGO: &[u8; 64 * 64 * 4] = include_bytes!(concat!(env!("OUT_DIR"), "/logo.bin")); 15 | 16 | #[allow( 17 | clippy::print_stderr, 18 | clippy::print_stdout, 19 | reason = "print from `main` is fine" 20 | )] 21 | fn main() -> miette::Result<()> { 22 | // On linux, a daemon is required to provide clipboard access even when 23 | // the process dies. 24 | // 25 | // If no daemon then: 26 | // - Something is copied to the clipboard. It can be pasted into other apps just fine. 27 | // - But, if the process from which the thing was copied dies: so does whatever we copied. 28 | // Clipboard empties! 29 | // 30 | // This daemon is invoked by ferrishot itself. We spawn a new `ferrishot` process and 31 | // pass in a unique argument to ourselves. 32 | // 33 | // If we receive this argument we become a daemon, running a background process 34 | // instead of the usual screenshot app which provides the clipboard item until the 35 | // user copies something else to their clipboard. 36 | // 37 | // More info: 38 | #[cfg(target_os = "linux")] 39 | if std::env::args().nth(1).as_deref() == Some(ferrishot::CLIPBOARD_DAEMON_ID) { 40 | ferrishot::run_clipboard_daemon().expect("Failed to run clipboard daemon"); 41 | return Ok(()); 42 | } 43 | 44 | // Parse command line arguments 45 | let cli = Arc::new(Cli::parse()); 46 | 47 | // Setup logging 48 | ferrishot::logging::initialize(&cli); 49 | 50 | if cli.dump_default_config { 51 | std::fs::create_dir_all( 52 | std::path::PathBuf::from(&cli.config_file) 53 | .parent() 54 | .ok_or_else(|| miette!("Could not get parent path of {}", cli.config_file))?, 55 | ) 56 | .into_diagnostic()?; 57 | 58 | std::fs::write(&cli.config_file, ferrishot::DEFAULT_KDL_CONFIG_STR).into_diagnostic()?; 59 | 60 | if !cli.silent { 61 | println!("Wrote the default config file to {}", cli.config_file); 62 | } 63 | 64 | return Ok(()); 65 | } 66 | 67 | // these variables need to be re-used after the `iced::application` ends 68 | let cli_save_path = cli.save_path.clone(); 69 | let is_silent = cli.silent; 70 | 71 | if let Some(delay) = cli.delay { 72 | if !cli.silent { 73 | println!("Sleeping for {delay:?}..."); 74 | } 75 | std::thread::sleep(delay); 76 | } 77 | 78 | // Parse user's `ferrishot.kdl` config file 79 | let config = Arc::new(ferrishot::Config::parse(&cli.config_file)?); 80 | 81 | // The image that we are going to be editing 82 | let image = Arc::new(ferrishot::get_image(cli.file.as_ref())?); 83 | 84 | // start the app with an initial selection of the image 85 | let initial_region = if cli.last_region { 86 | ferrishot::last_region::read(image.bounds())? 87 | } else { 88 | cli.region.map(|lazy_rect| lazy_rect.init(image.bounds())) 89 | }; 90 | 91 | let generate_output = match (cli.accept_on_select, initial_region) { 92 | // If we want to do an action as soon as we have a selection, 93 | // AND we start the app with the selection: Then don't even launch a window. 94 | // 95 | // Run in 'headless' mode and perform the action instantly 96 | (Some(accept_on_select), Some(region)) => { 97 | let runtime = tokio::runtime::Runtime::new().into_diagnostic()?; 98 | 99 | App::headless(accept_on_select, region, image, cli.json) 100 | .pipe(|fut| runtime.block_on(fut)) 101 | .map_err(|err| miette!("Failed to start ferrishot (headless): {err}"))? 102 | .pipe(Some) 103 | } 104 | // Launch full ferrishot app 105 | _ => { 106 | iced::application( 107 | move || { 108 | App::builder() 109 | .cli(Arc::clone(&cli)) 110 | .config(Arc::clone(&config)) 111 | .maybe_initial_region(initial_region) 112 | .image(Arc::clone(&image)) 113 | .build() 114 | }, 115 | App::update, 116 | App::view, 117 | ) 118 | .subscription(App::subscription) 119 | .window(iced::window::Settings { 120 | level: iced::window::Level::Normal, 121 | fullscreen: true, 122 | icon: Some( 123 | iced::window::icon::from_rgba(LOGO.to_vec(), 64, 64) 124 | .expect("Icon to be valid RGBA bytes"), 125 | ), 126 | ..Default::default() 127 | }) 128 | .title("ferrishot") 129 | .default_font(iced::Font::MONOSPACE) 130 | .run() 131 | .map_err(|err| miette!("Failed to start ferrishot: {err}"))?; 132 | 133 | None 134 | } 135 | }; 136 | 137 | let saved_path = if let Some(saved_image) = ferrishot::SAVED_IMAGE.get() { 138 | if let Some(save_path) = cli_save_path.or_else(|| { 139 | // Open file explorer to choose where to save the image 140 | let dialog = rfd::FileDialog::new() 141 | .set_title("Save Screenshot") 142 | .save_file(); 143 | 144 | if dialog.is_none() { 145 | log::info!("The file dialog was closed before a file was chosen"); 146 | } 147 | 148 | dialog 149 | }) { 150 | saved_image 151 | .save(&save_path) 152 | .map_err(|err| miette!("Failed to save the screenshot: {err}"))?; 153 | 154 | Some(save_path) 155 | } else { 156 | None 157 | } 158 | } else { 159 | None 160 | }; 161 | 162 | if let Some(print_output) = generate_output { 163 | let output = print_output(saved_path); 164 | if !is_silent { 165 | print!("{output}"); 166 | } 167 | } 168 | Ok(()) 169 | } 170 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | //! A message represents some event in the app that mutates the global state 2 | 3 | use crate::Command; 4 | use crate::ui; 5 | use std::time::Instant; 6 | 7 | /// Handles all mutation of the global state, the `App`. 8 | pub trait Handler { 9 | /// Handle the message, mutating the `App`. 10 | fn handle(self, app: &mut crate::App) -> iced::Task; 11 | } 12 | 13 | /// Represents an action happening in the application 14 | #[derive(Debug, Clone)] 15 | pub enum Message { 16 | /// Close the app 17 | Exit, 18 | /// Close the current popup 19 | ClosePopup, 20 | /// Image uploaded message 21 | ImageUploaded(ui::popup::image_uploaded::Message), 22 | /// A certain moment. This message is used for animations 23 | Tick(Instant), 24 | /// Letters message 25 | Letters(ui::popup::letters::Message), 26 | /// Size indicator message 27 | SizeIndicator(ui::size_indicator::Message), 28 | /// Selection message 29 | Selection(Box), 30 | /// Keybinding cheatsheet message 31 | KeyCheatsheet(ui::popup::keybindings_cheatsheet::Message), 32 | /// An error occured, display to the user 33 | Error(String), 34 | /// Do nothing 35 | NoOp, 36 | /// A command can be triggered by a keybind 37 | /// 38 | /// It can also be triggered through other means, such as pressing a button 39 | Command { 40 | /// What to do when this keybind is pressed 41 | action: Command, 42 | /// How many times it was pressed 43 | /// 44 | /// This does not always have an effect, such as it does not make sense to 45 | /// move the selection to the center several times 46 | /// 47 | /// It has an effect for stuff like moving the selection right by `N` pixels 48 | /// in which case we'd move to the right by `N * count` instead 49 | count: u32, 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/background_image.rs: -------------------------------------------------------------------------------- 1 | //! Renders the full, unprocessed desktop screenshot on the screen 2 | use iced::Length::Fill; 3 | use iced::advanced::widget::Tree; 4 | use iced::advanced::{Layout, Widget, layout, renderer}; 5 | use iced::widget::image; 6 | use iced::{Element, Length, Rectangle, Size, Theme, mouse}; 7 | 8 | #[derive(Debug)] 9 | /// A widget that draws an image on the entire screen 10 | pub struct BackgroundImage { 11 | /// Image handle of the full-desktop screenshot 12 | pub image_handle: image::Handle, 13 | } 14 | 15 | impl Widget for BackgroundImage 16 | where 17 | Renderer: iced::advanced::Renderer + iced::advanced::image::Renderer, 18 | { 19 | fn size(&self) -> Size { 20 | Size { 21 | width: Fill, 22 | height: Fill, 23 | } 24 | } 25 | 26 | fn layout( 27 | &self, 28 | _tree: &mut Tree, 29 | renderer: &Renderer, 30 | limits: &layout::Limits, 31 | ) -> layout::Node { 32 | image::layout( 33 | renderer, 34 | limits, 35 | &self.image_handle, 36 | Fill, 37 | Fill, 38 | iced::ContentFit::Contain, 39 | iced::Rotation::Solid(0.into()), 40 | ) 41 | } 42 | 43 | fn draw( 44 | &self, 45 | _state: &Tree, 46 | renderer: &mut Renderer, 47 | _theme: &Theme, 48 | _style: &renderer::Style, 49 | layout: Layout<'_>, 50 | _cursor: mouse::Cursor, 51 | viewport: &Rectangle, 52 | ) { 53 | image::draw( 54 | renderer, 55 | layout, 56 | viewport, 57 | &self.image_handle, 58 | iced::ContentFit::Contain, 59 | image::FilterMethod::Nearest, 60 | iced::Rotation::Solid(0.into()), 61 | 1.0, 62 | 1.0, 63 | ); 64 | } 65 | } 66 | 67 | impl From for Element<'_, Message, Theme, Renderer> 68 | where 69 | Renderer: iced::advanced::Renderer + iced::advanced::image::Renderer, 70 | { 71 | fn from(widget: BackgroundImage) -> Self { 72 | Self::new(widget) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ui/debug_overlay.rs: -------------------------------------------------------------------------------- 1 | //! Shows useful information when pressing F12 2 | 3 | use iced::{ 4 | Background, Element, 5 | Length::Fill, 6 | Task, Theme, 7 | widget::{Column, column, container, horizontal_space, row, scrollable, text, vertical_space}, 8 | }; 9 | 10 | crate::declare_commands! { 11 | enum Command { 12 | /// Toggle the overlay showing various information for debugging 13 | ToggleDebugOverlay, 14 | } 15 | } 16 | 17 | impl crate::command::Handler for Command { 18 | fn handle(self, app: &mut crate::App, _count: u32) -> Task { 19 | match self { 20 | Self::ToggleDebugOverlay => { 21 | app.popup = Some(super::popup::Popup::KeyCheatsheet); 22 | } 23 | } 24 | 25 | Task::none() 26 | } 27 | } 28 | 29 | /// Space between the label and what it represents 30 | const LABEL_SPACE: f32 = 25.0; 31 | 32 | /// Debug overlay shows useful information when pressing F12 33 | pub fn debug_overlay(app: &crate::App) -> Element { 34 | let container_style = |_: &Theme| container::Style { 35 | text_color: Some(app.config.theme.debug_fg), 36 | background: Some(Background::Color(app.config.theme.debug_bg)), 37 | ..Default::default() 38 | }; 39 | 40 | row![ 41 | container( 42 | scrollable( 43 | column![ 44 | text("Selection").color(app.config.theme.debug_label), 45 | vertical_space().height(LABEL_SPACE), 46 | ] 47 | .push_maybe(app.selection.map(|sel| text!("{sel:#?}"))) 48 | ) 49 | .width(400.0), 50 | ) 51 | .style(container_style), 52 | container( 53 | scrollable(column![ 54 | text("Screenshot").color(app.config.theme.debug_label), 55 | vertical_space().height(LABEL_SPACE), 56 | text!("{:#?}", app.image), 57 | ]) 58 | .width(400.0), 59 | ) 60 | .style(container_style), 61 | horizontal_space().width(Fill), 62 | container( 63 | scrollable(column![ 64 | text("Latest Messages").color(app.config.theme.debug_label), 65 | app.logged_messages 66 | .iter() 67 | .rev() 68 | .take(5) 69 | .map(|message| text!("{message:#?}").into()) 70 | .collect::>() 71 | ]) 72 | .width(400.0) 73 | .height(Fill), 74 | ) 75 | .style(container_style) 76 | ] 77 | .width(Fill) 78 | .height(Fill) 79 | .into() 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/errors.rs: -------------------------------------------------------------------------------- 1 | //! Show errors to the user when something is wrong 2 | 3 | use std::{ 4 | borrow::Cow, 5 | time::{Duration, Instant}, 6 | }; 7 | 8 | use iced::{ 9 | Background, Element, 10 | widget::{self, Column, Space, container, row}, 11 | }; 12 | 13 | /// Show an error message to the user 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd)] 15 | pub struct ErrorMessage { 16 | /// Error message 17 | pub message: Cow<'static, str>, 18 | /// When the error was created 19 | pub timestamp: Instant, 20 | } 21 | 22 | impl ErrorMessage { 23 | /// Create a new error message 24 | pub fn new>>(message: T) -> Self { 25 | Self { 26 | message: message.into(), 27 | timestamp: Instant::now(), 28 | } 29 | } 30 | } 31 | 32 | use crate::message::Message; 33 | 34 | /// Width of error message 35 | const ERROR_WIDTH: u32 = 300; 36 | 37 | /// When the error appears, how long should it take until it will disappear 38 | const ERROR_DURATION: Duration = Duration::from_secs(4); 39 | 40 | /// Render errors on the screen 41 | #[derive(Default, Debug)] 42 | pub struct Errors { 43 | /// A list of errors to show 44 | pub errors: Vec, 45 | } 46 | 47 | impl Errors { 48 | /// Add a new error to the list of errors 49 | pub fn push> + std::fmt::Display>(&mut self, error: T) { 50 | self.errors.push(ErrorMessage::new(error)); 51 | } 52 | 53 | /// Show errors on the screen 54 | pub fn view<'app>(&self, app: &'app super::App) -> Element<'app, Message> { 55 | let image_width = app.image.width(); 56 | let errors = self 57 | .errors 58 | .iter() 59 | .rev() 60 | // don't display more than the most recent 3 errors 61 | .take(3) 62 | .filter(|&error| (error.timestamp.elapsed() < ERROR_DURATION)) 63 | .map(|error| { 64 | container(widget::text!("Error: {}", error.message)) 65 | .height(80) 66 | .width(ERROR_WIDTH) 67 | .style(|_| container::Style { 68 | text_color: Some(app.config.theme.error_fg), 69 | background: Some(Background::Color(app.config.theme.error_bg)), 70 | border: iced::Border { 71 | color: app.config.theme.drop_shadow, 72 | width: 4.0, 73 | radius: 2.0.into(), 74 | }, 75 | shadow: iced::Shadow::default(), 76 | }) 77 | .padding(10.0) 78 | .into() 79 | }) 80 | .collect::>() 81 | .width(ERROR_WIDTH) 82 | .spacing(30); 83 | 84 | row![Space::with_width(image_width - ERROR_WIDTH), errors].into() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/ui/grid.rs: -------------------------------------------------------------------------------- 1 | //! Grid allows to position items on a canvas in a grid with labels 2 | 3 | use bon::Builder; 4 | use iced::{ 5 | Point, Rectangle, Size, 6 | advanced::graphics::geometry, 7 | widget::canvas::{Frame, Stroke}, 8 | }; 9 | 10 | use crate::geometry::{PointExt as _, RectangleExt as _, StrokeExt as _, TextExt as _}; 11 | 12 | /// A cell in a grid 13 | #[derive(Clone, Debug, Builder)] 14 | pub struct Cell<'frame, Draw: FnOnce(&mut Frame, Rectangle)> { 15 | /// The closure. Determines what to draw inside of the table cell 16 | draw: Draw, 17 | /// Stroke to draw around the cell 18 | stroke: Option>, 19 | /// Label of the cell. Drawn above the cell 20 | label: Option, 21 | /// Description of the cell. Drawn below the cell 22 | description: Option, 23 | } 24 | 25 | impl Cell<'_, Draw> { 26 | /// Draw the `Cell` 27 | pub fn draw(self, frame: &mut Frame, bounds: Rectangle) { 28 | // Stroke 29 | if let Some(stroke) = self.stroke { 30 | frame.stroke_rectangle(bounds.top_left(), bounds.size(), stroke); 31 | } 32 | 33 | // Label 34 | if let Some(label) = self.label { 35 | // center horizontally 36 | let label = label.position(|text_size| { 37 | Point::new( 38 | bounds.center_x_for(text_size), 39 | bounds.y - text_size.height - 4.0, 40 | ) 41 | }); 42 | 43 | frame.fill_text(label); 44 | } 45 | 46 | // Description 47 | if let Some(description) = self.description { 48 | let description = description.position(|text_size| { 49 | Point::new( 50 | bounds.center_x_for(text_size), 51 | bounds.y + bounds.height + 4.0, 52 | ) 53 | }); 54 | 55 | frame.fill_text(description); 56 | } 57 | 58 | // Draw cell contents 59 | (self.draw)(frame, bounds); 60 | } 61 | } 62 | 63 | /// A grid for a canvas 64 | #[derive(Clone, Debug, Builder)] 65 | pub struct Grid<'frame, Draw: FnOnce(&mut Frame, Rectangle)> { 66 | /// Top-left corner of the `Grid` 67 | top_left: Point, 68 | /// Cells of the grid 69 | cells: Vec>, 70 | /// Column count of the grid 71 | columns: usize, 72 | /// Size of each item 73 | cell_size: Size, 74 | /// Title of the grid. Drawn above the grid 75 | title: Option<(geometry::Text, f32)>, 76 | /// Description of the grid. Drawn below the grid 77 | description: Option<(geometry::Text, f32)>, 78 | /// Draw red border around grid items, for debugging purposes 79 | #[builder(default, with = || true)] 80 | dbg: bool, 81 | /// How much space to put between each item 82 | #[builder(default)] 83 | spacing: Size, 84 | } 85 | 86 | impl Grid<'_, Draw> { 87 | /// Region occupied by the `Grid` 88 | pub fn rect(&self) -> Rectangle { 89 | Rectangle::new(self.top_left, self.size()) 90 | } 91 | 92 | /// Size of the `Grid` 93 | pub fn size(&self) -> Size { 94 | let rows = self.cells.len() / self.columns; 95 | 96 | Size { 97 | width: self.columns as f32 * self.cell_size.width 98 | + (self.columns as f32 - 1.0) * self.spacing.width, 99 | height: (rows as f32) * self.cell_size.height 100 | + (rows as f32 - 1.0) * self.spacing.height 101 | + self 102 | .title 103 | .as_ref() 104 | .map_or(0.0, |title| title.0.size().height + title.1) 105 | + self 106 | .description 107 | .as_ref() 108 | .map_or(0.0, |desc| desc.0.size().height + desc.1), 109 | } 110 | } 111 | 112 | /// Draw the `Grid` on the `Frame` of a `Canvas` 113 | pub fn draw(self, frame: &mut Frame) { 114 | let grid_rect = Rectangle::new(self.top_left, self.size()); 115 | 116 | if self.dbg { 117 | frame.stroke_rectangle(grid_rect.top_left(), grid_rect.size(), Stroke::RED); 118 | } 119 | 120 | // how much vertical space the title takes up 121 | let title_vspace = self.title.map_or(0.0, |title| { 122 | let title_size = title.0.size(); 123 | 124 | let text_title = title.0.position(|text_size| Point { 125 | x: grid_rect.center_x_for(text_size), 126 | y: grid_rect.y, 127 | }); 128 | 129 | if self.dbg { 130 | frame.stroke_rectangle(text_title.position, title_size, Stroke::RED); 131 | } 132 | 133 | frame.fill_text(text_title); 134 | 135 | title_size.height + title.1 136 | }); 137 | 138 | if let Some(desc) = self.description { 139 | let desc_size = desc.0.size(); 140 | 141 | let desc = desc.0.position(|text_size| Point { 142 | x: grid_rect.center_x_for(text_size), 143 | y: grid_rect.y + grid_rect.height - text_size.height, 144 | }); 145 | 146 | if self.dbg { 147 | frame.stroke_rectangle(desc.position, desc_size, Stroke::RED); 148 | } 149 | 150 | frame.fill_text(desc); 151 | } 152 | 153 | for (index, cell) in self.cells.into_iter().enumerate() { 154 | let rows_drawn = (index / self.columns) as f32; 155 | let cols_drawn = (index % self.columns) as f32; 156 | 157 | let cell_top_left = Point::new( 158 | cols_drawn * self.cell_size.width + self.spacing.width * cols_drawn, 159 | rows_drawn * self.cell_size.height 160 | + self.spacing.height * rows_drawn 161 | + title_vspace, 162 | ) + self.top_left.into_vector(); 163 | 164 | cell.draw(frame, Rectangle::new(cell_top_left, self.cell_size)); 165 | 166 | if self.dbg { 167 | frame.stroke_rectangle(cell_top_left, self.cell_size, Stroke::RED); 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | //! Widgets with custom styles 2 | 3 | use iced::Element; 4 | 5 | pub mod app; 6 | mod background_image; 7 | pub mod debug_overlay; 8 | mod errors; 9 | mod grid; 10 | mod selection_icons; 11 | mod welcome_message; 12 | 13 | pub mod selection; 14 | 15 | use background_image::BackgroundImage; 16 | use debug_overlay::debug_overlay; 17 | use errors::Errors; 18 | 19 | pub mod popup; 20 | 21 | pub mod size_indicator; 22 | use size_indicator::size_indicator; 23 | 24 | use selection_icons::SelectionIcons; 25 | use welcome_message::welcome_message; 26 | 27 | pub use app::App; 28 | 29 | /// An extension trait to show a red border around an element and all children 30 | #[easy_ext::ext(Explainer)] 31 | #[expect( 32 | clippy::allow_attributes, 33 | reason = "so we dont have to switch between expect/allow" 34 | )] 35 | #[allow(dead_code, reason = "useful to exist for debugging")] 36 | pub impl<'a, M: 'a, E> E 37 | where 38 | E: Into>, 39 | { 40 | /// Shows red border around an element and all of its children 41 | fn explain(self) -> Element<'a, M> { 42 | self.into().explain(iced::Color::from_rgb8(255, 0, 0)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/popup/mod.rs: -------------------------------------------------------------------------------- 1 | //! Popups are overlaid on top of the screen. 2 | //! They block any inputs 3 | //! 4 | //! Only one of the popups can be active at any time (see `Popup` enum) 5 | 6 | pub mod keybindings_cheatsheet; 7 | use iced::Background; 8 | use iced::Element; 9 | use iced::Length::Fill; 10 | pub use keybindings_cheatsheet::KeybindingsCheatsheet; 11 | 12 | pub mod image_uploaded; 13 | pub use image_uploaded::ImageUploaded; 14 | 15 | use iced::widget::{ 16 | button, column, container, horizontal_space, row, stack, svg, tooltip, vertical_space, 17 | }; 18 | pub mod letters; 19 | pub use letters::Letters; 20 | 21 | /// Popup are overlaid on top and they block any events. allowing only Escape to close 22 | /// the popup. 23 | #[derive(Debug, strum::EnumTryAs)] 24 | pub enum Popup { 25 | /// Letters allow picking a one of 10,000+ regions on the screen in 4 keystrokes 26 | Letters(letters::State), 27 | /// An image has been uploaded to the internet 28 | ImageUploaded(image_uploaded::State), 29 | /// Shows available commands 30 | KeyCheatsheet, 31 | } 32 | 33 | /// Elements inside of a `popup` render in the center of the screen 34 | /// with a close button 35 | fn popup<'app>( 36 | size: iced::Size, 37 | contents: impl Into>, 38 | theme: &'app crate::Theme, 39 | ) -> Element<'app, crate::Message> { 40 | container(stack![ 41 | contents.into(), 42 | // 43 | // Close Button 'x' in the top right corner 44 | // 45 | column![ 46 | vertical_space().height(10.0), 47 | row![ 48 | horizontal_space().width(Fill), 49 | super::selection_icons::icon_tooltip( 50 | button( 51 | crate::icon!(Close) 52 | .style(|_, _| svg::Style { 53 | color: Some(theme.popup_close_icon_fg) 54 | }) 55 | .width(24.0) 56 | .height(24.0) 57 | ) 58 | .on_press(crate::Message::ClosePopup) 59 | .style(|_, _| button::Style { 60 | background: Some(Background::Color(theme.popup_close_icon_bg)), 61 | ..Default::default() 62 | }), 63 | "Close", 64 | tooltip::Position::Right, 65 | theme 66 | ), 67 | horizontal_space().width(10.0) 68 | ] 69 | .height(size.height) 70 | .width(size.width) 71 | ] 72 | ]) 73 | .center(Fill) 74 | .into() 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/size_indicator.rs: -------------------------------------------------------------------------------- 1 | //! Renders a tiny numeric input which shows a dimension of the rect and allow resizing it 2 | 3 | use super::{App, selection::OptionalSelectionExt as _}; 4 | use iced::{ 5 | Background, Element, Length, Rectangle, Task, 6 | widget::{self, Space, column, row, text::Shaping}, 7 | }; 8 | 9 | use crate::{geometry::RectangleExt as _, ui::selection::SelectionIsSome}; 10 | 11 | /// One of the values in the size indicator has changed 12 | #[derive(Clone, Debug)] 13 | pub enum Message { 14 | /// Change the height of the selection, bottom right does not move 15 | ResizeVertically { 16 | /// Change height of the selection to this 17 | new_height: u32, 18 | /// A key to obtain `&mut Selection` from `Option` with a guarantee that it will 19 | /// always be there (to bypass the limitation that we cannot pass `&mut Selection` in a `Message`) 20 | sel_is_some: SelectionIsSome, 21 | }, 22 | /// Change the width of the selection, bottom right does not move 23 | ResizeHorizontally { 24 | /// Change width of the selection to this 25 | new_width: u32, 26 | /// A key to obtain `&mut Selection` from `Option` with a guarantee that it will 27 | /// always be there (to bypass the limitation that we cannot pass `&mut Selection` in a `Message`) 28 | sel_is_some: SelectionIsSome, 29 | }, 30 | } 31 | 32 | impl crate::message::Handler for Message { 33 | fn handle(self, app: &mut App) -> Task { 34 | match self { 35 | Self::ResizeVertically { 36 | new_height, 37 | sel_is_some, 38 | } => { 39 | let sel = app.selection.unlock(sel_is_some); 40 | 41 | // what is the minimum value for `new_height` that would make 42 | // this overflow vertically? 43 | // We want to make sure the selection cannot get bigger than that. 44 | let new_height = 45 | new_height.min((sel.norm().rect.y + sel.norm().rect.height) as u32); 46 | 47 | let dy = new_height as f32 - sel.norm().rect.height; 48 | *sel = sel 49 | .norm() 50 | .with_height(|_| new_height as f32) 51 | .with_y(|y| y - dy); 52 | } 53 | Self::ResizeHorizontally { 54 | new_width, 55 | sel_is_some, 56 | } => { 57 | let sel = app.selection.unlock(sel_is_some); 58 | 59 | // what is the minimum value for `new_width` that would make 60 | // this overflow vertically? 61 | // We want to make sure the selection cannot get bigger than that. 62 | let new_width = new_width.min((sel.norm().rect.x + sel.norm().rect.width) as u32); 63 | 64 | let dx = new_width as f32 - sel.norm().rect.width; 65 | *sel = sel 66 | .norm() 67 | .with_width(|_| new_width as f32) 68 | .with_x(|x| x - dx); 69 | } 70 | } 71 | 72 | Task::none() 73 | } 74 | } 75 | 76 | /// Renders the indicator for a single dimension (e.g. width or height) 77 | fn dimension_indicator<'a>( 78 | value: u32, 79 | on_change: impl Fn(u32) -> crate::Message + 'a, 80 | theme: &'a crate::Theme, 81 | ) -> widget::TextInput<'a, crate::Message> { 82 | let content = value.to_string(); 83 | let input = widget::text_input(Default::default(), content.as_str()) 84 | // HACK: iced does not provide a way to mimic `width: min-content` from CSS 85 | // so we have to "guesstimate" the width that each character will be 86 | // `Length::Shrink` makes `width = 0` for some reason 87 | .width(Length::Fixed((12 * content.len()) as f32)) 88 | .on_input(move |s| { 89 | // if we get "" it means user e.g. just deleted everything 90 | if s.is_empty() { 91 | on_change(0) 92 | } else { 93 | s.parse::() 94 | .ok() 95 | .map_or(crate::Message::NoOp, &on_change) 96 | } 97 | }) 98 | .style(move |_, _| widget::text_input::Style { 99 | value: theme.size_indicator_fg, 100 | selection: theme.text_selection, 101 | // --- none 102 | background: Background::Color(iced::Color::TRANSPARENT), 103 | border: iced::Border { 104 | color: iced::Color::TRANSPARENT, 105 | width: 0.0, 106 | radius: 0.0.into(), 107 | }, 108 | icon: iced::Color::TRANSPARENT, 109 | placeholder: iced::Color::TRANSPARENT, 110 | }) 111 | .padding(0.0); 112 | 113 | input 114 | } 115 | 116 | /// Renders a tiny numeric input which shows a dimension of the rect and allow resizing it 117 | pub fn size_indicator( 118 | app: &App, 119 | selection_rect: Rectangle, 120 | sel_is_some: SelectionIsSome, 121 | ) -> Element { 122 | const SPACING: f32 = 12.0; 123 | const ESTIMATED_INDICATOR_WIDTH: u32 = 120; 124 | const ESTIMATED_INDICATOR_HEIGHT: u32 = 26; 125 | 126 | let image_height = app.image.height(); 127 | let image_width = app.image.width(); 128 | 129 | let x_offset = (selection_rect.bottom_right().x + SPACING) 130 | .min((image_width - ESTIMATED_INDICATOR_WIDTH) as f32); 131 | let y_offset = (selection_rect.bottom_right().y + SPACING) 132 | .min((image_height - ESTIMATED_INDICATOR_HEIGHT) as f32); 133 | 134 | let horizontal_space = Space::with_width(x_offset); 135 | let vertical_space = Space::with_height(y_offset); 136 | 137 | let width = dimension_indicator( 138 | selection_rect.width as u32, 139 | move |new_width| { 140 | crate::Message::SizeIndicator(Message::ResizeHorizontally { 141 | new_width, 142 | sel_is_some, 143 | }) 144 | }, 145 | &app.config.theme, 146 | ); 147 | let height = dimension_indicator( 148 | selection_rect.height as u32, 149 | move |new_height| { 150 | crate::Message::SizeIndicator(Message::ResizeVertically { 151 | new_height, 152 | sel_is_some, 153 | }) 154 | }, 155 | &app.config.theme, 156 | ); 157 | 158 | let x = widget::text("✕ ") 159 | .color(app.config.theme.size_indicator_fg) 160 | .shaping(Shaping::Advanced); 161 | let space = widget::text(" "); 162 | let c = widget::container(row![space, width, x, height]).style(|_| widget::container::Style { 163 | text_color: None, 164 | background: Some(Background::Color(app.config.theme.size_indicator_bg)), 165 | border: iced::Border::default(), 166 | shadow: iced::Shadow::default(), 167 | }); 168 | 169 | column![vertical_space, row![horizontal_space, c]].into() 170 | } 171 | -------------------------------------------------------------------------------- /src/ui/welcome_message.rs: -------------------------------------------------------------------------------- 1 | //! The welcome message contains tips on how to use ferrishot 2 | 3 | use iced::{ 4 | Background, Element, Font, 5 | Length::{self, Fill}, 6 | alignment::Vertical, 7 | widget::{Column, Space, column, row, text, text::Shaping}, 8 | }; 9 | 10 | use crate::message::Message; 11 | 12 | /// Width of the welcome message box 13 | const WIDTH: u32 = 380; 14 | /// Size of the font in the welcome message box 15 | const FONT_SIZE: f32 = 13.0; 16 | /// Tips to show to the user in the welcome message 17 | const SPACING: f32 = 8.0; 18 | /// Padding of the tips 19 | const PADDING: f32 = 10.0; 20 | /// Tips: The Key, and Action for each Key 21 | const TIPS: [(&str, &str); 7] = [ 22 | ("Mouse", "Select screenshot area"), 23 | ("Ctrl + S", "Save screenshot to a file"), 24 | ("Enter", "Copy screenshot to clipboard"), 25 | ("Right Click", "Snap closest corner to mouse"), 26 | ("Shift + Mouse", "Slowly resize / move area"), 27 | ("?", "Open Keybindings Cheatsheet"), 28 | ("Esc", "Exit"), 29 | ]; 30 | /// Height of the welcome message box 31 | const HEIGHT: f32 = 32 | 30.0 + TIPS.len() as f32 * FONT_SIZE + (TIPS.len() - 1) as f32 * SPACING + (PADDING * 2.0); 33 | 34 | /// Renders the welcome message that the user sees when they first launch the program 35 | pub fn welcome_message(app: &super::App) -> Element { 36 | let image_width = app.image.width(); 37 | let image_height = app.image.height(); 38 | let vertical_space = Space::with_height(image_height / 2 - HEIGHT as u32 / 2); 39 | let horizontal_space = Space::with_width(image_width / 2 - WIDTH / 2); 40 | 41 | let stuff = iced::widget::container( 42 | TIPS.into_iter() 43 | .map(|(key, action)| { 44 | row![ 45 | row![ 46 | Space::with_width(Fill), 47 | text(key) 48 | .size(FONT_SIZE) 49 | .font(Font { 50 | weight: iced::font::Weight::Bold, 51 | ..Font::default() 52 | }) 53 | .shaping(Shaping::Advanced) 54 | .align_y(Vertical::Bottom) 55 | ] 56 | .width(100.0), 57 | Space::with_width(Length::Fixed(20.0)), 58 | text(action).size(FONT_SIZE).align_y(Vertical::Bottom), 59 | ] 60 | .into() 61 | }) 62 | .collect::>() 63 | .spacing(SPACING) 64 | .height(HEIGHT) 65 | .width(WIDTH) 66 | .padding(PADDING), 67 | ) 68 | .style(|_| iced::widget::container::Style { 69 | text_color: Some(app.config.theme.info_box_fg), 70 | background: Some(Background::Color(app.config.theme.info_box_bg)), 71 | border: iced::Border::default() 72 | .color(app.config.theme.info_box_border) 73 | .rounded(6.0) 74 | .width(1.5), 75 | shadow: iced::Shadow::default(), 76 | }); 77 | 78 | column![vertical_space, row![horizontal_space, stuff]].into() 79 | } 80 | -------------------------------------------------------------------------------- /tests/lib.rs: -------------------------------------------------------------------------------- 1 | // #![allow(clippy::unwrap_used, reason = "relaxed rules in tests")] 2 | // //! Tests 3 | 4 | // use std::path::PathBuf; 5 | 6 | // use clap::Parser as _; 7 | // use ferrishot::Cli; 8 | // use tempfile::NamedTempFile; 9 | 10 | // fn cli(args: &[&str]) -> (Cli, PathBuf) { 11 | // let temp_path = NamedTempFile::new().unwrap(); 12 | // let cli = Cli::parse_from( 13 | // vec![ 14 | // "--accept-on-select", 15 | // "save-screenshot", 16 | // "--save-path", 17 | // temp_path.path().to_str().unwrap(), 18 | // ] 19 | // .iter() 20 | // .chain(args), 21 | // ); 22 | 23 | // (cli, temp_path.path().to_path_buf()) 24 | // } 25 | 26 | // /// Help and version must output to the standard output 27 | // /// 28 | // /// They need to 29 | // #[test] 30 | // fn help_and_version() { 31 | // let (cli, saved_path) = cli(&["-V"]); 32 | // assert_eq!(saved_path) 33 | // } 34 | 35 | // #[test] 36 | // fn region_100x100_bottom_right_corner() { 37 | // let cli = Cli::parse_from(vec![ 38 | // "--accept-on-select", 39 | // "save-screenshot", 40 | // "--save-path", 41 | // temp.path().as_os_str().to_str().unwrap(), 42 | // "--region", 43 | // ]); 44 | // } 45 | --------------------------------------------------------------------------------