├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── fonts │ ├── SpaceGrotesk-Bold.ttf │ ├── SpaceGrotesk-Medium.ttf │ └── capter.ttf ├── images │ ├── banner_dark.png │ ├── banner_light.png │ ├── dialog_image.png │ └── wix_banner.png └── resources │ ├── icon.png │ ├── linux │ ├── capter.desktop │ ├── hicolor │ │ ├── 128x128 │ │ │ └── apps │ │ │ │ └── capter.png │ │ ├── 256x256 │ │ │ └── apps │ │ │ │ └── capter.png │ │ ├── 32x32 │ │ │ └── apps │ │ │ │ └── capter.png │ │ └── 48x48 │ │ │ └── apps │ │ │ └── capter.png │ ├── pkgbuild_capter │ └── pkgbuild_capter_bin │ ├── macos │ └── icon.icns │ └── windows │ └── icon.ico ├── build.rs ├── rustfmt.toml └── src ├── action.rs ├── capture ├── canvas.rs ├── crop.rs ├── draw.rs ├── image.rs ├── init.rs ├── mod.rs ├── mode.rs ├── update.rs └── view.rs ├── config.rs ├── consts.rs ├── ipc.rs ├── key_listener.rs ├── main.rs ├── notify.rs ├── organize_type.rs ├── settings ├── init.rs ├── mod.rs ├── update.rs └── view.rs ├── subscription.rs ├── theme ├── button.rs ├── container.rs ├── menu.rs ├── mod.rs ├── picklist.rs ├── scrollable.rs ├── slider.rs ├── text.rs ├── text_input.rs └── toggler.rs ├── tray_icon.rs ├── update.rs ├── view.rs └── window.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: decipher3114 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | include: 18 | - target: x86_64-unknown-linux-gnu 19 | os: ubuntu-latest 20 | container: "archlinux" 21 | - target: x86_64-unknown-linux-gnu 22 | os: ubuntu-latest 23 | container: "" 24 | - target: x86_64-apple-darwin 25 | os: macos-latest 26 | - target: x86_64-pc-windows-msvc 27 | os: windows-latest 28 | 29 | runs-on: ${{ matrix.os }} 30 | container: ${{ matrix.container }} 31 | 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Install Packages (apt) 37 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == '' }} 38 | uses: awalsh128/cache-apt-pkgs-action@latest 39 | with: 40 | packages: pkg-config libclang-dev libxcb1-dev libxrandr-dev libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev libxdo-dev libgtk-3-dev libgbm-dev 41 | version: 1.0 42 | 43 | - name: Install Packages (pacman) 44 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == 'archlinux' }} 45 | run: | 46 | pacman -Syyu --noconfirm base-devel clang libxcb libxrandr dbus libpipewire xdotool gtk3 47 | 48 | - name: Setup Dev Drive 49 | if: ${{ matrix.os == 'windows-latest' }} 50 | uses: samypr100/setup-dev-drive@v3 51 | with: 52 | drive-size: 10GB 53 | workspace-copy: true 54 | native-dev-drive: true 55 | env-mapping: | 56 | CARGO_HOME,{{ DEV_DRIVE }}/.cargo 57 | RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup 58 | 59 | - name: Setup Rust 60 | uses: actions-rust-lang/setup-rust-toolchain@v1 61 | with: 62 | toolchain: stable 63 | target: ${{ matrix.target }} 64 | cache-shared-key: build-${{ matrix.os }}-${{ matrix.container }} 65 | cache-workspaces: ${{ env.DEV_DRIVE_WORKSPACE }} 66 | 67 | - name: Build binary 68 | working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} 69 | run: | 70 | cargo build --locked 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - v[0-9]+.* 10 | 11 | jobs: 12 | build-assets: 13 | strategy: 14 | matrix: 15 | include: 16 | - target: x86_64-unknown-linux-gnu 17 | os: ubuntu-latest 18 | container: "archlinux" 19 | format: pacman 20 | - target: x86_64-unknown-linux-gnu 21 | os: ubuntu-latest 22 | container: "" 23 | format: deb 24 | - target: x86_64-apple-darwin 25 | os: macos-latest 26 | format: dmg 27 | - target: x86_64-pc-windows-msvc 28 | os: windows-latest 29 | format: wix 30 | runs-on: ${{ matrix.os }} 31 | container: ${{ matrix.container }} 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Install Packages (apt) 37 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == '' }} 38 | uses: awalsh128/cache-apt-pkgs-action@latest 39 | with: 40 | packages: pkg-config libclang-dev libxcb1-dev libxrandr-dev libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev libxdo-dev libgtk-3-dev libgbm-dev 41 | version: 1.0 42 | 43 | - name: Install Packages (pacman) 44 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == 'archlinux' }} 45 | run: | 46 | pacman -Syyu --noconfirm base-devel clang libxcb libxrandr dbus libpipewire xdotool gtk3 47 | 48 | - name: Setup Dev Drive 49 | if: ${{ matrix.os == 'windows-latest' }} 50 | uses: samypr100/setup-dev-drive@v3 51 | with: 52 | drive-size: 10GB 53 | workspace-copy: true 54 | native-dev-drive: true 55 | env-mapping: | 56 | CARGO_HOME,{{ DEV_DRIVE }}/.cargo 57 | RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup 58 | 59 | - name: Install Certificate (Windows) 60 | if: matrix.os == 'windows-latest' 61 | run: | 62 | dotnet tool install --global wix 63 | $base64Cert = '${{ secrets.WINDOWS_CERT }}' 64 | [System.IO.File]::WriteAllBytes("certificate.pfx", [System.Convert]::FromBase64String($base64Cert)) 65 | $password = ConvertTo-SecureString -String '${{ secrets.WINDOWS_CERT_PASSWORD }}' -Force -AsPlainText 66 | Import-PfxCertificate -FilePath 'certificate.pfx' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Password $password 67 | 68 | - name: Setup Rust 69 | uses: actions-rust-lang/setup-rust-toolchain@v1 70 | with: 71 | toolchain: stable 72 | target: ${{ matrix.target }} 73 | cache-shared-key: release-${{ matrix.os }}-${{ matrix.container }} 74 | cache-workspaces: ${{ env.DEV_DRIVE_WORKSPACE }} 75 | 76 | - name: Build binary 77 | working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} 78 | run: | 79 | cargo build --release --locked 80 | 81 | - name: Install cargo-packager 82 | uses: taiki-e/install-action@v2 83 | with: 84 | tool: cargo-packager 85 | 86 | - name: Package binary 87 | working-directory: ${{ env.DEV_DRIVE_WORKSPACE }} 88 | run: | 89 | cargo packager --release --formats ${{ matrix.format }} 90 | 91 | - name: Upload Artifact (msi) 92 | uses: actions/upload-artifact@v4 93 | if: matrix.os == 'windows-latest' 94 | with: 95 | name: msi 96 | path: | 97 | ${{ env.DEV_DRIVE_WORKSPACE }}/target/packages/*.msi 98 | 99 | - name: Upload Artifact (deb) 100 | uses: actions/upload-artifact@v4 101 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == '' }} 102 | with: 103 | name: deb 104 | path: | 105 | target/packages/*.deb 106 | 107 | - name: Upload Artifact (tar) 108 | uses: actions/upload-artifact@v4 109 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.container == 'archlinux' }} 110 | with: 111 | name: tar 112 | path: | 113 | target/packages/*.tar.gz 114 | 115 | - name: Upload Artifacts (dmg) 116 | uses: actions/upload-artifact@v4 117 | if: matrix.os == 'macos-latest' 118 | with: 119 | name: dmg 120 | path: | 121 | target/packages/*.dmg 122 | 123 | after-build-job: 124 | needs: build-assets 125 | runs-on: "ubuntu-latest" 126 | steps: 127 | - name: Checkout 128 | uses: actions/checkout@v4 129 | 130 | - name: Generate CHANGELOG 131 | id: changelog 132 | uses: requarks/changelog-action@v1 133 | with: 134 | token: ${{ github.token }} 135 | tag: ${{ github.ref_name }} 136 | 137 | - name: Download Artifacts 138 | uses: actions/download-artifact@v4 139 | 140 | - name: Create Release and Upload Assets 141 | uses: softprops/action-gh-release@v2 142 | with: 143 | name: ${{ github.event.head_commit.message }} 144 | body: | 145 | ${{ steps.changelog.outputs.changes }} 146 | files: | 147 | msi/* 148 | deb/* 149 | tar/* 150 | dmg/* 151 | draft: false 152 | prerelease: false 153 | 154 | - name: Generate PKGBUILD (capter) 155 | run: | 156 | version=$(echo "${{ github.ref_name }}" | sed 's/^v//') 157 | sed "s/{{version}}/$version/" assets/resources/linux/pkgbuild_capter > PKGBUILD 158 | 159 | - name: Update AUR package (capter) 160 | uses: KSXGitHub/github-actions-deploy-aur@v3 161 | with: 162 | pkgname: capter 163 | pkgbuild: PKGBUILD 164 | commit_username: ${{ secrets.AUR_USERNAME }} 165 | commit_email: ${{ secrets.AUR_EMAIL }} 166 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 167 | commit_message: "Release ${{ github.ref_name}}" 168 | force_push: true 169 | 170 | - name: Generate PKGBUILD (capter-bin) 171 | run: | 172 | version=$(echo "${{ github.ref_name }}" | sed 's/^v//') 173 | sha256sum=$(sha256sum tar/*.tar.gz | awk '{print $1}') 174 | sed -e "s/{{version}}/$version/" -e "s/{{sha256sum}}/$sha256sum/" assets/resources/linux/pkgbuild_capter_bin > PKGBUILD 175 | 176 | - name: Update AUR package (capter-bin) 177 | uses: KSXGitHub/github-actions-deploy-aur@v3 178 | with: 179 | pkgname: capter-bin 180 | pkgbuild: PKGBUILD 181 | commit_username: ${{ secrets.AUR_USERNAME }} 182 | commit_email: ${{ secrets.AUR_EMAIL }} 183 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 184 | commit_message: "Release ${{ github.ref_name}}" 185 | force_push: true 186 | 187 | - name: Update Winget package 188 | uses: vedantmgoyal9/winget-releaser@main 189 | with: 190 | identifier: decipher.Capter 191 | installers-regex: '\.msi$' 192 | max-versions-to-keep: 3 193 | token: ${{ secrets.CLASSIC_PAT }} 194 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "evenBetterToml.formatter.alignComments": true, 3 | "evenBetterToml.formatter.alignEntries": true, 4 | "evenBetterToml.formatter.arrayAutoCollapse": true, 5 | "evenBetterToml.formatter.arrayAutoExpand": true, 6 | "evenBetterToml.formatter.arrayTrailingComma": true, 7 | "evenBetterToml.formatter.trailingNewline": true, 8 | "evenBetterToml.formatter.reorderArrays": true, 9 | "evenBetterToml.formatter.reorderInlineTables": true, 10 | "evenBetterToml.formatter.reorderKeys": true, 11 | "evenBetterToml.rules": [ 12 | { 13 | "formatting": { 14 | "reorder_arrays": false, 15 | "reorder_inline_tables": false, 16 | "reorder_keys": false 17 | }, 18 | "include": [ 19 | "Cargo.toml" 20 | ], 21 | "keys": [ 22 | "package", 23 | ] 24 | } 25 | ], 26 | "rust-analyzer.check.command": "clippy", 27 | "rust-analyzer.imports.granularity.enforce": true, 28 | "rust-analyzer.imports.prefix": "crate", 29 | "rust-analyzer.rustfmt.extraArgs": [ 30 | "+nightly" 31 | ], 32 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "capter" 3 | version = "4.0.0" 4 | edition = "2024" 5 | authors = ["decipher "] 6 | description = "Cross-Platform Screen Capture and Annotation Tool" 7 | license = "Apache-2.0" 8 | categories = ["Utility"] 9 | readme = "README.md" 10 | build = "build.rs" 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | arboard = { version = "3.5", features = [ 15 | "wayland-data-control", 16 | "wl-clipboard-rs", 17 | ] } 18 | chrono = { version = "0.4", default-features = false, features = ["clock"] } 19 | dark-light = "2.0" 20 | dirs = "6.0" 21 | edit-xml = "0.1" 22 | iced = { git = "https://github.com/iced-rs/iced.git", branch = "master", features = [ 23 | "advanced", 24 | "canvas", 25 | "image", 26 | "lazy", 27 | "tokio", 28 | ] } 29 | interprocess = { version = "2.2", features = ["tokio"] } 30 | mouse_position = "0.1" 31 | opener = "0.8" 32 | rdev = { git = "https://github.com/rustdesk-org/rdev", branch = "master" } 33 | resvg = { version = "0.45", default-features = false, features = ["text"] } 34 | rfd = "0.15" 35 | serde = { version = "1.0", features = ["derive"] } 36 | tokio = { version = "1.45", default-features = false, features = ["time"] } 37 | toml = { version = "0.8" } 38 | tray-icon = "0.20" 39 | xcap = { version = "0.6", default-features = false } 40 | 41 | [target.'cfg( target_os = "windows" )'.dependencies] 42 | win32_notif = "0.7" 43 | 44 | [target.'cfg( target_os = "linux" )'.dependencies] 45 | gtk = "0.18" 46 | notify-rust = { version = "4.11", features = ["images"] } 47 | 48 | [target.'cfg( target_os = "macos")'.dependencies] 49 | notify-rust = { version = "4.11", features = ["images"] } 50 | 51 | [target.'cfg( target_os = "windows" )'.build-dependencies] 52 | winresource = "0.1" 53 | 54 | [profile.dev] 55 | opt-level = 0 56 | debug = false 57 | incremental = true 58 | codegen-units = 256 59 | panic = "unwind" 60 | overflow-checks = true 61 | 62 | [profile.release] 63 | codegen-units = 1 64 | lto = true 65 | opt-level = 3 66 | panic = "abort" 67 | overflow-checks = false 68 | strip = "symbols" 69 | 70 | [package.metadata.winresource] 71 | ProductName = "capter" 72 | FileDescription = "capter" 73 | 74 | [package.metadata.packager] 75 | product-name = "Capter" 76 | identifier = "io.github.decipher.capter" 77 | authors = ["decipher"] 78 | publisher = "decipher" 79 | category = "Utility" 80 | license-file = "LICENSE" 81 | copyright = "Copyright © decipher" 82 | before-packaging-command = "cargo build --release" 83 | icons = [ 84 | "assets/resources/windows/icon.ico", 85 | "assets/resources/macos/icon.icns", 86 | "assets/resources/linux/hicolor/*/apps/capter.png", 87 | ] 88 | out-dir = "target/packages" 89 | 90 | [package.metadata.packager.wix] 91 | banner-path = "assets/images/wix_banner.png" 92 | dialog-image-path = "assets/images/dialog_image.png" 93 | 94 | [package.metadata.packager.windows] 95 | digest-algorithim = "SHA256" 96 | certificate-thumbprint = "07a9c417660868a4420fe9e2f8b6ac2e1a33228a" 97 | tsp = true 98 | timestamp-url = "http://timestamp.digicert.com" 99 | 100 | [package.metadata.packager.macos] 101 | minimum-system-version = "10.13" 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 |

8 |

Cross-Platform Screen Capture and Annotation Tool

9 | 10 | ## ✨ Features 11 | 12 | - Capture fullscreen, window, or cropped area with ease 13 | - Window selection assistance for precise captures 14 | - Powerful annotation tools: Rectangle, Circle, Line, Arrow, Freehand, Highlighter, and Text 15 | - Fast and efficient with a minimalistic, user-friendly UI 16 | - Built-in copy-to-clipboard support for quick sharing 17 | 18 | ## 📥 Installation 19 | 20 | - ### Windows 21 | 22 | ``` 23 | winget install decipher.Capter 24 | ``` 25 | 26 | - ### Arch 27 | ``` 28 | paru -S capter 29 | ``` 30 | OR 31 | ``` 32 | yay -S capter 33 | ``` 34 | - ### Debian 35 | 36 | Download from [Releases](https://github.com/decipher3114/Capter/releases/latest) 37 | 38 | - ### Mac OS 39 | 40 | Download from [Releases](https://github.com/decipher3114/Capter/releases/latest) 41 | 42 | - ### Cargo 43 | ``` 44 | cargo install --git https://github.com/decipher3114/Capter 45 | ``` 46 | 47 | ## 🎬 Video 48 | 49 | [![YouTube](http://i.ytimg.com/vi/1RSB8945yJA/0.jpg)](https://www.youtube.com/watch?v=1RSB8945yJA) 50 | 51 | ### 🙌 Thanks to 52 | 53 | - [iced](https://github.com/iced-rs) community for their help 54 | - [XelXen](https://github.com/xelxen) for UI idea 55 | - Other crate maintainers 56 | -------------------------------------------------------------------------------- /assets/fonts/SpaceGrotesk-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/fonts/SpaceGrotesk-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/SpaceGrotesk-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/fonts/SpaceGrotesk-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/capter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/fonts/capter.ttf -------------------------------------------------------------------------------- /assets/images/banner_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/images/banner_dark.png -------------------------------------------------------------------------------- /assets/images/banner_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/images/banner_light.png -------------------------------------------------------------------------------- /assets/images/dialog_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/images/dialog_image.png -------------------------------------------------------------------------------- /assets/images/wix_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/images/wix_banner.png -------------------------------------------------------------------------------- /assets/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/icon.png -------------------------------------------------------------------------------- /assets/resources/linux/capter.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Capter 3 | Comment=Cross-Platform Screen Capture and Annotation Tool 4 | Exec=capter 5 | Icon=capter 6 | Terminal=false 7 | Type=Application 8 | Categories=Utility 9 | -------------------------------------------------------------------------------- /assets/resources/linux/hicolor/128x128/apps/capter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/linux/hicolor/128x128/apps/capter.png -------------------------------------------------------------------------------- /assets/resources/linux/hicolor/256x256/apps/capter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/linux/hicolor/256x256/apps/capter.png -------------------------------------------------------------------------------- /assets/resources/linux/hicolor/32x32/apps/capter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/linux/hicolor/32x32/apps/capter.png -------------------------------------------------------------------------------- /assets/resources/linux/hicolor/48x48/apps/capter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/linux/hicolor/48x48/apps/capter.png -------------------------------------------------------------------------------- /assets/resources/linux/pkgbuild_capter: -------------------------------------------------------------------------------- 1 | # Maintainer: decipher 2 | 3 | _package_name=capter 4 | pkgname="$_package_name" 5 | pkgver={{version}} 6 | pkgrel=1 7 | epoch= 8 | pkgdesc="Cross-Platform Screen Capture and Annotation Tool" 9 | arch=('x86_64') 10 | url="https://github.com/decipher3114/Capter" 11 | license=('Apache-2.0') 12 | 13 | makedepends=( 14 | base-devel 15 | clang 16 | libxcb 17 | libxrandr 18 | dbus 19 | libpipewire 20 | xdotool 21 | gtk3 22 | ) 23 | depends=( 24 | libayatana-appindicator 25 | ) 26 | provides=("${_package_name}") 27 | conflicts=("${_package_name}-bin") 28 | replaces=("${_package_name}") 29 | 30 | source=( 31 | "${_package_name}::git+${url}.git" 32 | ) 33 | sha512sums=('SKIP') 34 | 35 | prepare() { 36 | cd "$srcdir/$_package_name" 37 | export RUSTUP_TOOLCHAIN=stable 38 | cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" 39 | } 40 | 41 | build() { 42 | cd "$srcdir/$_package_name" 43 | cargo build --release --locked 44 | } 45 | 46 | package() { 47 | cd "$srcdir/$_package_name" 48 | 49 | install -Dm755 "target/release/$_package_name" "${pkgdir}/usr/bin/$_package_name" 50 | 51 | install -d "$pkgdir/usr/share/icons/hicolor" 52 | cp -r "assets/resources/linux/hicolor"/* "$pkgdir/usr/share/icons/hicolor/" 53 | 54 | install -Dm644 "assets/resources/linux/capter.desktop" "${pkgdir}/usr/share/applications/${_package_name}.desktop" 55 | 56 | install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${_package_name}/LICENSE" 57 | } 58 | -------------------------------------------------------------------------------- /assets/resources/linux/pkgbuild_capter_bin: -------------------------------------------------------------------------------- 1 | # Maintainer: decipher 2 | 3 | _package_name=capter 4 | pkgname="$_package_name-bin" 5 | pkgver={{version}} 6 | pkgrel=1 7 | epoch= 8 | pkgdesc="Cross-Platform Screen Capture and Annotation Tool (prebuilt binary)" 9 | arch=('x86_64') 10 | url="https://github.com/decipher3114/Capter" 11 | license=('Apache-2.0') 12 | 13 | depends=( 14 | libayatana-appindicator 15 | ) 16 | provides=("${_package_name}") 17 | conflicts=("${_package_name}") 18 | replaces=("${_package_name}") 19 | 20 | source=( 21 | "${_package_name}-${pkgver}.tar.gz::${url}/releases/latest/download/capter_${pkgver}_x86_64.tar.gz" 22 | ) 23 | sha512sums=( 24 | "{{sha512sum}}" 25 | ) 26 | 27 | package() { 28 | cp -r "${srcdir}/"/* "${pkgdir}/" 29 | } 30 | -------------------------------------------------------------------------------- /assets/resources/macos/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/macos/icon.icns -------------------------------------------------------------------------------- /assets/resources/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decipher3114/Capter/974eca7ff5fa688b42d3c876033caa194f94f0a0/assets/resources/windows/icon.ico -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(target_os = "macos")] 3 | println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.13"); 4 | #[cfg(target_os = "windows")] 5 | { 6 | let mut res = winresource::WindowsResource::new(); 7 | res.set_icon("assets/resources/windows/icon.ico"); 8 | res.compile().expect("Resource must compile"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | array_width = 60 2 | attr_fn_like_width = 70 3 | binop_separator = "Front" 4 | blank_lines_lower_bound = 0 5 | blank_lines_upper_bound = 1 6 | brace_style = "SameLineWhere" 7 | chain_width = 60 8 | color = "Auto" 9 | combine_control_expr = true 10 | comment_width = 80 11 | condense_wildcard_suffixes = false 12 | control_brace_style = "AlwaysSameLine" 13 | disable_all_formatting = false 14 | doc_comment_code_block_width = 100 15 | edition = "2024" 16 | emit_mode = "Files" 17 | empty_item_single_line = true 18 | enum_discrim_align_threshold = 0 19 | error_on_line_overflow = false 20 | error_on_unformatted = false 21 | fn_call_width = 60 22 | fn_params_layout = "Tall" 23 | fn_single_line = false 24 | force_explicit_abi = true 25 | force_multiline_blocks = true 26 | format_code_in_doc_comments = false 27 | format_generated_files = true 28 | format_macro_bodies = true 29 | format_macro_matchers = false 30 | format_strings = false 31 | generated_marker_line_search_limit = 5 32 | group_imports = "StdExternalCrate" 33 | hard_tabs = false 34 | hex_literal_case = "Preserve" 35 | ignore = [] 36 | imports_granularity = "Crate" 37 | imports_indent = "Block" 38 | imports_layout = "Mixed" 39 | indent_style = "Block" 40 | inline_attribute_width = 0 41 | make_backup = false 42 | match_arm_blocks = true 43 | match_arm_leading_pipes = "Never" 44 | match_block_trailing_comma = false 45 | max_width = 100 46 | merge_derives = true 47 | newline_style = "Auto" 48 | normalize_comments = false 49 | normalize_doc_attributes = false 50 | overflow_delimited_expr = false 51 | remove_nested_parens = true 52 | reorder_impl_items = true 53 | reorder_imports = true 54 | reorder_modules = true 55 | required_version = "1.8.0" 56 | short_array_element_width_threshold = 10 57 | show_parse_errors = true 58 | single_line_if_else_max_width = 50 59 | single_line_let_else_max_width = 50 60 | skip_children = false 61 | skip_macro_invocations = [] 62 | space_after_colon = true 63 | space_before_colon = false 64 | spaces_around_ranges = false 65 | struct_field_align_threshold = 0 66 | struct_lit_single_line = true 67 | struct_lit_width = 18 68 | struct_variant_width = 35 69 | style_edition = "2024" 70 | tab_spaces = 4 71 | trailing_comma = "Vertical" 72 | trailing_semicolon = true 73 | type_punctuation_density = "Wide" 74 | unstable_features = true 75 | use_field_init_shorthand = false 76 | use_small_heuristics = "Default" 77 | use_try_shorthand = false 78 | where_single_line = false 79 | wrap_comments = false 80 | -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use iced::Task; 4 | 5 | pub struct Action { 6 | pub task: Task, 7 | pub requests: Vec, 8 | } 9 | 10 | impl Action { 11 | pub fn none() -> Self { 12 | Self { 13 | task: Task::none(), 14 | requests: Vec::new(), 15 | } 16 | } 17 | 18 | pub fn task(task: Task) -> Self { 19 | Self { 20 | task, 21 | requests: Vec::new(), 22 | } 23 | } 24 | 25 | pub fn requests(requests: I) -> Self 26 | where 27 | I: IntoIterator, 28 | { 29 | Self { 30 | task: Task::none(), 31 | requests: requests.into_iter().collect(), 32 | } 33 | } 34 | 35 | pub fn with_requests(self, requests: Vec) -> Self { 36 | Self { requests, ..self } 37 | } 38 | } 39 | 40 | impl From> for Action { 41 | fn from(value: Task) -> Self { 42 | Self { 43 | task: value, 44 | requests: Vec::new(), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/capture/canvas.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Mul; 2 | 3 | use iced::{ 4 | Color, Pixels, Point, Radians, Rectangle, Renderer, Size, 5 | alignment::Vertical, 6 | widget::{ 7 | Action, 8 | canvas::{ 9 | Fill, Frame, Geometry, LineCap, LineDash, Path, Program, Stroke, Style, Text, 10 | path::{Builder, arc::Elliptical}, 11 | }, 12 | text::{Alignment, LineHeight}, 13 | }, 14 | }; 15 | 16 | use crate::{ 17 | capture::{ 18 | Capture, Message, 19 | draw::{DrawElement, FONT_SIZE_FACTOR, STROKE_WIDHT_FACTOR, Tool}, 20 | mode::Mode, 21 | }, 22 | consts::MEDIUM_FONT, 23 | theme::Theme, 24 | }; 25 | 26 | impl Program for Capture { 27 | type State = (); 28 | 29 | fn update( 30 | &self, 31 | _state: &mut Self::State, 32 | event: &iced::Event, 33 | _bounds: Rectangle, 34 | _cursor: iced::advanced::mouse::Cursor, 35 | ) -> Option> { 36 | match event { 37 | iced::Event::Mouse(event) => { 38 | match event { 39 | iced::mouse::Event::CursorMoved { position } => { 40 | Some(Action::publish(Message::MouseMoved(*position))) 41 | } 42 | iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => { 43 | Some(Action::publish(Message::MousePressed)) 44 | } 45 | iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => { 46 | Some(Action::publish(Message::MouseReleased)) 47 | } 48 | _ => None, 49 | } 50 | } 51 | _ => None, 52 | } 53 | } 54 | 55 | fn draw( 56 | &self, 57 | _state: &Self::State, 58 | renderer: &Renderer, 59 | _theme: &Theme, 60 | bounds: Rectangle, 61 | _cursor: iced::advanced::mouse::Cursor, 62 | ) -> Vec> { 63 | let mut frame = Frame::new(renderer, bounds.size()); 64 | 65 | let mut overlay_frame = Frame::new(renderer, bounds.size()); 66 | overlay_frame.fill_rectangle( 67 | Point::ORIGIN, 68 | bounds.size(), 69 | Fill::from(Color::from_rgba(0.0, 0.0, 0.0, 0.5)), 70 | ); 71 | 72 | let shapes_frame = self.cache.draw(renderer, bounds.size(), |frame| { 73 | self.shapes 74 | .iter() 75 | .for_each(|shape| draw_shape(frame, shape, false)); 76 | }); 77 | 78 | match &self.mode { 79 | Mode::Draw { element: shape, .. } => { 80 | if self.mode.allows_drawing() { 81 | draw_shape(&mut frame, shape, true); 82 | } 83 | } 84 | Mode::Crop { 85 | top_left, 86 | bottom_right, 87 | size, 88 | .. 89 | } => { 90 | let overlay = Fill::from(Color::from_rgba(0.0, 0.0, 0.0, 0.5)); 91 | 92 | let selection = Path::rectangle(top_left.to_owned(), size.to_owned()); 93 | 94 | let stroke = Stroke { 95 | style: Style::Solid(Color::from_rgba8(255, 255, 255, 0.2)), 96 | width: 1.0, 97 | ..Default::default() 98 | }; 99 | 100 | frame.fill_rectangle( 101 | Point::new(0.0, 0.0), 102 | Size { 103 | height: top_left.y, 104 | width: bounds.width, 105 | }, 106 | overlay, 107 | ); 108 | frame.fill_rectangle( 109 | Point::new(0.0, bottom_right.y), 110 | Size { 111 | height: bounds.height - bottom_right.y, 112 | width: bounds.width, 113 | }, 114 | overlay, 115 | ); 116 | frame.fill_rectangle( 117 | Point::new(0.0, top_left.y), 118 | Size { 119 | height: bottom_right.y - top_left.y, 120 | width: top_left.x, 121 | }, 122 | overlay, 123 | ); 124 | frame.fill_rectangle( 125 | Point::new(bottom_right.x, top_left.y), 126 | Size { 127 | height: bottom_right.y - top_left.y, 128 | width: bounds.width - bottom_right.x, 129 | }, 130 | overlay, 131 | ); 132 | 133 | frame.stroke(&selection, stroke); 134 | 135 | let (width, height) = (size.width, size.height); 136 | 137 | let segment_len = |dim| if dim > 80.0 { 20.0 } else { dim / 4.0 }; 138 | let horizontal_segment_len = segment_len(width); 139 | let vertical_segment_len = segment_len(height); 140 | 141 | let dashed_stroke = Stroke { 142 | style: Style::Solid(Color::WHITE), 143 | width: 4.0, 144 | line_cap: LineCap::Square, 145 | line_dash: LineDash { 146 | segments: &[ 147 | horizontal_segment_len, 148 | width - (2.0 * horizontal_segment_len), 149 | horizontal_segment_len, 150 | 0.0, 151 | vertical_segment_len, 152 | height - (2.0 * vertical_segment_len), 153 | vertical_segment_len, 154 | 0.0, 155 | ], 156 | offset: 0, 157 | }, 158 | ..Default::default() 159 | }; 160 | 161 | frame.stroke(&selection, dashed_stroke); 162 | } 163 | } 164 | 165 | vec![ 166 | overlay_frame.into_geometry(), 167 | shapes_frame, 168 | frame.into_geometry(), 169 | ] 170 | } 171 | 172 | fn mouse_interaction( 173 | &self, 174 | _state: &Self::State, 175 | bounds: Rectangle, 176 | cursor: iced::advanced::mouse::Cursor, 177 | ) -> iced::mouse::Interaction { 178 | if cursor.is_over(bounds) { 179 | if let Mode::Draw { element: shape, .. } = &self.mode { 180 | if shape.tool.is_text_tool() { 181 | return iced::mouse::Interaction::Text; 182 | } 183 | } 184 | return iced::mouse::Interaction::Crosshair; 185 | } 186 | iced::mouse::Interaction::default() 187 | } 188 | } 189 | 190 | fn draw_shape(frame: &mut Frame, shape: &DrawElement, guide: bool) { 191 | let tool = shape.tool.clone(); 192 | let color = shape.color.into(); 193 | let stroke = Stroke::default() 194 | .with_width(shape.size.mul(STROKE_WIDHT_FACTOR) as f32) 195 | .with_color(color); 196 | match tool { 197 | Tool::Rectangle { 198 | top_left, 199 | bottom_right: _, 200 | size, 201 | filled, 202 | opaque, 203 | .. 204 | } => { 205 | let path = Path::rectangle(top_left, size); 206 | if filled { 207 | if opaque { 208 | frame.fill(&path, color); 209 | } else { 210 | frame.fill(&path, shape.color.into_translucent_color()); 211 | } 212 | } else { 213 | frame.stroke(&path, stroke); 214 | } 215 | } 216 | Tool::Ellipse { 217 | center, 218 | radii, 219 | filled, 220 | .. 221 | } => { 222 | let arc = Elliptical { 223 | center, 224 | radii, 225 | rotation: Radians(0.0), 226 | start_angle: Radians(0.0), 227 | end_angle: Radians(360.0), 228 | }; 229 | let mut builder = Builder::new(); 230 | builder.ellipse(arc); 231 | let path = builder.build(); 232 | if filled { 233 | frame.fill(&path, color); 234 | } else { 235 | frame.stroke(&path, stroke); 236 | }; 237 | } 238 | Tool::FreeHand { points } => { 239 | let mut builder = Builder::new(); 240 | 241 | builder.move_to(points[0]); 242 | points 243 | .iter() 244 | .skip(1) 245 | .for_each(|point| builder.line_to(*point)); 246 | let path = builder.build(); 247 | 248 | frame.stroke(&path, stroke); 249 | } 250 | Tool::Line { start, end } => { 251 | let path = Path::line(start, end); 252 | frame.stroke(&path, stroke); 253 | } 254 | Tool::Arrow { 255 | start, 256 | end, 257 | right, 258 | left, 259 | } => { 260 | let mut builder = Builder::new(); 261 | builder.move_to(start); 262 | builder.line_to(end); 263 | builder.move_to(right); 264 | builder.line_to(end); 265 | builder.line_to(left); 266 | let path = builder.build(); 267 | frame.stroke(&path, stroke); 268 | } 269 | Tool::Text { 270 | anchor_point: mid_point, 271 | text, 272 | } => { 273 | let font_size = shape.size.mul(FONT_SIZE_FACTOR); 274 | 275 | let top_left = Point::new(mid_point.x, mid_point.y - (font_size / 2) as f32); 276 | 277 | if guide { 278 | frame.stroke_rectangle( 279 | top_left, 280 | Size::new(frame.width() - mid_point.x, font_size as f32), 281 | Stroke::default().with_color(Color::WHITE), 282 | ); 283 | } 284 | 285 | let text = Text { 286 | content: text, 287 | position: top_left, 288 | size: Pixels(font_size as f32), 289 | color, 290 | font: MEDIUM_FONT, 291 | align_x: Alignment::Left, 292 | align_y: Vertical::Top, 293 | line_height: LineHeight::Relative(1.0), 294 | ..Default::default() 295 | }; 296 | 297 | frame.fill_text(text); 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/capture/crop.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use iced::Point; 4 | 5 | use crate::capture::CapturedWindow; 6 | 7 | #[derive(Debug, Default)] 8 | pub enum CropState { 9 | #[default] 10 | FullScreen, 11 | Window(Rc), 12 | InProgress { 13 | start: Point, 14 | end: Point, 15 | }, 16 | Area, 17 | None, 18 | } 19 | 20 | impl CropState { 21 | pub fn is_idle(&self) -> bool { 22 | !matches!(self, Self::InProgress { .. }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/capture/draw.rs: -------------------------------------------------------------------------------- 1 | use iced::{Point, Size, Vector}; 2 | 3 | use crate::consts::{ 4 | ARROW_ICON, FILLED_ELLIPSE_ICON, FILLED_RECTANGLE_ICON, FREE_HAND_ICON, HIGHLIGHTER_ICON, 5 | HOLLOW_ELLIPSE_ICON, HOLLOW_RECTANGLE_ICON, LINE_ICON, TEXT_ICON, 6 | }; 7 | 8 | pub const STROKE_WIDHT_FACTOR: u32 = 2; 9 | pub const FONT_SIZE_FACTOR: u32 = 12; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct DrawElement { 13 | pub tool: Tool, 14 | pub color: ToolColor, 15 | pub size: u32, 16 | } 17 | 18 | impl Default for DrawElement { 19 | fn default() -> Self { 20 | Self { 21 | tool: Tool::default(), 22 | color: ToolColor::default(), 23 | size: 3, 24 | } 25 | } 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | pub enum Tool { 30 | Rectangle { 31 | top_left: Point, 32 | bottom_right: Point, 33 | size: Size, 34 | filled: bool, 35 | opaque: bool, 36 | }, 37 | Ellipse { 38 | center: Point, 39 | radii: Vector, 40 | filled: bool, 41 | }, 42 | FreeHand { 43 | points: Vec, 44 | }, 45 | Line { 46 | start: Point, 47 | end: Point, 48 | }, 49 | Arrow { 50 | start: Point, 51 | end: Point, 52 | right: Point, 53 | left: Point, 54 | }, 55 | Text { 56 | anchor_point: Point, 57 | text: String, 58 | }, 59 | } 60 | 61 | impl Default for Tool { 62 | fn default() -> Self { 63 | Self::Rectangle { 64 | top_left: Point::default(), 65 | bottom_right: Point::default(), 66 | size: Size::default(), 67 | filled: true, 68 | opaque: true, 69 | } 70 | } 71 | } 72 | 73 | impl PartialEq for Tool { 74 | fn eq(&self, other: &Self) -> bool { 75 | match (self, other) { 76 | ( 77 | Self::Rectangle { 78 | filled: l_filled, 79 | opaque: l_opaque, 80 | .. 81 | }, 82 | Self::Rectangle { 83 | filled: r_filled, 84 | opaque: r_opaque, 85 | .. 86 | }, 87 | ) => l_filled == r_filled && l_opaque == r_opaque, 88 | ( 89 | Self::Ellipse { 90 | filled: l_filled, .. 91 | }, 92 | Self::Ellipse { 93 | filled: r_filled, .. 94 | }, 95 | ) => l_filled == r_filled, 96 | (Self::FreeHand { .. }, Self::FreeHand { .. }) => true, 97 | (Self::Line { .. }, Self::Line { .. }) => true, 98 | (Self::Arrow { .. }, Self::Arrow { .. }) => true, 99 | (Self::Text { .. }, Self::Text { .. }) => true, 100 | _ => false, 101 | } 102 | } 103 | } 104 | 105 | impl Tool { 106 | pub const ALL: [Tool; 9] = [ 107 | Self::Rectangle { 108 | top_left: Point::ORIGIN, 109 | bottom_right: Point::ORIGIN, 110 | size: Size::ZERO, 111 | filled: true, 112 | opaque: true, 113 | }, 114 | Self::Rectangle { 115 | top_left: Point::ORIGIN, 116 | bottom_right: Point::ORIGIN, 117 | size: Size::ZERO, 118 | filled: false, 119 | opaque: true, 120 | }, 121 | Self::Ellipse { 122 | center: Point::ORIGIN, 123 | radii: Vector::ZERO, 124 | filled: true, 125 | }, 126 | Self::Ellipse { 127 | center: Point::ORIGIN, 128 | radii: Vector::ZERO, 129 | filled: false, 130 | }, 131 | Self::FreeHand { points: Vec::new() }, 132 | Self::Line { 133 | start: Point::ORIGIN, 134 | end: Point::ORIGIN, 135 | }, 136 | Self::Arrow { 137 | start: Point::ORIGIN, 138 | end: Point::ORIGIN, 139 | right: Point::ORIGIN, 140 | left: Point::ORIGIN, 141 | }, 142 | Self::Rectangle { 143 | top_left: Point::ORIGIN, 144 | bottom_right: Point::ORIGIN, 145 | size: Size::ZERO, 146 | filled: true, 147 | opaque: false, 148 | }, 149 | Self::Text { 150 | anchor_point: Point::ORIGIN, 151 | text: String::new(), 152 | }, 153 | ]; 154 | 155 | pub fn icon(&self) -> String { 156 | match self { 157 | Tool::Rectangle { 158 | filled: true, 159 | opaque: true, 160 | .. 161 | } => FILLED_RECTANGLE_ICON, 162 | Tool::Rectangle { 163 | filled: false, 164 | opaque: true, 165 | .. 166 | } => HOLLOW_RECTANGLE_ICON, 167 | Tool::Rectangle { 168 | filled: true, 169 | opaque: false, 170 | .. 171 | } => HIGHLIGHTER_ICON, 172 | Tool::Ellipse { filled: true, .. } => FILLED_ELLIPSE_ICON, 173 | Tool::Ellipse { filled: false, .. } => HOLLOW_ELLIPSE_ICON, 174 | Tool::FreeHand { .. } => FREE_HAND_ICON, 175 | Tool::Line { .. } => LINE_ICON, 176 | Tool::Arrow { .. } => ARROW_ICON, 177 | Tool::Text { .. } => TEXT_ICON, 178 | _ => ' ', 179 | } 180 | .to_string() 181 | } 182 | 183 | pub fn xml_tag(&self) -> String { 184 | match self { 185 | Tool::Rectangle { .. } => "rect", 186 | Tool::Ellipse { .. } => "ellipse", 187 | Tool::FreeHand { .. } => "polyline", 188 | Tool::Line { .. } | Tool::Arrow { .. } => "line", 189 | Tool::Text { .. } => "text", 190 | } 191 | .to_string() 192 | } 193 | 194 | pub fn reset(&mut self) { 195 | if let Some(tool) = Self::ALL.into_iter().find(|tool| tool == self) { 196 | *self = tool 197 | }; 198 | } 199 | 200 | pub fn initiate(&mut self, point: Point) { 201 | match self { 202 | Self::Rectangle { 203 | top_left, 204 | bottom_right, 205 | .. 206 | } => { 207 | *top_left = point; 208 | *bottom_right = point; 209 | } 210 | Self::Ellipse { center, .. } => { 211 | *center = point; 212 | } 213 | Self::FreeHand { points } => { 214 | points.push(point); 215 | } 216 | Self::Line { start, end, .. } => { 217 | *start = point; 218 | *end = point; 219 | } 220 | Self::Arrow { start, end, .. } => { 221 | *start = point; 222 | *end = point; 223 | } 224 | Self::Text { 225 | anchor_point: anchor, 226 | .. 227 | } => { 228 | *anchor = point; 229 | } 230 | } 231 | } 232 | 233 | pub fn update(&mut self, initial_pt: Point, final_pt: Point) { 234 | match self { 235 | Self::Rectangle { 236 | top_left, 237 | bottom_right, 238 | size, 239 | .. 240 | } => { 241 | *top_left = Point::new(initial_pt.x.min(final_pt.x), initial_pt.y.min(final_pt.y)); 242 | *bottom_right = 243 | Point::new(initial_pt.x.max(final_pt.x), initial_pt.y.max(final_pt.y)); 244 | *size = Size::new(bottom_right.x - top_left.x, bottom_right.y - top_left.y); 245 | } 246 | Self::Ellipse { center, radii, .. } => { 247 | *center = Point::new( 248 | (initial_pt.x + final_pt.x) / 2.0, 249 | (initial_pt.y + final_pt.y) / 2.0, 250 | ); 251 | *radii = Vector::new( 252 | (final_pt.x - initial_pt.x) / 2.0, 253 | (final_pt.y - initial_pt.y) / 2.0, 254 | ); 255 | } 256 | Self::FreeHand { points } => { 257 | points.push(final_pt); 258 | } 259 | Self::Line { end, .. } => { 260 | *end = final_pt; 261 | } 262 | Self::Arrow { 263 | start, 264 | end, 265 | right, 266 | left, 267 | } => { 268 | *end = final_pt; 269 | let line = final_pt - *start; 270 | let length = final_pt.distance(*start); 271 | let size = if length < 60.0 { length / 2.0 } else { 30.0 }; 272 | let rad = line.y.atan2(line.x); 273 | 274 | *right = Point::new( 275 | final_pt.x - size * (rad - std::f32::consts::PI / 5.0).cos(), 276 | final_pt.y - size * (rad - std::f32::consts::PI / 5.0).sin(), 277 | ); 278 | *left = Point::new( 279 | final_pt.x - size * (rad + std::f32::consts::PI / 5.0).cos(), 280 | final_pt.y - size * (rad + std::f32::consts::PI / 5.0).sin(), 281 | ); 282 | } 283 | _ => {} 284 | } 285 | } 286 | 287 | pub fn scale(&mut self, scale_factor: f32) { 288 | match self { 289 | Tool::Rectangle { 290 | top_left, 291 | bottom_right, 292 | size, 293 | .. 294 | } => { 295 | *top_left = Point::new(top_left.x * scale_factor, top_left.y * scale_factor); 296 | *bottom_right = 297 | Point::new(bottom_right.x * scale_factor, bottom_right.y * scale_factor); 298 | *size = Size::new(size.width * scale_factor, size.height * scale_factor); 299 | } 300 | Tool::Ellipse { center, radii, .. } => { 301 | *center = Point::new(center.x * scale_factor, center.y * scale_factor); 302 | *radii = Vector::new(radii.x * scale_factor, radii.y * scale_factor); 303 | } 304 | Tool::FreeHand { points } => { 305 | *points = points 306 | .iter() 307 | .map(|point| Point::new(point.x * scale_factor, point.y * scale_factor)) 308 | .collect(); 309 | } 310 | Tool::Line { start, end, .. } => { 311 | *start = Point::new(start.x * scale_factor, start.y * scale_factor); 312 | *end = Point::new(end.x * scale_factor, end.y * scale_factor); 313 | } 314 | Tool::Arrow { 315 | start, 316 | end, 317 | right, 318 | left, 319 | .. 320 | } => { 321 | *start = Point::new(start.x * scale_factor, start.y * scale_factor); 322 | *end = Point::new(end.x * scale_factor, end.y * scale_factor); 323 | *right = Point::new(right.x * scale_factor, right.y * scale_factor); 324 | *left = Point::new(left.x * scale_factor, left.y * scale_factor); 325 | } 326 | Tool::Text { 327 | anchor_point: mid_point, 328 | .. 329 | } => { 330 | *mid_point = Point::new(mid_point.x * scale_factor, mid_point.y * scale_factor); 331 | } 332 | }; 333 | } 334 | 335 | pub fn update_text(&mut self, text: String) { 336 | if let Self::Text { text: old_text, .. } = self { 337 | *old_text = text; 338 | } 339 | } 340 | 341 | pub fn is_valid(&self) -> bool { 342 | match self { 343 | Self::Rectangle { size, .. } => size != &Size::ZERO, 344 | Self::Ellipse { radii, .. } => radii != &Vector::ZERO, 345 | Self::FreeHand { points } => points.len() > 1, 346 | Self::Line { start, end } => start != end, 347 | Self::Arrow { start, end, .. } => start != end, 348 | Self::Text { text, .. } => !text.is_empty(), 349 | } 350 | } 351 | 352 | pub fn need_size(&self) -> bool { 353 | match self { 354 | Self::Rectangle { 355 | filled: is_filled, .. 356 | } 357 | | Self::Ellipse { 358 | filled: is_filled, .. 359 | } => !*is_filled, 360 | Self::Line { .. } | Self::FreeHand { .. } | Self::Text { .. } => true, 361 | _ => false, 362 | } 363 | } 364 | 365 | pub fn is_text_tool(&self) -> bool { 366 | matches!(self, Self::Text { .. }) 367 | } 368 | } 369 | 370 | #[derive(Debug, Default, Clone, Copy, PartialEq)] 371 | pub enum ToolColor { 372 | #[default] 373 | Red, 374 | Green, 375 | Blue, 376 | Yellow, 377 | Black, 378 | White, 379 | } 380 | 381 | impl From for iced::Color { 382 | fn from(value: ToolColor) -> Self { 383 | match value { 384 | ToolColor::Red => iced::Color::from_rgb8(255, 0, 0), 385 | ToolColor::Green => iced::Color::from_rgb8(0, 255, 0), 386 | ToolColor::Blue => iced::Color::from_rgb8(0, 0, 255), 387 | ToolColor::Yellow => iced::Color::from_rgb8(255, 255, 0), 388 | ToolColor::Black => iced::Color::from_rgb8(0, 0, 0), 389 | ToolColor::White => iced::Color::from_rgb8(255, 255, 255), 390 | } 391 | } 392 | } 393 | 394 | impl ToolColor { 395 | pub const ALL: [ToolColor; 6] = [ 396 | Self::Red, 397 | Self::Green, 398 | Self::Blue, 399 | Self::Yellow, 400 | Self::Black, 401 | Self::White, 402 | ]; 403 | 404 | pub fn icon(&self) -> String { 405 | FILLED_RECTANGLE_ICON.to_string() 406 | } 407 | 408 | pub fn as_hex(&self) -> String { 409 | match self { 410 | ToolColor::Red => "#FF0000", 411 | ToolColor::Green => "#00FF00", 412 | ToolColor::Blue => "#0000FF", 413 | ToolColor::Yellow => "#FFFF00", 414 | ToolColor::Black => "#000000", 415 | ToolColor::White => "#FFFFFF", 416 | } 417 | .to_string() 418 | } 419 | 420 | pub fn into_translucent_color(self) -> iced::Color { 421 | iced::Color::from(self).scale_alpha(0.3) 422 | } 423 | } 424 | 425 | #[derive(Debug, Default, Clone)] 426 | pub enum DrawState { 427 | #[default] 428 | Idle, 429 | InProgress { 430 | initial_pt: Point, 431 | final_pt: Point, 432 | }, 433 | TextInput, 434 | } 435 | 436 | impl DrawState { 437 | pub fn is_idle(&self) -> bool { 438 | !matches!(self, Self::InProgress { .. }) 439 | } 440 | 441 | pub fn is_waiting_for_input(&self) -> bool { 442 | matches!(self, Self::TextInput) 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/capture/image.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use anyhow::{Context, Error, Result}; 4 | use arboard::Clipboard; 5 | use chrono::Local; 6 | use edit_xml::{Document, ElementBuilder}; 7 | use iced::Point; 8 | use resvg::{tiny_skia, usvg}; 9 | use xcap::image::{ 10 | ImageFormat, RgbaImage, 11 | imageops::{crop_imm, overlay}, 12 | }; 13 | 14 | use crate::{ 15 | capture::{ 16 | Capture, 17 | crop::CropState, 18 | draw::{DrawElement, FONT_SIZE_FACTOR, STROKE_WIDHT_FACTOR, Tool}, 19 | mode::Mode, 20 | }, 21 | config::Config, 22 | consts::{APPNAME, FONT_NAME, MEDIUM_FONT_TTF}, 23 | organize_type::OrgranizeMode, 24 | }; 25 | 26 | impl Capture { 27 | pub fn finalize(mut self, config: &Config) -> Result { 28 | if let Mode::Crop { 29 | top_left, 30 | bottom_right, 31 | state, 32 | .. 33 | } = self.mode 34 | { 35 | let top_left = Point::new( 36 | top_left.x * self.scale_factor, 37 | top_left.y * self.scale_factor, 38 | ); 39 | let bottom_right = Point::new( 40 | bottom_right.x * self.scale_factor, 41 | bottom_right.y * self.scale_factor, 42 | ); 43 | 44 | let (img_width, img_height) = self.image.dimensions(); 45 | let annotation_overlay = 46 | create_annotation_overlay(img_width, img_height, self.shapes, self.scale_factor) 47 | .unwrap_or(RgbaImage::new(0, 0)); 48 | 49 | match state { 50 | CropState::FullScreen => { 51 | overlay(&mut self.image, &annotation_overlay, 0, 0); 52 | } 53 | CropState::Window(window) => { 54 | let mut base_image = RgbaImage::new(img_width, img_height); 55 | 56 | overlay( 57 | &mut base_image, 58 | &window.screenshot, 59 | top_left.x as i64, 60 | top_left.y as i64, 61 | ); 62 | 63 | overlay(&mut base_image, &annotation_overlay, 0, 0); 64 | 65 | self.image = crop_imm( 66 | &base_image, 67 | top_left.x as u32, 68 | top_left.y as u32, 69 | window.width as u32, 70 | window.height as u32, 71 | ) 72 | .to_image(); 73 | } 74 | CropState::Area | CropState::InProgress { .. } => { 75 | let x = top_left.x; 76 | let y = top_left.y; 77 | let size = bottom_right - top_left; 78 | let width = size.x; 79 | let height = size.y; 80 | overlay(&mut self.image, &annotation_overlay, 0, 0); 81 | self.image = 82 | crop_imm(&self.image, x as u32, y as u32, width as u32, height as u32) 83 | .to_image(); 84 | } 85 | CropState::None => { 86 | return Err(Error::msg("Screenshot Cancelled!!")); 87 | } 88 | }; 89 | } 90 | 91 | save_image(self.image, config) 92 | } 93 | } 94 | 95 | pub fn create_annotation_overlay( 96 | width: u32, 97 | height: u32, 98 | shapes: Vec, 99 | scale_factor: f32, 100 | ) -> Option { 101 | let mut pixmap = tiny_skia::Pixmap::new(width, height)?; 102 | let transform = usvg::Transform::identity(); 103 | 104 | let mut xml = Document::new(); 105 | 106 | let svg = ElementBuilder::new("svg") 107 | .attribute("xmlns", "http://www.w3.org/2000/svg") 108 | .attribute("width", width.to_string()) 109 | .attribute("height", height.to_string()) 110 | .push_to_root_node(&mut xml); 111 | 112 | for mut shape in shapes.into_iter() { 113 | let element = ElementBuilder::new(shape.tool.xml_tag()); 114 | let color = shape.color; 115 | let stroke_width = ((shape.size * STROKE_WIDHT_FACTOR) as f32 * scale_factor).to_string(); 116 | 117 | shape.tool.scale(scale_factor); 118 | 119 | match shape.tool { 120 | Tool::Rectangle { 121 | top_left, 122 | size, 123 | filled, 124 | opaque, 125 | .. 126 | } => { 127 | let drawn_shape = element 128 | .attribute("x", top_left.x.to_string()) 129 | .attribute("y", top_left.y.to_string()) 130 | .attribute("width", size.width.to_string()) 131 | .attribute("height", size.height.to_string()); 132 | 133 | if filled { 134 | if opaque { 135 | drawn_shape.attribute("fill", color.as_hex()) 136 | } else { 137 | drawn_shape 138 | .attribute("fill", color.as_hex()) 139 | .attribute("opacity", "0.3") 140 | } 141 | } else { 142 | drawn_shape 143 | .attribute("fill", "none") 144 | .attribute("stroke", color.as_hex()) 145 | .attribute("stroke-width", stroke_width) 146 | } 147 | .push_to(&mut xml, svg); 148 | } 149 | Tool::Ellipse { 150 | center, 151 | radii, 152 | filled, 153 | .. 154 | } => { 155 | let drawn_shape = element 156 | .attribute("cx", center.x.to_string()) 157 | .attribute("cy", center.y.to_string()) 158 | .attribute("rx", radii.x.to_string()) 159 | .attribute("ry", radii.y.to_string()); 160 | 161 | if filled { 162 | drawn_shape.attribute("fill", color.as_hex()) 163 | } else { 164 | drawn_shape 165 | .attribute("fill", "none") 166 | .attribute("stroke", color.as_hex()) 167 | .attribute("stroke-width", stroke_width) 168 | } 169 | .push_to(&mut xml, svg); 170 | } 171 | Tool::FreeHand { points } => { 172 | let points_str = points 173 | .iter() 174 | .map(|point| format!("{},{}", point.x, point.y)) 175 | .collect::>() 176 | .join(" "); 177 | 178 | element 179 | .attribute("points", points_str) 180 | .attribute("fill", "none") 181 | .attribute("stroke", color.as_hex()) 182 | .attribute("stroke-width", stroke_width) 183 | .push_to(&mut xml, svg); 184 | } 185 | Tool::Line { start, end } => { 186 | element 187 | .attribute("x1", start.x.to_string()) 188 | .attribute("y1", start.y.to_string()) 189 | .attribute("x2", end.x.to_string()) 190 | .attribute("y2", end.y.to_string()) 191 | .attribute("fill", "none") 192 | .attribute("stroke", color.as_hex()) 193 | .attribute("stroke-width", stroke_width) 194 | .push_to(&mut xml, svg); 195 | } 196 | Tool::Arrow { 197 | start, 198 | end, 199 | right, 200 | left, 201 | } => { 202 | ElementBuilder::new("polyline") 203 | .attribute( 204 | "points", 205 | format!( 206 | "{},{}, {},{} {},{}", 207 | left.x, left.y, end.x, end.y, right.x, right.y 208 | ) 209 | .to_string(), 210 | ) 211 | .attribute("fill", "none") 212 | .attribute("stroke", color.as_hex()) 213 | .attribute("stroke-width", stroke_width.clone()) 214 | .push_to(&mut xml, svg); 215 | 216 | element 217 | .attribute("x1", start.x.to_string()) 218 | .attribute("y1", start.y.to_string()) 219 | .attribute("x2", end.x.to_string()) 220 | .attribute("y2", end.y.to_string()) 221 | .attribute("fill", "none") 222 | .attribute("stroke", color.as_hex()) 223 | .attribute("stroke-width", stroke_width) 224 | .push_to(&mut xml, svg); 225 | } 226 | Tool::Text { 227 | anchor_point: mid_point, 228 | text, 229 | } => { 230 | let text_size = (shape.size * FONT_SIZE_FACTOR) as f32 * scale_factor; 231 | 232 | let (bottom_right_x, bottom_right_y) = ( 233 | mid_point.x + (text_size / 2.0), 234 | mid_point.y + (text_size / 2.0), 235 | ); 236 | 237 | element 238 | .attribute("x", bottom_right_x.to_string()) 239 | .attribute("y", bottom_right_y.to_string()) 240 | .attribute("font-family", FONT_NAME) 241 | .attribute("font-size", text_size.to_string()) 242 | .attribute("fill", color.as_hex()) 243 | .add_text(text) 244 | .push_to(&mut xml, svg); 245 | } 246 | }; 247 | } 248 | 249 | let mut options = usvg::Options::default(); 250 | options 251 | .fontdb_mut() 252 | .load_font_data(MEDIUM_FONT_TTF.to_vec()); 253 | 254 | let tree = usvg::Tree::from_str( 255 | xml.write_str_with_opts(edit_xml::WriteOptions { 256 | write_decl: false, 257 | ..Default::default() 258 | }) 259 | .expect("XML must be valid") 260 | .as_str(), 261 | &options, 262 | ) 263 | .expect("SVG must be valid"); 264 | 265 | resvg::render(&tree, transform, &mut pixmap.as_mut()); 266 | 267 | RgbaImage::from_vec(width, height, pixmap.take()) 268 | } 269 | 270 | fn save_image(image: RgbaImage, config: &Config) -> Result { 271 | let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S"); 272 | 273 | let file_name = format!("{}_{}.png", APPNAME, timestamp); 274 | 275 | let folder_path = match config.organize_mode { 276 | OrgranizeMode::Flat => config.folder_path.clone(), 277 | OrgranizeMode::ByYear => { 278 | let year = Local::now().format("%Y"); 279 | config.folder_path.join(year.to_string()) 280 | } 281 | OrgranizeMode::ByYearAndMonth => { 282 | let year = Local::now().format("%Y"); 283 | let month = Local::now().format("%m"); 284 | config 285 | .folder_path 286 | .join(year.to_string()) 287 | .join(month.to_string()) 288 | } 289 | }; 290 | 291 | if !folder_path.exists() { 292 | fs::create_dir_all(&folder_path) 293 | .with_context(|| format!("Failed to create folder: {}", folder_path.display()))?; 294 | } 295 | 296 | let image_path = folder_path.join(file_name); 297 | 298 | Clipboard::new() 299 | .context("Failed to initialize clipboard")? 300 | .set_image(arboard::ImageData { 301 | width: image.width() as usize, 302 | height: image.height() as usize, 303 | bytes: std::borrow::Cow::Borrowed(image.as_raw()), 304 | }) 305 | .context("Failed to copy image to clipboard")?; 306 | 307 | image 308 | .save_with_format(&image_path, ImageFormat::Png) 309 | .context("Failed to save image!!")?; 310 | 311 | Ok(image_path) 312 | } 313 | -------------------------------------------------------------------------------- /src/capture/init.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use anyhow::{Context, Result}; 4 | use iced::{Point, widget::canvas::Cache}; 5 | use xcap::Monitor; 6 | 7 | use crate::capture::{Capture, CapturedWindow, mode::Mode}; 8 | 9 | impl Capture { 10 | pub fn new(monitor: Monitor) -> Result { 11 | let scale_factor = monitor 12 | .scale_factor() 13 | .with_context(|| "Unable to get scale factor")?; 14 | 15 | let windows = xcap::Window::all() 16 | .map(|windows| { 17 | windows 18 | .into_iter() 19 | .filter_map(|window| { 20 | if window.current_monitor().ok()?.id().ok()? == monitor.id().ok()? 21 | && !window.is_minimized().ok()? 22 | && window.width().ok()? != 0 23 | && window.height().ok()? != 0 24 | && !window.title().ok()?.is_empty() 25 | && !window.app_name().ok()?.is_empty() 26 | { 27 | Some(Rc::new(CapturedWindow { 28 | name: window.title().ok()?.to_string(), 29 | x: window.x().ok()? as f32, 30 | y: window.y().ok()? as f32, 31 | width: window.width().ok()? as f32, 32 | height: window.height().ok()? as f32, 33 | screenshot: window.capture_image().ok()?, 34 | })) 35 | } else { 36 | None 37 | } 38 | }) 39 | .collect() 40 | }) 41 | .unwrap_or_default(); 42 | 43 | let screenshot = monitor 44 | .capture_image() 45 | .with_context(|| "Unable to capture Monitor")?; 46 | 47 | Ok(Capture { 48 | toolbar_at_top: true, 49 | scale_factor, 50 | image: screenshot, 51 | windows, 52 | cursor_position: Point::default(), 53 | mode: Mode::default(), 54 | shapes: Vec::new(), 55 | cache: Cache::new(), 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/capture/mod.rs: -------------------------------------------------------------------------------- 1 | mod canvas; 2 | mod image; 3 | mod init; 4 | mod update; 5 | mod view; 6 | 7 | mod crop; 8 | mod draw; 9 | mod mode; 10 | 11 | use std::rc::Rc; 12 | 13 | use draw::{DrawElement, Tool, ToolColor}; 14 | use iced::{Point, widget::canvas::Cache}; 15 | use mode::Mode; 16 | use xcap::image::RgbaImage; 17 | 18 | pub struct Capture { 19 | toolbar_at_top: bool, 20 | scale_factor: f32, 21 | image: RgbaImage, 22 | windows: Vec>, 23 | cursor_position: Point, 24 | mode: Mode, 25 | shapes: Vec, 26 | cache: Cache, 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub enum Message { 31 | MoveToolBar, 32 | Undo, 33 | Done, 34 | Cancel, 35 | ChangeTool(Tool), 36 | ChangeSize(u32), 37 | ChangeColor(ToolColor), 38 | UpdateText(String), 39 | MousePressed, 40 | MouseMoved(Point), 41 | MouseReleased, 42 | } 43 | 44 | pub enum Request { 45 | Close, 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct CapturedWindow { 50 | pub name: String, 51 | pub x: f32, 52 | pub y: f32, 53 | pub width: f32, 54 | pub height: f32, 55 | pub screenshot: RgbaImage, 56 | } 57 | -------------------------------------------------------------------------------- /src/capture/mode.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use iced::{Point, Size}; 4 | 5 | use crate::capture::{ 6 | CapturedWindow, 7 | crop::CropState, 8 | draw::{DrawElement, DrawState}, 9 | }; 10 | 11 | #[derive(Debug)] 12 | pub enum Mode { 13 | Draw { 14 | element: DrawElement, 15 | state: DrawState, 16 | }, 17 | Crop { 18 | top_left: Point, 19 | bottom_right: Point, 20 | size: Size, 21 | state: CropState, 22 | }, 23 | } 24 | 25 | impl Default for Mode { 26 | fn default() -> Self { 27 | Self::Crop { 28 | top_left: Point::default(), 29 | bottom_right: Point::default(), 30 | size: Size::default(), 31 | state: CropState::default(), 32 | } 33 | } 34 | } 35 | 36 | impl Mode { 37 | pub fn is_draw_mode(&self) -> bool { 38 | matches!(self, Self::Draw { .. }) 39 | } 40 | 41 | pub fn get_window_below_cursor( 42 | &mut self, 43 | windows: &[Rc], 44 | cursor_position: &Point, 45 | scale_factor: f32, 46 | (x, y): (u32, u32), 47 | ) { 48 | if let Mode::Crop { 49 | top_left, 50 | bottom_right, 51 | size, 52 | state: status, 53 | } = self 54 | { 55 | let _ = windows 56 | .iter() 57 | .find_map(|window| { 58 | let window_top_left = Point::new( 59 | window.x.max(0.0) / scale_factor, 60 | window.y.max(0.0) / scale_factor, 61 | ); 62 | 63 | let window_bottom_right = Point::new( 64 | (window.x + window.width) / scale_factor, 65 | (window.y + window.height) / scale_factor, 66 | ); 67 | 68 | if (window_top_left.x..=window_bottom_right.x).contains(&cursor_position.x) 69 | && (window_top_left.y..=window_bottom_right.y).contains(&cursor_position.y) 70 | { 71 | *top_left = window_top_left; 72 | *bottom_right = window_bottom_right; 73 | *size = (*bottom_right - *top_left).into(); 74 | *status = CropState::Window(window.clone()); 75 | Some(()) 76 | } else { 77 | None 78 | } 79 | }) 80 | .or_else(|| { 81 | *top_left = Point::ORIGIN; 82 | *bottom_right = Point::new(x as f32 / scale_factor, y as f32 / scale_factor); 83 | *size = (*bottom_right - *top_left).into(); 84 | *status = CropState::FullScreen; 85 | Some(()) 86 | }); 87 | } 88 | } 89 | 90 | pub fn allows_drawing(&self) -> bool { 91 | if let Mode::Draw { 92 | element: shape, 93 | state: status, 94 | } = self 95 | { 96 | shape.tool.is_valid() || (shape.tool.is_text_tool() && status.is_waiting_for_input()) 97 | } else { 98 | false 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/capture/update.rs: -------------------------------------------------------------------------------- 1 | use iced::{Point, Size, widget::text_input::focus}; 2 | 3 | use crate::{ 4 | action::Action, 5 | capture::{ 6 | Capture, Message, Request, 7 | crop::CropState, 8 | draw::{DrawElement, DrawState}, 9 | mode::Mode, 10 | }, 11 | }; 12 | 13 | impl Capture { 14 | pub fn update(&mut self, message: Message) -> Action { 15 | match message { 16 | Message::MoveToolBar => { 17 | self.toolbar_at_top = !self.toolbar_at_top; 18 | } 19 | Message::Undo => { 20 | if self.mode.is_draw_mode() { 21 | self.shapes.pop(); 22 | self.cache.clear(); 23 | } 24 | } 25 | Message::Done => { 26 | match &self.mode { 27 | Mode::Draw { element: shape, .. } => { 28 | if shape.tool.is_text_tool() { 29 | self.push_shape(); 30 | } 31 | self.mode = Mode::default(); 32 | self.mode.get_window_below_cursor( 33 | &self.windows, 34 | &self.cursor_position, 35 | self.scale_factor, 36 | self.image.dimensions(), 37 | ); 38 | } 39 | Mode::Crop { .. } => { 40 | return Action::requests([Request::Close]); 41 | } 42 | } 43 | } 44 | Message::Cancel => { 45 | match &mut self.mode { 46 | Mode::Draw { .. } => { 47 | self.shapes.clear(); 48 | self.cache.clear(); 49 | self.mode = Mode::default(); 50 | self.mode.get_window_below_cursor( 51 | &self.windows, 52 | &self.cursor_position, 53 | self.scale_factor, 54 | self.image.dimensions(), 55 | ); 56 | } 57 | Mode::Crop { state: status, .. } => { 58 | *status = CropState::None; 59 | return Action::requests([Request::Close]); 60 | } 61 | } 62 | } 63 | Message::ChangeTool(tool) => { 64 | self.push_shape(); 65 | if let Mode::Draw { element: shape, .. } = &mut self.mode { 66 | shape.tool = tool; 67 | } else { 68 | self.mode = Mode::Draw { 69 | element: DrawElement { 70 | tool, 71 | ..Default::default() 72 | }, 73 | state: DrawState::Idle, 74 | } 75 | } 76 | } 77 | Message::ChangeSize(stroke_width) => { 78 | self.push_shape(); 79 | if let Mode::Draw { 80 | element: shape, 81 | state: status, 82 | } = &mut self.mode 83 | { 84 | shape.size = stroke_width; 85 | *status = DrawState::Idle; 86 | } 87 | } 88 | Message::ChangeColor(color) => { 89 | self.push_shape(); 90 | if let Mode::Draw { 91 | element: shape, 92 | state: status, 93 | } = &mut self.mode 94 | { 95 | shape.color = color; 96 | *status = DrawState::Idle; 97 | } 98 | } 99 | Message::UpdateText(text) => { 100 | if let Mode::Draw { element: shape, .. } = &mut self.mode { 101 | shape.tool.update_text(text); 102 | } 103 | } 104 | Message::MousePressed => { 105 | match &mut self.mode { 106 | Mode::Crop { 107 | top_left, 108 | bottom_right, 109 | size, 110 | state: status, 111 | } => { 112 | *top_left = self.cursor_position; 113 | *bottom_right = self.cursor_position; 114 | *size = Size::ZERO; 115 | *status = CropState::InProgress { 116 | start: self.cursor_position, 117 | end: self.cursor_position, 118 | }; 119 | } 120 | Mode::Draw { 121 | element: shape, 122 | state: status, 123 | } => { 124 | if shape.tool.is_text_tool() && shape.tool.is_valid() { 125 | self.shapes.push(shape.clone()); 126 | self.cache.clear(); 127 | shape.tool.reset(); 128 | } 129 | 130 | shape.tool.initiate(self.cursor_position); 131 | *status = DrawState::InProgress { 132 | initial_pt: self.cursor_position, 133 | final_pt: self.cursor_position, 134 | }; 135 | } 136 | } 137 | } 138 | Message::MouseMoved(position) => { 139 | self.cursor_position = position; 140 | match &mut self.mode { 141 | Mode::Crop { 142 | top_left, 143 | bottom_right, 144 | size, 145 | state: status, 146 | } => { 147 | match status { 148 | CropState::FullScreen | CropState::Window(_) => { 149 | self.mode.get_window_below_cursor( 150 | &self.windows, 151 | &self.cursor_position, 152 | self.scale_factor, 153 | self.image.dimensions(), 154 | ); 155 | } 156 | CropState::InProgress { start, end } => { 157 | *end = position; 158 | *top_left = Point::new(start.x.min(end.x), start.y.min(end.y)); 159 | *bottom_right = Point::new(start.x.max(end.x), start.y.max(end.y)); 160 | *size = Size::new( 161 | bottom_right.x - top_left.x, 162 | bottom_right.y - top_left.y, 163 | ); 164 | } 165 | _ => {} 166 | } 167 | } 168 | Mode::Draw { 169 | element: shape, 170 | state: status, 171 | } => { 172 | if shape.tool.is_text_tool() { 173 | return Action::none(); 174 | }; 175 | if let DrawState::InProgress { 176 | initial_pt, 177 | final_pt, 178 | } = status 179 | { 180 | *final_pt = position; 181 | shape.tool.update(*initial_pt, *final_pt); 182 | } 183 | } 184 | } 185 | } 186 | Message::MouseReleased => { 187 | match &mut self.mode { 188 | Mode::Crop { state: status, .. } => { 189 | if let CropState::InProgress { start, end } = status { 190 | if start != end { 191 | *status = CropState::Area; 192 | } else { 193 | self.mode.get_window_below_cursor( 194 | &self.windows, 195 | &self.cursor_position, 196 | self.scale_factor, 197 | self.image.dimensions(), 198 | ); 199 | } 200 | } 201 | } 202 | Mode::Draw { 203 | element: shape, 204 | state: status, 205 | } => { 206 | if shape.tool.is_text_tool() { 207 | *status = DrawState::TextInput; 208 | return focus("text_input").into(); 209 | } else { 210 | if shape.tool.is_valid() { 211 | self.shapes.push(shape.clone()); 212 | self.cache.clear(); 213 | shape.tool.reset(); 214 | } 215 | *status = DrawState::Idle; 216 | } 217 | } 218 | } 219 | } 220 | } 221 | Action::none() 222 | } 223 | 224 | fn push_shape(&mut self) { 225 | if let Mode::Draw { 226 | element: shape, 227 | state: status, 228 | } = &mut self.mode 229 | { 230 | if shape.tool.is_valid() { 231 | self.shapes.push(shape.clone()); 232 | } 233 | shape.tool.reset(); 234 | *status = DrawState::Idle; 235 | } 236 | self.cache.clear(); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/capture/view.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Alignment::{self}, 3 | Length, 4 | widget::{ 5 | Button, Canvas, Column, Container, Image, Row, Slider, Stack, Text, TextInput, Tooltip, 6 | image::Handle, opaque, tooltip::Position, 7 | }, 8 | }; 9 | 10 | use crate::{ 11 | capture::{ 12 | Capture, Message, 13 | crop::CropState, 14 | draw::{Tool, ToolColor}, 15 | mode::Mode, 16 | }, 17 | consts::{ICON_FONT, MEDIUM_FONT, MOVE_ICON}, 18 | theme::{Element, button::ButtonClass, container::ContainerClass, text::TextClass}, 19 | }; 20 | 21 | const PADDING: f32 = 10.0; 22 | const SPACING: f32 = 10.0; 23 | const TEXT_SIZE: f32 = 18.0; 24 | const BUTTON_SIZE: f32 = 30.0; 25 | const CONTAINER_WIDTH: f32 = 420.0; 26 | 27 | impl Capture { 28 | pub fn view(&self) -> Element { 29 | let mut stack = Stack::new().height(Length::Fill).width(Length::Fill); 30 | 31 | stack = stack.push( 32 | Image::new(Handle::from_rgba( 33 | self.image.width(), 34 | self.image.height(), 35 | self.image.clone().into_raw(), 36 | )) 37 | .height(Length::Fill) 38 | .width(Length::Fill), 39 | ); 40 | 41 | let canvas_with_tooltip = |description| { 42 | Tooltip::new( 43 | Canvas::new(self).width(Length::Fill).height(Length::Fill), 44 | Container::new( 45 | Container::new(Text::new(description).size(TEXT_SIZE).center()) 46 | .padding(PADDING), 47 | ) 48 | .padding(PADDING) 49 | .class(ContainerClass::Transparent), 50 | Position::FollowCursor, 51 | ) 52 | .class(ContainerClass::Transparent) 53 | }; 54 | 55 | match &self.mode { 56 | Mode::Crop { 57 | size, 58 | state: status, 59 | .. 60 | } => { 61 | let description = match status { 62 | CropState::FullScreen => String::from("Fullscreen"), 63 | CropState::Window(window) => window.name.clone(), 64 | CropState::Area | CropState::InProgress { .. } => { 65 | format!("{} x {}", size.width as u32, size.height as u32) 66 | } 67 | CropState::None => "Exiting".to_string(), 68 | }; 69 | 70 | stack = stack.push(canvas_with_tooltip(description)); 71 | 72 | if status.is_idle() { 73 | stack = stack.push( 74 | self.toolbar( 75 | Row::new() 76 | .push( 77 | Row::from_iter(Tool::ALL.into_iter().map(|tool| { 78 | toolbar_icon( 79 | tool.icon(), 80 | TextClass::Default, 81 | false, 82 | Message::ChangeTool(tool), 83 | ) 84 | })) 85 | .spacing(SPACING), 86 | ) 87 | .push(icon_button( 88 | MOVE_ICON.to_string(), 89 | TextClass::Default, 90 | Message::MoveToolBar, 91 | ButtonClass::Selected, 92 | )) 93 | .spacing(SPACING), 94 | ), 95 | ); 96 | }; 97 | } 98 | Mode::Draw { 99 | element: shape, 100 | state: status, 101 | } => { 102 | stack = stack.push(canvas_with_tooltip(format!( 103 | "{} x {}", 104 | self.cursor_position.x as u32, self.cursor_position.y as u32 105 | ))); 106 | 107 | if status.is_idle() || shape.tool.is_text_tool() { 108 | let mut toolbar_column = Column::new() 109 | .push( 110 | Row::new() 111 | .push( 112 | Row::from_iter(Tool::ALL.into_iter().map(|tool| { 113 | toolbar_icon( 114 | tool.icon(), 115 | TextClass::Default, 116 | shape.tool == tool, 117 | Message::ChangeTool(tool), 118 | ) 119 | })) 120 | .spacing(SPACING), 121 | ) 122 | .push(icon_button( 123 | MOVE_ICON.to_string(), 124 | TextClass::Default, 125 | Message::MoveToolBar, 126 | ButtonClass::Selected, 127 | )) 128 | .spacing(SPACING), 129 | ) 130 | .push( 131 | Row::new() 132 | .push( 133 | Slider::new(1..=5, shape.size, Message::ChangeSize) 134 | .height(BUTTON_SIZE) 135 | .width(Length::Fill), 136 | ) 137 | .push( 138 | Row::from_iter(ToolColor::ALL.into_iter().map(|color| { 139 | toolbar_icon( 140 | color.icon(), 141 | TextClass::Custom(color.into()), 142 | shape.color == color, 143 | Message::ChangeColor(color), 144 | ) 145 | })) 146 | .spacing(SPACING), 147 | ) 148 | .spacing(SPACING), 149 | ) 150 | .align_x(Alignment::Center) 151 | .spacing(SPACING); 152 | 153 | if status.is_waiting_for_input() { 154 | if let Tool::Text { text, .. } = &shape.tool { 155 | toolbar_column = toolbar_column.push( 156 | TextInput::new("Enter Text", text) 157 | .width(Length::Fill) 158 | .font(MEDIUM_FONT) 159 | .size(TEXT_SIZE) 160 | .on_input(Message::UpdateText) 161 | .id("text_input"), 162 | ); 163 | } 164 | } 165 | 166 | stack = stack.push(self.toolbar(toolbar_column)) 167 | }; 168 | } 169 | } 170 | 171 | stack.into() 172 | } 173 | 174 | fn toolbar<'a>(&self, content: impl Into>) -> Element<'a, Message> { 175 | Container::new(opaque( 176 | Container::new(content) 177 | .center_x(CONTAINER_WIDTH) 178 | .padding(PADDING), 179 | )) 180 | .center_x(Length::Fill) 181 | .height(Length::Fill) 182 | .align_y(match self.toolbar_at_top { 183 | true => Alignment::Start, 184 | false => Alignment::End, 185 | }) 186 | .padding(PADDING) 187 | .class(ContainerClass::Transparent) 188 | .into() 189 | } 190 | } 191 | 192 | fn toolbar_icon<'a>( 193 | icon: String, 194 | text_class: TextClass, 195 | selected: bool, 196 | message: Message, 197 | ) -> Element<'a, Message> { 198 | let button_class = match selected { 199 | true => ButtonClass::Selected, 200 | false => ButtonClass::Default, 201 | }; 202 | 203 | icon_button(icon, text_class, message, button_class) 204 | } 205 | 206 | fn icon_button<'a>( 207 | text: impl ToString, 208 | text_class: TextClass, 209 | message: Message, 210 | button_class: ButtonClass, 211 | ) -> Element<'a, Message> { 212 | Button::new( 213 | Text::new(text.to_string()) 214 | .font(ICON_FONT) 215 | .size(TEXT_SIZE) 216 | .center() 217 | .class(text_class), 218 | ) 219 | .on_press(message) 220 | .height(BUTTON_SIZE) 221 | .width(BUTTON_SIZE) 222 | .class(button_class) 223 | .into() 224 | } 225 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{DirBuilder, File}, 3 | io::{Read, Write}, 4 | path::PathBuf, 5 | }; 6 | 7 | use anyhow::{Context, Result}; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::{organize_type::OrgranizeMode, theme::Theme}; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct Config { 14 | pub folder_path: PathBuf, 15 | pub organize_mode: OrgranizeMode, 16 | pub show_notification: bool, 17 | pub theme: Theme, 18 | } 19 | 20 | impl Default for Config { 21 | fn default() -> Self { 22 | Self { 23 | folder_path: Self::default_screenshot_dir(), 24 | organize_mode: Default::default(), 25 | show_notification: true, 26 | theme: Default::default(), 27 | } 28 | } 29 | } 30 | 31 | impl Config { 32 | /// Loads config from file or creates a new one. 33 | pub fn load() -> Result<(Self, bool)> { 34 | let (mut config_file, is_newly_created) = Self::get_config_file()?; 35 | let mut file_content = String::new(); 36 | 37 | let _ = config_file.read_to_string(&mut file_content); 38 | let config = toml::from_str::(&file_content).unwrap_or_else(|_| { 39 | let default_config = Self::default(); 40 | default_config 41 | .save() 42 | .expect("Failed to save default config"); 43 | default_config 44 | }); 45 | 46 | Ok((config, is_newly_created)) 47 | } 48 | 49 | /// Saves the config to file. 50 | pub fn save(&self) -> Result<()> { 51 | let (mut file, _) = Self::get_config_file()?; 52 | file.set_len(0)?; // Clear existing content 53 | let contents = toml::to_string(self)?; 54 | file.write_all(contents.as_bytes())?; 55 | Ok(()) 56 | } 57 | 58 | /// Gets the config file path and ensures the folder exists. 59 | fn get_config_file() -> Result<(File, bool)> { 60 | let config_folder = dirs::config_dir() 61 | .with_context(|| "Failed to get config folder path")? 62 | .join("Capter"); 63 | let config_path = config_folder.join("capter.toml"); 64 | 65 | if !config_folder.exists() { 66 | DirBuilder::new().recursive(true).create(&config_folder)?; 67 | } 68 | 69 | let is_newly_created = !config_path.exists(); 70 | let file = File::options() 71 | .create(true) 72 | .truncate(false) 73 | .read(true) 74 | .write(true) 75 | .open(&config_path)?; 76 | 77 | Ok((file, is_newly_created)) 78 | } 79 | 80 | /// Returns a truncated version of the screenshot folder path for UI. 81 | pub fn truncate_folder_path(&self) -> String { 82 | let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("")); 83 | 84 | let mut display_path = self.folder_path.clone(); 85 | if let Ok(stripped) = self.folder_path.strip_prefix(&home_dir) { 86 | display_path = PathBuf::from("~").join(stripped); 87 | } 88 | 89 | let display_str = display_path.to_string_lossy(); 90 | if display_str.len() > 20 { 91 | format!("...{}", &display_str[display_str.len() - 17..]) 92 | } else { 93 | display_str.into_owned() 94 | } 95 | } 96 | 97 | /// Provides the default screenshots folder. 98 | fn default_screenshot_dir() -> PathBuf { 99 | let screenshot_dir = dirs::picture_dir() 100 | .unwrap_or_else(|| PathBuf::from("Pictures")) 101 | .join("Capter"); 102 | 103 | DirBuilder::new() 104 | .recursive(true) 105 | .create(&screenshot_dir) 106 | .expect("Failed to create screenshots folder"); 107 | 108 | screenshot_dir 109 | } 110 | 111 | pub fn open_screenshot_folder(&self) { 112 | opener::open(&self.folder_path).expect("Failed to open screenshot folder"); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/consts.rs: -------------------------------------------------------------------------------- 1 | pub const APPNAME: &str = "Capter"; 2 | 3 | #[cfg(target_os = "windows")] 4 | pub const APPID: &str = "io.github.decipher.capter"; 5 | 6 | pub const FONT_NAME: &str = "Space Grotesk"; 7 | 8 | pub const MEDIUM_FONT_TTF: &[u8] = include_bytes!("../assets/fonts/SpaceGrotesk-Medium.ttf"); 9 | 10 | pub const BOLD_FONT_TTF: &[u8] = include_bytes!("../assets/fonts/SpaceGrotesk-Bold.ttf"); 11 | 12 | pub const ICON_FONT_TTF: &[u8] = include_bytes!("../assets/fonts/capter.ttf"); 13 | 14 | pub const APPICON: &[u8] = include_bytes!("../assets/resources/icon.png"); 15 | 16 | use iced::Font; 17 | 18 | pub const MEDIUM_FONT: Font = Font { 19 | family: iced::font::Family::Name(FONT_NAME), 20 | weight: iced::font::Weight::Medium, 21 | stretch: iced::font::Stretch::Normal, 22 | style: iced::font::Style::Normal, 23 | }; 24 | 25 | pub const BOLD_FONT: Font = Font { 26 | family: iced::font::Family::Name(FONT_NAME), 27 | weight: iced::font::Weight::Bold, 28 | stretch: iced::font::Stretch::Normal, 29 | style: iced::font::Style::Normal, 30 | }; 31 | 32 | pub const ICON_FONT: Font = Font::with_name("capter"); 33 | 34 | pub const FOLDER_ICON_ICON: char = '\u{E101}'; 35 | 36 | pub const FILLED_RECTANGLE_ICON: char = '\u{F101}'; 37 | 38 | pub const HOLLOW_RECTANGLE_ICON: char = '\u{F102}'; 39 | 40 | pub const FILLED_ELLIPSE_ICON: char = '\u{F103}'; 41 | 42 | pub const HOLLOW_ELLIPSE_ICON: char = '\u{F104}'; 43 | 44 | pub const LINE_ICON: char = '\u{F105}'; 45 | 46 | pub const ARROW_ICON: char = '\u{F106}'; 47 | 48 | pub const FREE_HAND_ICON: char = '\u{F107}'; 49 | 50 | pub const HIGHLIGHTER_ICON: char = '\u{F108}'; 51 | 52 | pub const TEXT_ICON: char = '\u{F109}'; 53 | 54 | pub const MOVE_ICON: char = '\u{F201}'; 55 | -------------------------------------------------------------------------------- /src/ipc.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | futures::{SinkExt, Stream}, 3 | stream, 4 | }; 5 | use interprocess::local_socket::{ 6 | GenericNamespaced, ListenerOptions, ToNsName, traits::tokio::Listener, 7 | }; 8 | 9 | use crate::{Message, consts::APPNAME}; 10 | 11 | pub fn ipc_listener() -> impl Stream { 12 | stream::channel(1, async |mut output| { 13 | let name = APPNAME 14 | .to_ns_name::() 15 | .expect("Name should be created"); 16 | 17 | let listner_opts = ListenerOptions::new().name(name); 18 | 19 | let listener = listner_opts 20 | .create_tokio() 21 | .expect("Listener should be created"); 22 | 23 | loop { 24 | if let Ok(_stream) = listener.accept().await { 25 | let _ = output.send(Message::OpenSettingsWindow).await; 26 | } 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/key_listener.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | futures::{SinkExt, Stream, StreamExt, channel::mpsc}, 3 | stream, 4 | }; 5 | use rdev::{EventType, Key, listen}; 6 | 7 | use crate::Message; 8 | 9 | pub fn global_key_listener() -> impl Stream { 10 | stream::channel(1, async |mut output| { 11 | let (mut sender, mut receiver) = mpsc::channel(1); 12 | 13 | std::thread::spawn(move || { 14 | let _ = listen(move |event| { 15 | let _ = sender.try_send(event); 16 | }); 17 | }); 18 | 19 | let mut alt_pressed = false; 20 | let mut shift_pressed = false; 21 | 22 | loop { 23 | let event = receiver.select_next_some().await; 24 | match event.event_type { 25 | EventType::KeyPress(key) => { 26 | match key { 27 | Key::Alt => alt_pressed = true, 28 | Key::ShiftLeft | Key::ShiftRight => shift_pressed = true, 29 | Key::KeyS if alt_pressed && shift_pressed => { 30 | let _ = output.send(Message::OpenCaptureWindow).await; 31 | } 32 | Key::KeyO if alt_pressed && shift_pressed => { 33 | let _ = output.send(Message::OpenSettingsWindow).await; 34 | } 35 | _ => {} 36 | } 37 | } 38 | EventType::KeyRelease(key) => { 39 | match key { 40 | Key::Alt => alt_pressed = false, 41 | Key::ShiftLeft | Key::ShiftRight => shift_pressed = false, 42 | _ => {} 43 | } 44 | } 45 | _ => {} 46 | } 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | mod action; 4 | mod consts; 5 | mod ipc; 6 | mod key_listener; 7 | mod notify; 8 | mod subscription; 9 | mod theme; 10 | mod tray_icon; 11 | mod update; 12 | mod view; 13 | mod window; 14 | 15 | mod capture; 16 | mod config; 17 | mod settings; 18 | 19 | mod organize_type; 20 | 21 | use std::collections::BTreeMap; 22 | 23 | use config::Config; 24 | use consts::{APPNAME, BOLD_FONT_TTF, ICON_FONT_TTF, MEDIUM_FONT, MEDIUM_FONT_TTF}; 25 | use iced::{Task, daemon, window::Id}; 26 | use interprocess::local_socket::{self, GenericNamespaced, ToNsName, traits::Stream}; 27 | use tray_icon::create_tray_icon; 28 | use window::AppWindow; 29 | 30 | fn main() -> Result<(), iced::Error> { 31 | let name = APPNAME 32 | .to_ns_name::() 33 | .expect("Name must be valid"); 34 | 35 | if local_socket::Stream::connect(name).is_ok() { 36 | return Ok(()); 37 | }; 38 | 39 | #[cfg(not(target_os = "linux"))] 40 | let _tray_icon = create_tray_icon(); 41 | 42 | #[cfg(target_os = "linux")] 43 | std::thread::spawn(|| { 44 | gtk::init().expect("GTK must be initialized"); 45 | let _tray_icon = create_tray_icon(); 46 | gtk::main(); 47 | }); 48 | 49 | daemon(App::new, App::update, App::view) 50 | .font(MEDIUM_FONT_TTF) 51 | .font(BOLD_FONT_TTF) 52 | .font(ICON_FONT_TTF) 53 | .default_font(MEDIUM_FONT) 54 | .title(App::title) 55 | .style(App::style) 56 | .theme(App::theme) 57 | .subscription(App::subscription) 58 | .antialiasing(true) 59 | .run() 60 | } 61 | 62 | pub struct App { 63 | #[cfg(target_os = "windows")] 64 | notifier: win32_notif::ToastsNotifier, 65 | 66 | config: Config, 67 | windows: BTreeMap, 68 | } 69 | 70 | #[derive(Debug, Clone)] 71 | pub enum Message { 72 | ConfigInitialized, 73 | OpenSettingsWindow, 74 | OpenCaptureWindow, 75 | Undo, 76 | Done, 77 | Cancel, 78 | RequestClose(Id), 79 | WindowClosed(Id), 80 | ExitApp, 81 | Settings(Id, settings::Message), 82 | Capture(Id, capture::Message), 83 | } 84 | 85 | impl App { 86 | pub fn new() -> (App, Task) { 87 | let (config, task) = match Config::load() { 88 | Ok((config, is_first_creation)) => { 89 | ( 90 | config, 91 | if is_first_creation { 92 | Task::done(Message::OpenSettingsWindow) 93 | } else { 94 | Task::none() 95 | }, 96 | ) 97 | } 98 | Err(_) => (Config::default(), Task::done(Message::OpenSettingsWindow)), 99 | }; 100 | 101 | ( 102 | App { 103 | #[cfg(target_os = "windows")] 104 | notifier: win32_notif::ToastsNotifier::new(consts::APPID) 105 | .expect("Notifier must be created"), 106 | 107 | config, 108 | windows: BTreeMap::new(), 109 | }, 110 | task, 111 | ) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/notify.rs: -------------------------------------------------------------------------------- 1 | use crate::App; 2 | 3 | impl App { 4 | #[cfg(target_os = "windows")] 5 | pub fn notify(&self, body: &str, image_path: Option) { 6 | use win32_notif::{ 7 | NotificationActivatedEventHandler, NotificationBuilder, 8 | notification::visual::{Image, Placement, Text, image::ImageCrop}, 9 | }; 10 | 11 | if !self.config.show_notification { 12 | return; 13 | }; 14 | 15 | use crate::consts::{APPID, APPNAME}; 16 | 17 | let mut notification_builder = 18 | NotificationBuilder::new().visual(Text::new(1, None, None, String::from(body))); 19 | 20 | if let Some(image_path) = image_path { 21 | notification_builder = notification_builder.visual(Image::new( 22 | 1, 23 | format!("file:///{image_path}"), 24 | None, 25 | false, 26 | Placement::Hero, 27 | ImageCrop::Default, 28 | false, 29 | )); 30 | 31 | notification_builder = notification_builder.on_activated( 32 | NotificationActivatedEventHandler::new(move |_, _| { 33 | let _ = opener::open(image_path.clone()); 34 | Ok(()) 35 | }), 36 | ); 37 | }; 38 | 39 | let _ = notification_builder 40 | .build(1, &self.notifier, APPNAME, APPID) 41 | .and_then(|notification| notification.show()); 42 | } 43 | 44 | #[cfg(target_os = "linux")] 45 | pub fn notify(&self, body: &str, image_path: Option) { 46 | use notify_rust::Notification; 47 | 48 | use crate::consts::APPNAME; 49 | 50 | if !self.config.show_notification { 51 | return; 52 | }; 53 | 54 | let mut notification = Notification::new(); 55 | 56 | notification.appname(APPNAME); 57 | 58 | notification.summary(body); 59 | 60 | notification.auto_icon(); 61 | 62 | if let Some(image_path) = image_path { 63 | notification.image_path(&image_path); 64 | }; 65 | 66 | let _ = notification.show(); 67 | } 68 | 69 | #[cfg(target_os = "macos")] 70 | pub fn notify(&self, body: &str, _image_path: Option) { 71 | use notify_rust::Notification; 72 | 73 | use crate::consts::APPNAME; 74 | 75 | if !self.config.show_notification { 76 | return; 77 | }; 78 | 79 | let mut notification = Notification::new(); 80 | 81 | notification.appname(APPNAME); 82 | 83 | notification.summary(body); 84 | 85 | notification.auto_icon(); 86 | 87 | let _ = notification.show(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/organize_type.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 4 | pub enum OrgranizeMode { 5 | #[default] 6 | Flat, 7 | ByYear, 8 | ByYearAndMonth, 9 | } 10 | 11 | impl OrgranizeMode { 12 | pub const ALL: [OrgranizeMode; 3] = [ 13 | OrgranizeMode::Flat, 14 | OrgranizeMode::ByYear, 15 | OrgranizeMode::ByYearAndMonth, 16 | ]; 17 | } 18 | 19 | impl std::fmt::Display for OrgranizeMode { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | match self { 22 | OrgranizeMode::Flat => write!(f, "Flat"), 23 | OrgranizeMode::ByYear => write!(f, "By Year"), 24 | OrgranizeMode::ByYearAndMonth => write!(f, "By Year And Month"), 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/settings/init.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Config, settings::Settings}; 2 | 3 | impl Settings { 4 | pub fn init(config: &Config) -> Self { 5 | Self { 6 | folder_path: config.truncate_folder_path(), 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/settings/mod.rs: -------------------------------------------------------------------------------- 1 | mod init; 2 | mod update; 3 | mod view; 4 | 5 | use crate::{organize_type::OrgranizeMode, theme::Theme}; 6 | 7 | #[derive(Debug)] 8 | pub struct Settings { 9 | folder_path: String, 10 | } 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum Message { 14 | UpdateFolderPath, 15 | OpenFolder, 16 | UpdateTheme(Theme), 17 | ToggleShowNotification(bool), 18 | UpdateOrganizeMode(OrgranizeMode), 19 | RequestExit, 20 | } 21 | 22 | pub enum Request { 23 | Exit, 24 | } 25 | -------------------------------------------------------------------------------- /src/settings/update.rs: -------------------------------------------------------------------------------- 1 | use rfd::FileDialog; 2 | 3 | use crate::{ 4 | action::Action, 5 | config::Config, 6 | settings::{Message, Request, Settings}, 7 | }; 8 | 9 | impl Settings { 10 | pub fn update(&mut self, message: Message, config: &mut Config) -> Action { 11 | match message { 12 | Message::UpdateFolderPath => { 13 | if let Some(path) = FileDialog::new() 14 | .set_directory(config.folder_path.clone()) 15 | .pick_folder() 16 | { 17 | config.folder_path = path; 18 | 19 | self.folder_path = config.truncate_folder_path(); 20 | } 21 | } 22 | Message::OpenFolder => { 23 | config.open_screenshot_folder(); 24 | } 25 | Message::UpdateTheme(theme) => { 26 | config.theme = theme; 27 | } 28 | Message::ToggleShowNotification(show_notification) => { 29 | config.show_notification = show_notification; 30 | } 31 | Message::UpdateOrganizeMode(organize_type) => { 32 | config.organize_mode = organize_type; 33 | } 34 | Message::RequestExit => { 35 | return Action::requests([Request::Exit]); 36 | } 37 | } 38 | Action::none() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/settings/view.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Alignment::Center, 3 | Length, 4 | widget::{Button, Column, Container, PickList, Row, Scrollable, Space, Text, Toggler}, 5 | }; 6 | 7 | use crate::{ 8 | config::Config, 9 | consts::{APPNAME, BOLD_FONT, FOLDER_ICON_ICON, ICON_FONT}, 10 | organize_type::OrgranizeMode, 11 | settings::{Message, Settings}, 12 | theme::{Element, Theme, button::ButtonClass}, 13 | }; 14 | 15 | const TEXT_SIZE: u32 = 20; 16 | 17 | impl Settings { 18 | pub fn view<'a>(&'a self, config: &'a Config) -> Element<'a, Message> { 19 | let header = Row::new() 20 | .push(Text::new(APPNAME).size(60).font(BOLD_FONT)) 21 | .push(Space::with_width(Length::Fill)) 22 | .push( 23 | Button::new(Text::new("Exit").center().size(TEXT_SIZE)) 24 | .on_press(Message::RequestExit) 25 | .height(40) 26 | .class(ButtonClass::Danger), 27 | ) 28 | .align_y(Center); 29 | 30 | let body = Scrollable::new( 31 | Column::new() 32 | .push(list_item( 33 | "Theme", 34 | PickList::new(&Theme::ALL[..], Some(&config.theme), Message::UpdateTheme) 35 | .text_size(TEXT_SIZE) 36 | .into(), 37 | )) 38 | .push(list_item( 39 | "Show Notification", 40 | Toggler::new(config.show_notification) 41 | .size(22) 42 | .on_toggle(Message::ToggleShowNotification) 43 | .into(), 44 | )) 45 | .push(list_item( 46 | "Screenshots Folder", 47 | Row::new() 48 | .push( 49 | Button::new( 50 | Text::new(FOLDER_ICON_ICON) 51 | .font(ICON_FONT) 52 | .size(TEXT_SIZE) 53 | .center(), 54 | ) 55 | .on_press(Message::OpenFolder), 56 | ) 57 | .push(Space::with_width(10)) 58 | .push( 59 | Button::new( 60 | Text::new(self.folder_path.as_str()) 61 | .size(TEXT_SIZE) 62 | .center(), 63 | ) 64 | .on_press(Message::UpdateFolderPath), 65 | ) 66 | .into(), 67 | )) 68 | .push(list_item( 69 | "Organize Mode", 70 | PickList::new( 71 | &OrgranizeMode::ALL[..], 72 | Some(&config.organize_mode), 73 | Message::UpdateOrganizeMode, 74 | ) 75 | .text_size(TEXT_SIZE) 76 | .into(), 77 | )) 78 | .spacing(10), 79 | ) 80 | .spacing(10); 81 | 82 | Column::new() 83 | .push(header) 84 | .push(body) 85 | .spacing(10) 86 | .padding(10) 87 | .into() 88 | } 89 | } 90 | 91 | fn list_item<'a>(label: &'a str, item: Element<'a, Message>) -> Element<'a, Message> { 92 | Container::new( 93 | Row::new() 94 | .push(Text::new(label).size(22).font(BOLD_FONT)) 95 | .push(Space::with_width(Length::Fill)) 96 | .push(item) 97 | .align_y(Center) 98 | .width(Length::Fill) 99 | .height(Length::Fill) 100 | .padding(10), 101 | ) 102 | .height(80) 103 | .into() 104 | } 105 | -------------------------------------------------------------------------------- /src/subscription.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Subscription, 3 | keyboard::{self, Modifiers, key}, 4 | window, 5 | }; 6 | 7 | use crate::{ 8 | App, Message, 9 | ipc::ipc_listener, 10 | key_listener::global_key_listener, 11 | tray_icon::{tray_icon_listener, tray_menu_listener}, 12 | }; 13 | 14 | impl App { 15 | pub fn subscription(&self) -> Subscription { 16 | let window_events = window::close_events().map(Message::WindowClosed); 17 | 18 | let app_key_listener = keyboard::on_key_press(|key, modifiers| { 19 | match key { 20 | key::Key::Named(named) => { 21 | match named { 22 | key::Named::Escape => Some(Message::Cancel), 23 | key::Named::Enter => Some(Message::Done), 24 | _ => None, 25 | } 26 | } 27 | key::Key::Character(char) => { 28 | match char.as_str() { 29 | "s" if modifiers.contains(Modifiers::SHIFT) 30 | && modifiers.contains(Modifiers::ALT) => 31 | { 32 | Some(Message::OpenCaptureWindow) 33 | } 34 | "z" if modifiers == Modifiers::CTRL => Some(Message::Undo), 35 | _ => None, 36 | } 37 | } 38 | _ => None, 39 | } 40 | }); 41 | 42 | let global_key_listener = Subscription::run(global_key_listener); 43 | 44 | let tray_icon_listener = Subscription::run(tray_icon_listener); 45 | 46 | let tray_menu_listener = Subscription::run(tray_menu_listener); 47 | 48 | let ipc = Subscription::run(ipc_listener); 49 | 50 | Subscription::batch([ 51 | window_events, 52 | app_key_listener, 53 | global_key_listener, 54 | tray_icon_listener, 55 | tray_menu_listener, 56 | ipc, 57 | ]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/theme/button.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, Color, 3 | widget::button::{Catalog, Status, Style}, 4 | }; 5 | 6 | use crate::theme::{Theme, border}; 7 | 8 | pub enum ButtonClass { 9 | Default, 10 | Danger, 11 | Selected, 12 | } 13 | 14 | impl Catalog for Theme { 15 | type Class<'a> = ButtonClass; 16 | 17 | fn default<'a>() -> Self::Class<'a> { 18 | ButtonClass::Default 19 | } 20 | 21 | fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { 22 | let palette = self.palette(); 23 | let extended_palette = self.extended_palette(); 24 | 25 | Style { 26 | background: { 27 | match status { 28 | Status::Active => { 29 | match class { 30 | ButtonClass::Default => { 31 | Some(Background::Color(extended_palette.background.strong.color)) 32 | } 33 | ButtonClass::Danger => { 34 | Some(Background::Color(extended_palette.danger.base.color)) 35 | } 36 | ButtonClass::Selected => { 37 | Some(Background::Color(extended_palette.primary.base.color)) 38 | } 39 | } 40 | } 41 | Status::Hovered | Status::Pressed => { 42 | match class { 43 | ButtonClass::Default => { 44 | Some(Background::Color(extended_palette.background.strong.color)) 45 | } 46 | ButtonClass::Danger => { 47 | Some(Background::Color(extended_palette.danger.strong.color)) 48 | } 49 | ButtonClass::Selected => { 50 | Some(Background::Color(extended_palette.primary.strong.color)) 51 | } 52 | } 53 | } 54 | Status::Disabled => Some(Background::Color(Color::default())), 55 | } 56 | }, 57 | border: border(extended_palette), 58 | text_color: palette.text, 59 | ..Default::default() 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/theme/container.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, Color, 3 | widget::container::{Catalog, Style}, 4 | }; 5 | 6 | use crate::theme::{Theme, border}; 7 | 8 | pub enum ContainerClass { 9 | Default, 10 | Transparent, 11 | } 12 | 13 | impl Catalog for Theme { 14 | type Class<'a> = ContainerClass; 15 | 16 | fn default<'a>() -> Self::Class<'a> { 17 | ContainerClass::Default 18 | } 19 | 20 | fn style(&self, class: &Self::Class<'_>) -> Style { 21 | let palette = self.palette(); 22 | let extended_palette = self.extended_palette(); 23 | match class { 24 | ContainerClass::Default => { 25 | Style { 26 | background: Some(Background::Color(extended_palette.background.weak.color)), 27 | border: border(extended_palette), 28 | text_color: Some(palette.text), 29 | ..Default::default() 30 | } 31 | } 32 | ContainerClass::Transparent => { 33 | Style { 34 | background: Some(Background::Color(Color::TRANSPARENT)), 35 | ..Default::default() 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/theme/menu.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, 3 | overlay::menu::{Catalog, Style}, 4 | }; 5 | 6 | use crate::theme::{Theme, border}; 7 | 8 | pub enum MenuClass { 9 | Default, 10 | } 11 | 12 | impl Catalog for Theme { 13 | type Class<'a> = MenuClass; 14 | 15 | fn default<'a>() -> ::Class<'a> { 16 | MenuClass::Default 17 | } 18 | 19 | fn style(&self, _class: &::Class<'_>) -> Style { 20 | let palette = self.palette(); 21 | let extended_palette = self.extended_palette(); 22 | 23 | Style { 24 | background: Background::Color(extended_palette.background.strong.color), 25 | border: border(extended_palette), 26 | text_color: palette.text, 27 | selected_text_color: palette.text, 28 | selected_background: Background::Color(extended_palette.primary.base.color), 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/theme/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod button; 2 | pub mod container; 3 | pub mod menu; 4 | pub mod picklist; 5 | pub mod scrollable; 6 | pub mod slider; 7 | pub mod text; 8 | pub mod text_input; 9 | pub mod toggler; 10 | 11 | use std::{fmt::Display, sync::LazyLock}; 12 | 13 | use iced::{ 14 | Border, Color, 15 | border::Radius, 16 | color, 17 | theme::{Base, Palette, Style, palette::Extended}, 18 | }; 19 | use serde::{Deserialize, Serialize}; 20 | 21 | #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] 22 | pub enum Theme { 23 | #[default] 24 | System, 25 | Light, 26 | Dark, 27 | } 28 | 29 | pub const LIGHT_PALETTE: Palette = Palette { 30 | background: color!(0xdcdcdc), 31 | text: color!(0x323232), 32 | primary: color!(0x6464ff), 33 | success: color!(0x4caf50), 34 | warning: color!(0xffa500), 35 | danger: color!(0xff6464), 36 | }; 37 | 38 | pub static EXTENDED_LIGHT_PALETTE: LazyLock = 39 | LazyLock::new(|| Extended::generate(LIGHT_PALETTE)); 40 | 41 | pub const DARK_PALETTE: Palette = Palette { 42 | background: color!(0x3c3c3c), 43 | text: color!(0xd2d2d2), 44 | primary: color!(0x4343e4), 45 | success: color!(0x5da65d), 46 | warning: color!(0xe4a343), 47 | danger: color!(0xe44343), 48 | }; 49 | 50 | pub static EXTENDED_DARK_PALETTE: LazyLock = 51 | LazyLock::new(|| Extended::generate(DARK_PALETTE)); 52 | 53 | pub type Element<'a, Message> = iced::Element<'a, Message, Theme>; 54 | 55 | impl Theme { 56 | pub const ALL: [Theme; 3] = [Self::System, Self::Light, Self::Dark]; 57 | 58 | pub fn palette(&self) -> Palette { 59 | match self { 60 | Self::System => { 61 | dark_light::detect().map_or(LIGHT_PALETTE, |theme| { 62 | match theme { 63 | dark_light::Mode::Dark => DARK_PALETTE, 64 | dark_light::Mode::Light => LIGHT_PALETTE, 65 | dark_light::Mode::Unspecified => LIGHT_PALETTE, 66 | } 67 | }) 68 | } 69 | Self::Light => LIGHT_PALETTE, 70 | Self::Dark => DARK_PALETTE, 71 | } 72 | } 73 | 74 | pub fn extended_palette(&self) -> Extended { 75 | match self { 76 | Self::System => { 77 | dark_light::detect().map_or(*EXTENDED_LIGHT_PALETTE, |theme| { 78 | match theme { 79 | dark_light::Mode::Dark => *EXTENDED_DARK_PALETTE, 80 | dark_light::Mode::Light => *EXTENDED_LIGHT_PALETTE, 81 | dark_light::Mode::Unspecified => *EXTENDED_LIGHT_PALETTE, 82 | } 83 | }) 84 | } 85 | Self::Light => *EXTENDED_LIGHT_PALETTE, 86 | Self::Dark => *EXTENDED_DARK_PALETTE, 87 | } 88 | } 89 | 90 | pub fn toggle(&mut self) { 91 | match self { 92 | Self::System => *self = Self::Light, 93 | Self::Light => *self = Self::Dark, 94 | Self::Dark => *self = Self::System, 95 | } 96 | } 97 | } 98 | 99 | impl Display for Theme { 100 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 101 | match self { 102 | Self::System => write!(f, "System"), 103 | Self::Light => write!(f, "Light"), 104 | Self::Dark => write!(f, "Dark"), 105 | } 106 | } 107 | } 108 | 109 | impl Base for Theme { 110 | fn base(&self) -> Style { 111 | Style { 112 | background_color: self.extended_palette().background.weakest.color, 113 | text_color: Color::default(), 114 | } 115 | } 116 | 117 | fn palette(&self) -> Option { 118 | Some(self.palette()) 119 | } 120 | } 121 | 122 | pub fn border(extended_palette: Extended) -> Border { 123 | Border { 124 | color: extended_palette.background.strongest.color, 125 | width: 0.5, 126 | radius: Radius::new(8), 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/theme/picklist.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, 3 | widget::pick_list::{Catalog, Style}, 4 | }; 5 | 6 | use crate::theme::{Theme, border}; 7 | 8 | pub enum PickListClass { 9 | Default, 10 | } 11 | 12 | impl Catalog for Theme { 13 | type Class<'a> = PickListClass; 14 | 15 | fn default<'a>() -> ::Class<'a> { 16 | PickListClass::Default 17 | } 18 | 19 | fn style( 20 | &self, 21 | _class: &::Class<'_>, 22 | _status: iced::widget::pick_list::Status, 23 | ) -> Style { 24 | let palette = self.palette(); 25 | let extended_palette = self.extended_palette(); 26 | 27 | Style { 28 | text_color: palette.text, 29 | placeholder_color: extended_palette.background.weak.color, 30 | handle_color: palette.text, 31 | background: Background::Color(extended_palette.background.strong.color), 32 | border: border(extended_palette), 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/theme/scrollable.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, 3 | widget::{ 4 | container, 5 | scrollable::{Catalog, Rail, Scroller, Status, Style}, 6 | }, 7 | }; 8 | 9 | use crate::theme::{Theme, border}; 10 | 11 | pub enum ScrollableClass { 12 | Default, 13 | } 14 | 15 | impl Catalog for Theme { 16 | type Class<'a> = ScrollableClass; 17 | 18 | fn default<'a>() -> Self::Class<'a> { 19 | ScrollableClass::Default 20 | } 21 | 22 | fn style(&self, _class: &Self::Class<'_>, _status: Status) -> Style { 23 | let extended_palette = self.extended_palette(); 24 | 25 | let rail = Rail { 26 | background: Some(Background::Color(extended_palette.background.weak.color)), 27 | border: border(extended_palette), 28 | scroller: Scroller { 29 | color: extended_palette.background.strong.color, 30 | border: border(extended_palette), 31 | }, 32 | }; 33 | 34 | Style { 35 | container: container::Style::default(), 36 | vertical_rail: rail, 37 | horizontal_rail: rail, 38 | gap: None, 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/theme/slider.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, 3 | widget::slider::{Catalog, Handle, HandleShape, Rail, Status, Style}, 4 | }; 5 | 6 | use crate::theme::{Theme, border}; 7 | 8 | pub enum SliderClass { 9 | Default, 10 | } 11 | 12 | impl Catalog for Theme { 13 | type Class<'a> = SliderClass; 14 | 15 | fn default<'a>() -> Self::Class<'a> { 16 | SliderClass::Default 17 | } 18 | 19 | fn style(&self, _class: &Self::Class<'_>, status: Status) -> Style { 20 | let extended_palette = self.extended_palette(); 21 | 22 | Style { 23 | rail: Rail { 24 | backgrounds: ( 25 | Background::Color(extended_palette.background.base.color), 26 | Background::Color(extended_palette.background.base.color), 27 | ), 28 | width: 10.0, 29 | border: border(extended_palette), 30 | }, 31 | handle: Handle { 32 | shape: HandleShape::Circle { radius: 10.0 }, 33 | background: match status { 34 | Status::Active => extended_palette.primary.base.color, 35 | Status::Hovered | Status::Dragged => extended_palette.primary.strong.color, 36 | } 37 | .into(), 38 | border_width: 0.5, 39 | border_color: extended_palette.background.strongest.color, 40 | }, 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/theme/text.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Color, 3 | widget::text::{Catalog, Style}, 4 | }; 5 | 6 | use crate::theme::Theme; 7 | 8 | pub enum TextClass { 9 | Default, 10 | Custom(Color), 11 | } 12 | 13 | impl Catalog for Theme { 14 | type Class<'a> = TextClass; 15 | 16 | fn default<'a>() -> Self::Class<'a> { 17 | TextClass::Default 18 | } 19 | 20 | fn style(&self, item: &Self::Class<'_>) -> Style { 21 | Style { 22 | color: match item { 23 | TextClass::Default => Some(self.palette().text), 24 | TextClass::Custom(color) => Some(*color), 25 | }, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/theme/text_input.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Background, Border, 3 | border::Radius, 4 | widget::text_input::{Catalog, Status, Style}, 5 | }; 6 | 7 | use crate::theme::Theme; 8 | 9 | pub enum TextInputClass { 10 | Default, 11 | } 12 | 13 | impl Catalog for Theme { 14 | type Class<'a> = TextInputClass; 15 | 16 | fn default<'a>() -> Self::Class<'a> { 17 | TextInputClass::Default 18 | } 19 | 20 | fn style(&self, _class: &Self::Class<'_>, status: Status) -> Style { 21 | let palette = self.palette(); 22 | let extended_palette = self.extended_palette(); 23 | 24 | Style { 25 | background: Background::Color(extended_palette.background.strong.color), 26 | border: Border { 27 | color: match status { 28 | Status::Hovered | Status::Focused { .. } => extended_palette.primary.base.color, 29 | Status::Active | Status::Disabled => extended_palette.background.weak.color, 30 | }, 31 | width: 0.5, 32 | radius: Radius::new(8), 33 | }, 34 | icon: palette.text, 35 | placeholder: extended_palette.background.weakest.text, 36 | value: palette.text, 37 | selection: extended_palette.primary.weak.color, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/theme/toggler.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::toggler::{Catalog, Status, Style}; 2 | 3 | use crate::theme::Theme; 4 | 5 | pub enum TogglerClass { 6 | Default, 7 | } 8 | 9 | impl Catalog for Theme { 10 | type Class<'a> = TogglerClass; 11 | 12 | fn default<'a>() -> Self::Class<'a> { 13 | TogglerClass::Default 14 | } 15 | 16 | fn style(&self, _class: &Self::Class<'_>, status: Status) -> Style { 17 | let extended_palette = self.extended_palette(); 18 | 19 | Style { 20 | background: extended_palette.background.base.color, 21 | background_border_width: 0.5, 22 | background_border_color: extended_palette.background.strongest.color, 23 | foreground: match status { 24 | Status::Active { is_toggled } => { 25 | match is_toggled { 26 | true => extended_palette.primary.base.color, 27 | false => extended_palette.secondary.base.color, 28 | } 29 | } 30 | Status::Hovered { is_toggled } => { 31 | match is_toggled { 32 | true => extended_palette.primary.strong.color, 33 | false => extended_palette.secondary.strong.color, 34 | } 35 | } 36 | Status::Disabled => extended_palette.background.strongest.color, 37 | }, 38 | foreground_border_width: 0.5, 39 | foreground_border_color: extended_palette.background.strongest.color, 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/tray_icon.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use iced::{ 4 | futures::{SinkExt, Stream, StreamExt, channel::mpsc}, 5 | stream, 6 | }; 7 | use tokio::time::sleep; 8 | use tray_icon::{ 9 | Icon, 10 | MouseButton::Left, 11 | TrayIcon, TrayIconBuilder, TrayIconEvent, 12 | menu::{ 13 | Menu, MenuEvent, MenuItem, PredefinedMenuItem, 14 | accelerator::{Accelerator, Code, Modifiers}, 15 | }, 16 | }; 17 | use xcap::image::load_from_memory; 18 | 19 | use crate::{ 20 | Message, 21 | consts::{APPICON, APPNAME}, 22 | }; 23 | 24 | pub fn create_tray_icon() -> TrayIcon { 25 | let icon_image = load_from_memory(APPICON).expect("Icon should be loaded"); 26 | let (width, height) = (icon_image.width(), icon_image.height()); 27 | 28 | let icon = 29 | Icon::from_rgba(icon_image.into_bytes(), width, height).expect("Icon should be created"); 30 | 31 | let menu = Menu::with_items(&[ 32 | &MenuItem::with_id( 33 | "open", 34 | "Open", 35 | true, 36 | Some(Accelerator::new( 37 | Some(Modifiers::SHIFT.union(Modifiers::ALT)), 38 | Code::KeyO, 39 | )), 40 | ), 41 | &PredefinedMenuItem::separator(), 42 | &MenuItem::with_id( 43 | "capture", 44 | "Capture", 45 | true, 46 | Some(Accelerator::new( 47 | Some(Modifiers::SHIFT.union(Modifiers::ALT)), 48 | Code::KeyS, 49 | )), 50 | ), 51 | &PredefinedMenuItem::separator(), 52 | &MenuItem::with_id("exit", "Exit", true, None), 53 | ]) 54 | .expect("Menu should be created"); 55 | 56 | TrayIconBuilder::new() 57 | .with_icon(icon) 58 | .with_menu(Box::new(menu)) 59 | .with_tooltip(format!("{} {}", APPNAME, env!("CARGO_PKG_VERSION"))) 60 | .build() 61 | .expect("Tray icon should be created") 62 | } 63 | 64 | pub fn tray_icon_listener() -> impl Stream { 65 | stream::channel(1, async |mut output| { 66 | let (mut sender, mut reciever) = mpsc::channel(1); 67 | 68 | std::thread::spawn(move || { 69 | loop { 70 | if let Ok(event) = TrayIconEvent::receiver().recv() { 71 | let _ = sender.try_send(event); 72 | } 73 | } 74 | }); 75 | 76 | loop { 77 | if let TrayIconEvent::DoubleClick { button: Left, .. } = 78 | reciever.select_next_some().await 79 | { 80 | let _ = output.send(Message::OpenCaptureWindow).await; 81 | } 82 | } 83 | }) 84 | } 85 | 86 | pub fn tray_menu_listener() -> impl Stream { 87 | stream::channel(1, async |mut output| { 88 | let (mut sender, mut reciever) = mpsc::channel(1); 89 | 90 | std::thread::spawn(move || { 91 | loop { 92 | if let Ok(event) = MenuEvent::receiver().recv() { 93 | let _ = sender.try_send(event); 94 | } 95 | } 96 | }); 97 | 98 | loop { 99 | match reciever.select_next_some().await.id().0.as_str() { 100 | "open" => { 101 | let _ = output.send(Message::OpenSettingsWindow).await; 102 | } 103 | "capture" => { 104 | sleep(Duration::from_secs(1)).await; 105 | let _ = output.send(Message::OpenCaptureWindow).await; 106 | } 107 | "exit" => { 108 | let _ = output.send(Message::ExitApp).await; 109 | } 110 | _ => {} 111 | } 112 | } 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /src/update.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Point, Size, Task, 3 | window::{self, settings::PlatformSpecific}, 4 | }; 5 | use mouse_position::mouse_position::Mouse; 6 | 7 | use crate::{ 8 | App, Message, 9 | capture::{self, Capture}, 10 | consts::APPICON, 11 | settings::{self, Settings}, 12 | window::AppWindow, 13 | }; 14 | 15 | impl App { 16 | pub fn update(&mut self, message: Message) -> Task { 17 | match message { 18 | Message::ConfigInitialized => { 19 | self.notify("", None); 20 | return Task::done(Message::OpenCaptureWindow); 21 | } 22 | Message::OpenSettingsWindow => { 23 | if self.windows.is_empty() { 24 | let (id, task) = window::open(window::Settings { 25 | size: Size { 26 | width: 700.0, 27 | height: 430.0, 28 | }, 29 | position: window::Position::Centered, 30 | resizable: false, 31 | level: window::Level::Normal, 32 | icon: Some( 33 | window::icon::from_file_data(APPICON, None) 34 | .expect("AppIcon should be loaded"), 35 | ), 36 | #[cfg(target_os = "macos")] 37 | platform_specific: PlatformSpecific { 38 | title_hidden: true, 39 | titlebar_transparent: true, 40 | fullsize_content_view: true, 41 | }, 42 | #[cfg(target_os = "linux")] 43 | platform_specific: PlatformSpecific { 44 | application_id: String::from("Capter"), 45 | override_redirect: true, 46 | }, 47 | ..Default::default() 48 | }); 49 | 50 | self.windows.insert(id, Settings::init(&self.config).into()); 51 | 52 | return task.discard().chain(window::gain_focus(id)); 53 | } 54 | } 55 | Message::OpenCaptureWindow => { 56 | if self.windows.is_empty() 57 | || !matches!( 58 | self.windows 59 | .first_key_value() 60 | .expect("A window must exist") 61 | .1, 62 | AppWindow::Capture(_) 63 | ) 64 | { 65 | let (x, y) = match Mouse::get_mouse_position() { 66 | Mouse::Position { x, y } => (x, y), 67 | Mouse::Error => (0, 0), 68 | }; 69 | 70 | match xcap::Monitor::from_point(x, y) { 71 | Ok(monitor) => { 72 | let (id, open_task) = window::open(window::Settings { 73 | position: window::Position::Specific(Point::new( 74 | x as f32, y as f32, 75 | )), 76 | transparent: true, 77 | decorations: false, 78 | #[cfg(target_os = "windows")] 79 | platform_specific: PlatformSpecific { 80 | drag_and_drop: false, 81 | skip_taskbar: true, 82 | undecorated_shadow: false, 83 | }, 84 | ..Default::default() 85 | }); 86 | 87 | match Capture::new(monitor) { 88 | Ok(capture) => { 89 | self.windows.insert(id, capture.into()); 90 | 91 | return open_task 92 | .discard() 93 | .chain(window::gain_focus(id)) 94 | .chain(window::set_mode(id, window::Mode::Fullscreen)); 95 | } 96 | Err(err) => { 97 | let error = err.to_string(); 98 | self.notify(&error, None); 99 | } 100 | }; 101 | } 102 | Err(err) => { 103 | let error = err.to_string(); 104 | self.notify(&error, None); 105 | } 106 | } 107 | } 108 | } 109 | Message::Undo => { 110 | if let Some((id, AppWindow::Capture(_))) = self.windows.last_key_value() { 111 | return Task::done(Message::Capture(*id, capture::Message::Undo)); 112 | } 113 | } 114 | Message::Done => { 115 | if let Some((id, AppWindow::Capture(_))) = self.windows.last_key_value() { 116 | return Task::done(Message::Capture(*id, capture::Message::Done)); 117 | } 118 | } 119 | Message::Cancel => { 120 | if let Some((id, AppWindow::Capture(_))) = self.windows.last_key_value() { 121 | return Task::done(Message::Capture(*id, capture::Message::Cancel)); 122 | } 123 | } 124 | Message::RequestClose(id) => { 125 | return window::close(id); 126 | } 127 | Message::WindowClosed(id) => { 128 | match self.windows.remove(&id) { 129 | Some(AppWindow::Settings(_)) => { 130 | let _ = self.config.save(); 131 | } 132 | Some(AppWindow::Capture(capture)) => { 133 | let result = capture.finalize(&self.config); 134 | 135 | let image_path = result 136 | .as_ref() 137 | .ok() 138 | .and_then(|filename| filename.to_str().map(String::from)); 139 | 140 | let msg = result 141 | .map(|_| "Screenshot saved and copied to clipboard".to_string()) 142 | .unwrap_or_else(|err| err.to_string()); 143 | 144 | self.notify(&msg, image_path); 145 | } 146 | None => {} 147 | }; 148 | } 149 | Message::ExitApp => { 150 | let _ = self.config.save(); 151 | return iced::exit(); 152 | } 153 | Message::Settings(id, message) => { 154 | if let Some(AppWindow::Settings(config_window)) = self.windows.get_mut(&id) { 155 | let action = config_window.update(message, &mut self.config); 156 | 157 | let mut tasks = Vec::with_capacity(2); 158 | 159 | tasks.push( 160 | action 161 | .task 162 | .map(move |message| Message::Settings(id, message)), 163 | ); 164 | 165 | action.requests.into_iter().for_each(|request| { 166 | match request { 167 | settings::Request::Exit => { 168 | tasks.push(Task::done(Message::ExitApp)); 169 | } 170 | } 171 | }); 172 | 173 | return Task::batch(tasks); 174 | } 175 | } 176 | Message::Capture(id, message) => { 177 | if let Some(AppWindow::Capture(capture_window)) = self.windows.get_mut(&id) { 178 | let action = capture_window.update(message); 179 | 180 | let mut tasks = Vec::with_capacity(2); 181 | 182 | tasks.push( 183 | action 184 | .task 185 | .map(move |message| Message::Capture(id, message)), 186 | ); 187 | 188 | action.requests.into_iter().for_each(|request| { 189 | match request { 190 | capture::Request::Close => { 191 | tasks.push(Task::done(Message::RequestClose(id))); 192 | } 193 | } 194 | }); 195 | 196 | return Task::batch(tasks); 197 | } 198 | } 199 | } 200 | Task::none() 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/view.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | theme::{Base, Style}, 3 | window::Id, 4 | }; 5 | 6 | use crate::{ 7 | App, Message, 8 | theme::{Element, Theme}, 9 | window::AppWindow, 10 | }; 11 | 12 | impl App { 13 | pub fn title(&self, _id: Id) -> String { 14 | String::from("Capter") 15 | } 16 | 17 | pub fn theme(&self, _id: Id) -> Theme { 18 | self.config.theme 19 | } 20 | 21 | pub fn style(&self, theme: &Theme) -> Style { 22 | theme.base() 23 | } 24 | 25 | pub fn view(&self, id: Id) -> Element { 26 | match &self.windows.get(&id) { 27 | Some(AppWindow::Settings(settings)) => { 28 | settings 29 | .view(&self.config) 30 | .map(move |message| Message::Settings(id, message)) 31 | } 32 | Some(AppWindow::Capture(capture)) => { 33 | capture 34 | .view() 35 | .map(move |message| Message::Capture(id, message)) 36 | } 37 | None => unreachable!(), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | use crate::{capture::Capture, settings::Settings}; 2 | 3 | pub enum AppWindow { 4 | Settings(Box), 5 | Capture(Box), 6 | } 7 | 8 | impl From for AppWindow { 9 | fn from(settings: Settings) -> Self { 10 | AppWindow::Settings(Box::new(settings)) 11 | } 12 | } 13 | 14 | impl From for AppWindow { 15 | fn from(capture: Capture) -> Self { 16 | AppWindow::Capture(Box::new(capture)) 17 | } 18 | } 19 | --------------------------------------------------------------------------------