├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── RELEASE-CHECKLIST.md ├── build.rs ├── deny.toml ├── docs ├── README.md ├── advanced.md ├── config.md ├── file-format.md ├── jolly.toml └── static │ ├── basic-search.png │ ├── clipboard.png │ ├── startup.png │ └── toml-error.png ├── icon ├── README.md └── jolly.svg └── src ├── cli.rs ├── config.rs ├── custom ├── measured_container.rs ├── mod.rs └── mouse_area.rs ├── entry.rs ├── error.rs ├── icon ├── linux_and_friends.rs ├── macos.rs ├── mod.rs └── windows.rs ├── lib.rs ├── log.rs ├── main.rs ├── platform.rs ├── search_results.rs ├── settings.rs ├── store.rs ├── theme.rs └── ui.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | format: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Check Formatting 20 | run: cargo fmt --all --check 21 | 22 | cargo-deny: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | checks: 27 | - advisories 28 | - bans licenses sources 29 | 30 | # Prevent sudden announcement of a new advisory from failing ci: 31 | continue-on-error: ${{ matrix.checks == 'advisories' }} 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: EmbarkStudios/cargo-deny-action@v1 36 | with: 37 | command: check ${{ matrix.checks }} 38 | 39 | test: 40 | strategy: 41 | matrix: 42 | os: [windows-latest, ubuntu-latest, macos-latest] 43 | runs-on: ${{ matrix.os }} 44 | 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v3 48 | 49 | - name: Determine MSRV 50 | shell: bash 51 | run: | 52 | msrv=$(grep rust-version Cargo.toml | cut -d '"' -f2) 53 | echo "MSRV=$msrv" >> $GITHUB_ENV 54 | 55 | - name: Install MSRV rustc 56 | uses: dtolnay/rust-toolchain@master 57 | with: 58 | toolchain: ${{ env.MSRV }} 59 | 60 | - name: Install dependencies 61 | run: sudo apt-get install -y --no-install-recommends shared-mime-info xdg-utils gnome-icon-theme 62 | if: startsWith(matrix.os, 'ubuntu-') 63 | 64 | - name: Build 65 | run: cargo build --verbose 66 | - name: Run tests 67 | run: cargo test --verbose 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Create release builds for Windows, MacOS, Linux 2 | # based on https://jon.sprig.gs/blog/post/2442 3 | 4 | name: release 5 | on: 6 | push: 7 | # Enable when testing release infrastructure on a branch. 8 | # branches: 9 | # - ag/work 10 | tags: 11 | - "[0-9]+.[0-9]+.[0-9]+" 12 | jobs: 13 | create-release: 14 | name: Create Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Create Release 18 | id: create_release 19 | uses: softprops/action-gh-release@v1 20 | with: 21 | name: ${{ github.ref_name }} 22 | draft: false 23 | prerelease: false 24 | generate_release_notes: false 25 | 26 | build-release: 27 | name: build-release 28 | needs: create-release 29 | runs-on: ${{ matrix.os }} 30 | strategy: 31 | matrix: 32 | os: [windows-latest, ubuntu-latest, macos-latest] 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v3 37 | 38 | - name: Install dependencies 39 | run: sudo apt-get install -y --no-install-recommends shared-mime-info xdg-utils gnome-icon-theme 40 | if: startsWith(matrix.os, 'ubuntu-') 41 | 42 | 43 | - name: Build release binary 44 | run: cargo build --verbose --release 45 | 46 | - name: Strip release binary (linux and macos) 47 | if: matrix.build == 'linux' || matrix.build == 'macos' 48 | run: strip "target/release/jolly" 49 | 50 | - name: Build archive 51 | shell: bash 52 | run: | 53 | target=$(rustc -vV | grep host | awk '{ print $2 }') 54 | staging="jolly-${{ github.ref_name }}-$target" 55 | mkdir -p "$staging" 56 | 57 | cp {README.md,LICENSE-APACHE,LICENSE-MIT,CHANGELOG.md} "$staging/" 58 | cp -r docs "$staging" 59 | cp docs/jolly.toml "$staging/" 60 | 61 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 62 | cp "target/release/jolly.exe" "$staging/" 63 | 7z a "$staging.zip" "$staging" 64 | echo "ASSET=$staging.zip" >> $GITHUB_ENV 65 | else 66 | cp "target/release/jolly" "$staging/" 67 | tar czf "$staging.tar.gz" "$staging" 68 | echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV 69 | fi 70 | 71 | - name: Release 72 | uses: softprops/action-gh-release@v1 73 | with: 74 | tag_name: ${{ needs.create_release.outputs.tag-name }} 75 | files: ${{ env.ASSET }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.3.0] - 2023-08-09 4 | 5 | ### Added 6 | 7 | - Add support for `description` field. You can use this to provide more detail about a jolly entry, beyond its title. [#19](https://github.com/apgoetz/jolly/pull/19) 8 | 9 | - Add support for icons. Jolly will look up appropriate icons for files and display them inline. [#18](https://github.com/apgoetz/jolly/issues/18), [#20](https://github.com/apgoetz/jolly/pull/20), [#35](https://github.com/apgoetz/jolly/pull/35) 10 | 11 | - Added support for logging facade. Logging can be configured in the [config file](docs/config.md#log). [#30](https://github.com/apgoetz/jolly/pull/30) 12 | 13 | - Added basic CLI args to Jolly. Config file can now be specified as an argument. [#31](https://github.com/apgoetz/jolly/pull/31) 14 | 15 | 16 | ### Changed 17 | 18 | - Text shaping uses `iced` Advanced text shaping. Should have better support for non-ascii characters in entries [#25](https://github.com/apgoetz/jolly/pull/25), [#36](https://github.com/apgoetz/jolly/pull/36) 19 | 20 | ### Fixed 21 | 22 | - Cleaned up window resize commands to avoid flashing of window [#26](https://github.com/apgoetz/jolly/pull/26) 23 | 24 | 25 | ## [0.2.0] - 2023-02-06 26 | 27 | ### Added 28 | 29 | - MSRV statement. Jolly will track latest stable rust [#6](https://github.com/apgoetz/jolly/issues/6) 30 | - Settings Support. Jolly now has ability to customize settings [#10](https://github.com/apgoetz/jolly/issues/10) 31 | - Theme Support. Jolly now has ability to specify the colors of its theme [#11](https://github.com/apgoetz/jolly/issues/11) [#13](https://github.com/apgoetz/jolly/issues/13) 32 | - Packaging for NetBSD. via [#12](https://github.com/apgoetz/jolly/issues/12) 33 | 34 | ### Fixed 35 | 36 | - Jolly can show blank / garbage screen on startup on windows [#9](https://github.com/apgoetz/jolly/issues/7) 37 | - Starting Jolly while typing in another application prevents focus on windows [#14](https://github.com/apgoetz/jolly/issues/14) 38 | 39 | ## [0.1.1] - 2023-01-04 40 | 41 | This release fixes a bug on windows release builds where exeuting system commands would cause a console window to appear. 42 | 43 | ## [0.1.0] - 2022-12-22 44 | 45 | Initial Release 46 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jolly" 3 | version = "0.3.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | description = "a bookmark manager meets an application launcher, developed with iced" 7 | homepage = "https://github.com/apgoetz/jolly" 8 | documentation = "https://github.com/apgoetz/jolly" 9 | repository = "https://github.com/apgoetz/jolly" 10 | readme = "README.md" 11 | keywords = ["launcher","bookmarks", "iced"] 12 | exclude = ["docs/"] 13 | rust-version = "1.70" 14 | 15 | [dependencies] 16 | iced = { version = "0.10.0", features = ["image", "advanced"] } 17 | toml = { version = "0.7.1", features = ["preserve_order"] } 18 | serde = { version = "1.0", features = ["derive"] } 19 | dirs = "5" 20 | opener = "0.6" 21 | urlencoding = "2.1.0" 22 | lazy_static = "1" 23 | csscolorparser = { version = "0.6.2", features = ["serde"] } 24 | dark-light = "1.0.0" 25 | pulldown-cmark = "0.9" 26 | url = "2" 27 | once_cell = "1.18.0" 28 | resvg = "0.35.0" 29 | env_logger = "0.10.0" 30 | log = "0.4.19" 31 | which = "4.4.0" 32 | 33 | [target.'cfg(target_os = "macos")'.dependencies] 34 | objc = "0.2" 35 | core-graphics = "0.23" 36 | core-foundation = "0.9" 37 | 38 | [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] 39 | freedesktop-icons = "0.2" 40 | xdg-mime = "0.3.3" 41 | 42 | 43 | [target.'cfg(all(unix, not(target_os = "macos")))'.build-dependencies] 44 | freedesktop-icons = "0.2" 45 | dirs = "5" 46 | 47 | 48 | [dev-dependencies] 49 | tempfile = "3" 50 | 51 | [build-dependencies] 52 | chrono = { version = "0.4.26", default-features = false, features = ["clock"]} 53 | 54 | [target.'cfg(windows)'.build-dependencies] 55 | resvg = "0.35.0" 56 | ico = "0.3.0" 57 | winres = "0.1.12" 58 | 59 | [target.'cfg(windows)'.dependencies.windows] 60 | version = "0.48.0" 61 | features = [ 62 | 'UI_ViewManagement', 63 | 'Win32_UI_Shell', 64 | 'Win32_UI_Shell_Common', 65 | 'Win32_UI_WindowsAndMessaging', 66 | 'Win32_Foundation', 67 | 'Win32_Graphics_Gdi', 68 | 'Win32_System_Com', 69 | 'Win32_UI_Controls', 70 | 'Win32_System_LibraryLoader', 71 | ] 72 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/LICENSE-2.0 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 | https://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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jolly 2 | Jolly is a cross between a bookmark manager and an application launcher. 3 | 4 | It extends the concept of your browser bookmarking interface, but 5 | allows these bookmarks to access files on your local file system, as 6 | well as run commands in your shell. 7 | 8 | https://user-images.githubusercontent.com/1356587/259296825-59452f58-701d-410c-9da3-61f1e2f48e91.mov 9 | 10 | # Quick Introduction 11 | 12 | To use Jolly, simply run the `jolly` executable. 13 | 14 | ```bash 15 | # Run Jolly with jolly.toml in the current directory 16 | jolly 17 | ``` 18 | 19 | To use a config file that is not in the current directory, pass its path on the command line: 20 | 21 | ```bash 22 | # Run Jolly with a custom config file 23 | jolly /path/to/custom/jolly.toml 24 | ``` 25 | 26 | For more details on how Jolly finds its config file, see the 27 | [documentation](docs/file-format.md#locations). 28 | 29 | By default, Jolly won't show any results: just tell you how many 30 | entries it has loaded: 31 | 32 | ![startup page](docs/static/startup.png) 33 | 34 | You can search for an entry by typing in text: Jolly will use the 35 | title of the entry and any [tags](docs/file-format.md#tags) associated 36 | with the entry to find results: 37 | 38 | ![startup page](docs/static/basic-search.png) 39 | 40 | To open the entry, you can select it using the arrow and enter keys, 41 | or click it with the mouse. 42 | 43 | To learn more about the file format used by Jolly, see the [file-format](docs/file-format.md) page. 44 | 45 | To learn more about changing settings for Jolly, including how to 46 | customize the theme, see the [config](docs/config.md) page. 47 | 48 | To learn more advanced tips and tricks, see the [advanced](docs/advanced.md) usage page. 49 | 50 | # Why Jolly was created 51 | There are a lot of really good full featured launcher programs out 52 | there, for example, consider [launchy](https://www.launchy.net/), 53 | [rofi](https://github.com/davatorium/rofi), or 54 | [alfred](https://www.alfredapp.com/). These launcher programs tend to 55 | be packed with features, allowing you to do tons of different actions: 56 | for example, accessing any installed program or searching for any 57 | document on your computer. This can be quite powerful, but it may be 58 | hard to find the exact entry that you want among all of the other 59 | launcher results. 60 | 61 | On the other end of the spectrum are notetaking applications, such as 62 | [onenote](https://www.onenote.com), [obsidian](https://obsidian.md/), 63 | or [org mode](https://orgmode.org/). These are also super powerful, 64 | and solve the "noise" problem that launchers have, by only including 65 | content that is curated by the user. However, they are focused on the 66 | usecase of storing knowledge, not on quickly launching apps and links, 67 | which means it can take a couple of click to open a bookmark, instead 68 | of the nearly instantaneous feedback of a launcher app. 69 | 70 | The other obvious option here would be your web browser. And lets be 71 | honest, your web browser's search bar and bookmark interface has 72 | thousands more hours of development time poured into it: 73 | Jolly can't possibly hope to compete. However, web browsers are 74 | focused on web content only, which means that local files and external 75 | programs are annoyingly sandboxed away, hard to use with the bookmark 76 | interface. 77 | 78 | Hence Jolly: the curation of notetaking apps, with the instantaneous 79 | gratification of an app launcher, and sharp edges exposed that your 80 | web browser doesn't want you to have. 81 | 82 | # Installation 83 | 84 | The latest release of Jolly can be found on github [here](https://github.com/apgoetz/jolly/releases/latest) 85 | 86 | Alternatively, for rust users, Jolly can be installed via cargo: 87 | 88 | ```bash 89 | cargo install jolly 90 | ``` 91 | 92 | ## Freedesktop based systems 93 | 94 | If you want to use Jolly on Linux and BSD based platforms, then icon 95 | support is based on freedesktop.org standards. This means that you 96 | will need the following packages installed: 97 | 98 | + xdg-utils 99 | + shared-mime-info 100 | 101 | In addition, at least one icon theme needs to be installed. The 102 | default icon theme can be customized at build time using the 103 | environment variable `JOLLY_DEFAULT_THEME`, or it can be configured at 104 | runtime in the config file. See [icon 105 | documentation](docs/config#icon) for more details. 106 | 107 | ## NetBSD 108 | 109 | On NetBSD, a pre-compiled binary is available from the official 110 | repositories. To install Jolly, simply run: 111 | 112 | ```bash 113 | pkgin install jolly 114 | ``` 115 | 116 | Or, if you prefer to build it from source: 117 | 118 | ```bash 119 | cd /usr/pkgsrc/x11/jolly 120 | make install 121 | ``` 122 | 123 | **Regarding Minimum Supported Rust Version**: Jolly 124 | uses [iced](https://github.com/iced-rs/iced) for its GUI implementation, which 125 | is a fast moving project that generally only targets the latest stable 126 | rustc. Therefore Jolly will also usually target the same MSRV as 127 | `iced`. (Currently 1.70.0) 128 | 129 | ## macOS 130 | Jolly provides builds for macOS, and Jolly is tested for this 131 | platform, but the builds that Jolly provides are not packaged as an 132 | App Bundle and are unsigned. It might be just easier on macos to use 133 | `cargo install jolly` 134 | -------------------------------------------------------------------------------- /RELEASE-CHECKLIST.md: -------------------------------------------------------------------------------- 1 | Release Checklist 2 | ----------------- 3 | This checklist is based on ripgrep release process . 4 | 5 | * Ensure local `main` is up to date with respect to `origin/main`. 6 | * Make sure that `rustc --version` matches MSRV. 7 | * Run `cargo update` and review dependency updates. Commit updated 8 | `Cargo.lock`. 9 | * Run `cargo outdated -d 1` and review semver incompatible updates. Unless there is 10 | a strong motivation otherwise, review and update every dependency. 11 | * Run `cargo test` or `cargo msrv` to check if MSRV needs to be 12 | bumped. If MSRV must be updated, update `rust-version` key in Cargo.toml as well 13 | as the MSRV version mentioned in the readme. 14 | * Run `cargo deny check` and check output. Update dependencies as necessary. 15 | * Update the CHANGELOG as appropriate. 16 | * Edit the `Cargo.toml` to set the new jolly version. Run 17 | `cargo update -p jolly` so that the `Cargo.lock` is updated. Commit the 18 | changes and create a new signed tag. 19 | * Push changes to GitHub, NOT including the tag. (But do not publish new 20 | version of jolly to crates.io yet.) 21 | * Once CI for `master` finishes successfully, push the version tag. (Trying to 22 | do this in one step seems to result in GitHub Actions not seeing the tag 23 | push and thus not running the release workflow.) 24 | * Wait for CI to finish creating the release. If the release build fails, then 25 | delete the tag from GitHub, make fixes, re-tag, delete the release and push. 26 | * Copy the relevant section of the CHANGELOG to the tagged release notes. 27 | Include this blurb describing what jolly is: 28 | > tbd 29 | * Run `cargo publish`. 30 | * Add TBD section to the top of the CHANGELOG: 31 | ``` 32 | TBD 33 | === 34 | Unreleased changes. Release notes have not yet been written. 35 | ``` 36 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | // build script to add icon to Jolly executable. 2 | // this script is only used on windows platforms 3 | 4 | fn common() { 5 | use chrono::Utc; 6 | let date = Utc::now().date_naive(); 7 | println!("cargo:rustc-env=JOLLY_BUILD_DATE={date}"); 8 | } 9 | 10 | // no build requirements for macos FOR NOW 11 | #[cfg(target_os = "macos")] 12 | fn main() { 13 | common(); 14 | } 15 | 16 | // check to make sure dependencies are installed 17 | #[cfg(all(unix, not(target_os = "macos")))] 18 | fn main() { 19 | use std::env; 20 | 21 | common(); 22 | 23 | let theme = env::var("JOLLY_DEFAULT_THEME").unwrap_or("gnome".into()); 24 | println!("cargo:rustc-env=JOLLY_DEFAULT_THEME={}", theme); 25 | 26 | // check default theme is installed 27 | let themes = freedesktop_icons::list_themes(); 28 | if themes 29 | .iter() 30 | .filter(|t| t.to_uppercase() == theme.to_uppercase()) 31 | .next() 32 | .is_none() 33 | { 34 | println!("cargo:warning=Jolly default icon theme '{}' does not seem to be installed. You can override the default theme via environment variable JOLLY_DEFAULT_THEME", theme); 35 | } 36 | 37 | // check xdg-utils is installed 38 | let path = env::var("PATH").unwrap_or("".into()); 39 | if path 40 | .split(":") 41 | .map(std::path::PathBuf::from) 42 | .find(|p| p.join("xdg-settings").exists()) 43 | .is_none() 44 | { 45 | println!("cargo:warning=package `xdg-utils` does not seem to be installed. Icon support may be broken"); 46 | } 47 | 48 | // check shared-mime-info installed 49 | let mut xdg_data_dirs = env::var("XDG_DATA_DIRS").unwrap_or("".into()); 50 | 51 | if xdg_data_dirs.is_empty() { 52 | xdg_data_dirs = "/usr/local/share/:/usr/share/".into(); 53 | } 54 | 55 | let data_home = dirs::data_dir().unwrap_or("/nonexistant/path".into()); 56 | 57 | if std::iter::once(data_home) 58 | .chain(xdg_data_dirs.split(":").map(std::path::PathBuf::from)) 59 | .find(|p| p.join("mime/mime.cache").exists()) 60 | .is_none() 61 | { 62 | println!("cargo:warning=package `shared-mime-info` does not seem to be installed. Icon support may be broken"); 63 | } 64 | } 65 | 66 | // set a nice icon 67 | #[cfg(windows)] 68 | fn main() { 69 | common(); 70 | 71 | // determine path to save icon to 72 | let out_file = format!("{}/jolly.ico", std::env::var("OUT_DIR").unwrap()); 73 | 74 | // render SVG as PNG 75 | use resvg::usvg::TreeParsing; 76 | let svg_data = std::fs::read("icon/jolly.svg").unwrap(); 77 | let utree = 78 | resvg::usvg::Tree::from_data(&svg_data, &Default::default()).expect("could not parse svg"); 79 | 80 | let icon_size = 256 as u32; 81 | 82 | let mut pixmap = 83 | resvg::tiny_skia::Pixmap::new(icon_size, icon_size).expect("could not create pixmap"); 84 | 85 | let rtree = resvg::Tree::from_usvg(&utree); 86 | 87 | // we have non-square svg 88 | assert_eq!( 89 | rtree.size.width(), 90 | rtree.size.height(), 91 | "Jolly Icon not square" 92 | ); 93 | 94 | let scalefactor = icon_size as f32 / rtree.size.width(); 95 | let transform = resvg::tiny_skia::Transform::from_scale(scalefactor, scalefactor); 96 | 97 | rtree.render(transform, &mut pixmap.as_mut()); 98 | let bytes = pixmap.encode_png().unwrap(); 99 | 100 | // Create a new, empty icon collection: 101 | let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); 102 | // Read a PNG file from disk and add it to the collection: 103 | let image = ico::IconImage::read_png(bytes.as_slice()).unwrap(); 104 | icon_dir.add_entry(ico::IconDirEntry::encode(&image).unwrap()); 105 | // Finally, write the ICO file to disk: 106 | let file = std::fs::File::create(&out_file).unwrap(); 107 | icon_dir.write(file).unwrap(); 108 | 109 | // attach icon to resources for this executable 110 | let mut res = winres::WindowsResource::new(); 111 | res.set_icon(&out_file); 112 | res.compile().unwrap(); 113 | } 114 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # Root options 13 | 14 | # If 1 or more target triples (and optionally, target_features) are specified, 15 | # only the specified targets will be checked when running `cargo deny check`. 16 | # This means, if a particular package is only ever used as a target specific 17 | # dependency, such as, for example, the `nix` crate only being used via the 18 | # `target_family = "unix"` configuration, that only having windows targets in 19 | # this list would mean the nix crate, as well as any of its exclusive 20 | # dependencies not shared by any other crates, would be ignored, as the target 21 | # list here is effectively saying which targets you are building for. 22 | targets = [ 23 | # The triple can be any string, but only the target triples built in to 24 | # rustc (as of 1.40) can be checked against actual config expressions 25 | #{ triple = "x86_64-unknown-linux-musl" }, 26 | # You can also specify which target_features you promise are enabled for a 27 | # particular target. target_features are currently not validated against 28 | # the actual valid features supported by the target architecture. 29 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 30 | ] 31 | # When creating the dependency graph used as the source of truth when checks are 32 | # executed, this field can be used to prune crates from the graph, removing them 33 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 34 | # is pruned from the graph, all of its dependencies will also be pruned unless 35 | # they are connected to another crate in the graph that hasn't been pruned, 36 | # so it should be used with care. The identifiers are [Package ID Specifications] 37 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 38 | #exclude = [] 39 | # If true, metadata will be collected with `--all-features`. Note that this can't 40 | # be toggled off if true, if you want to conditionally enable `--all-features` it 41 | # is recommended to pass `--all-features` on the cmd line instead 42 | all-features = false 43 | # If true, metadata will be collected with `--no-default-features`. The same 44 | # caveat with `all-features` applies 45 | no-default-features = false 46 | # If set, these feature will be enabled when collecting metadata. If `--features` 47 | # is specified on the cmd line they will take precedence over this option. 48 | #features = [] 49 | # When outputting inclusion graphs in diagnostics that include features, this 50 | # option can be used to specify the depth at which feature edges will be added. 51 | # This option is included since the graphs can be quite large and the addition 52 | # of features from the crate(s) to all of the graph roots can be far too verbose. 53 | # This option can be overridden via `--feature-depth` on the cmd line 54 | feature-depth = 1 55 | 56 | # This section is considered when running `cargo deny check advisories` 57 | # More documentation for the advisories section can be found here: 58 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 59 | [advisories] 60 | # The path where the advisory database is cloned/fetched into 61 | db-path = "~/.cargo/advisory-db" 62 | # The url(s) of the advisory databases to use 63 | db-urls = ["https://github.com/rustsec/advisory-db"] 64 | # The lint level for security vulnerabilities 65 | vulnerability = "deny" 66 | # The lint level for unmaintained crates 67 | unmaintained = "warn" 68 | # The lint level for crates that have been yanked from their source registry 69 | yanked = "warn" 70 | # The lint level for crates with security notices. Note that as of 71 | # 2019-12-17 there are no security notice advisories in 72 | # https://github.com/rustsec/advisory-db 73 | notice = "warn" 74 | # A list of advisory IDs to ignore. Note that ignored advisories will still 75 | # output a note when they are encountered. 76 | ignore = [ 77 | #"RUSTSEC-0000-0000", 78 | ] 79 | # Threshold for security vulnerabilities, any vulnerability with a CVSS score 80 | # lower than the range specified will be ignored. Note that ignored advisories 81 | # will still output a note when they are encountered. 82 | # * None - CVSS Score 0.0 83 | # * Low - CVSS Score 0.1 - 3.9 84 | # * Medium - CVSS Score 4.0 - 6.9 85 | # * High - CVSS Score 7.0 - 8.9 86 | # * Critical - CVSS Score 9.0 - 10.0 87 | #severity-threshold = 88 | 89 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 90 | # If this is false, then it uses a built-in git library. 91 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 92 | # See Git Authentication for more information about setting up git authentication. 93 | #git-fetch-with-cli = true 94 | 95 | # This section is considered when running `cargo deny check licenses` 96 | # More documentation for the licenses section can be found here: 97 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 98 | [licenses] 99 | # The lint level for crates which do not have a detectable license 100 | unlicensed = "deny" 101 | # List of explicitly allowed licenses 102 | # See https://spdx.org/licenses/ for list of possible licenses 103 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 104 | allow = [ 105 | "MIT", 106 | "Apache-2.0", 107 | "Unicode-DFS-2016", 108 | "MPL-2.0", 109 | "BSD-3-Clause", 110 | "BSL-1.0", 111 | "Zlib", 112 | "BSD-2-Clause", 113 | "ISC", 114 | "OFL-1.1", 115 | "CC0-1.0", 116 | #"Apache-2.0 WITH LLVM-exception", 117 | ] 118 | # List of explicitly disallowed licenses 119 | # See https://spdx.org/licenses/ for list of possible licenses 120 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 121 | deny = [ 122 | #"Nokia", 123 | ] 124 | # Lint level for licenses considered copyleft 125 | copyleft = "warn" 126 | # Blanket approval or denial for OSI-approved or FSF Free/Libre licenses 127 | # * both - The license will be approved if it is both OSI-approved *AND* FSF 128 | # * either - The license will be approved if it is either OSI-approved *OR* FSF 129 | # * osi - The license will be approved if it is OSI approved 130 | # * fsf - The license will be approved if it is FSF Free 131 | # * osi-only - The license will be approved if it is OSI-approved *AND NOT* FSF 132 | # * fsf-only - The license will be approved if it is FSF *AND NOT* OSI-approved 133 | # * neither - This predicate is ignored and the default lint level is used 134 | allow-osi-fsf-free = "neither" 135 | # Lint level used when no other predicates are matched 136 | # 1. License isn't in the allow or deny lists 137 | # 2. License isn't copyleft 138 | # 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" 139 | default = "deny" 140 | # The confidence threshold for detecting a license from license text. 141 | # The higher the value, the more closely the license text must be to the 142 | # canonical license text of a valid SPDX license file. 143 | # [possible values: any between 0.0 and 1.0]. 144 | confidence-threshold = 0.8 145 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 146 | # aren't accepted for every possible crate as with the normal allow list 147 | exceptions = [ 148 | # Each entry is the crate and version constraint, and its specific allow 149 | # list 150 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 151 | ] 152 | 153 | # Some crates don't have (easily) machine readable licensing information, 154 | # adding a clarification entry for it allows you to manually specify the 155 | # licensing information 156 | #[[licenses.clarify]] 157 | # The name of the crate the clarification applies to 158 | #name = "ring" 159 | # The optional version constraint for the crate 160 | #version = "*" 161 | # The SPDX expression for the license requirements of the crate 162 | #expression = "MIT AND ISC AND OpenSSL" 163 | # One or more files in the crate's source used as the "source of truth" for 164 | # the license expression. If the contents match, the clarification will be used 165 | # when running the license check, otherwise the clarification will be ignored 166 | # and the crate will be checked normally, which may produce warnings or errors 167 | # depending on the rest of your configuration 168 | #license-files = [ 169 | # Each entry is a crate relative path, and the (opaque) hash of its contents 170 | #{ path = "LICENSE", hash = 0xbd0eed23 } 171 | #] 172 | 173 | [licenses.private] 174 | # If true, ignores workspace crates that aren't published, or are only 175 | # published to private registries. 176 | # To see how to mark a crate as unpublished (to the official registry), 177 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 178 | ignore = false 179 | # One or more private registries that you might publish crates to, if a crate 180 | # is only published to private registries, and ignore is true, the crate will 181 | # not have its license(s) checked 182 | registries = [ 183 | #"https://sekretz.com/registry 184 | ] 185 | 186 | # This section is considered when running `cargo deny check bans`. 187 | # More documentation about the 'bans' section can be found here: 188 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 189 | [bans] 190 | # Lint level for when multiple versions of the same crate are detected 191 | multiple-versions = "warn" 192 | # Lint level for when a crate version requirement is `*` 193 | wildcards = "allow" 194 | # The graph highlighting used when creating dotgraphs for crates 195 | # with multiple versions 196 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 197 | # * simplest-path - The path to the version with the fewest edges is highlighted 198 | # * all - Both lowest-version and simplest-path are used 199 | highlight = "all" 200 | # The default lint level for `default` features for crates that are members of 201 | # the workspace that is being checked. This can be overridden by allowing/denying 202 | # `default` on a crate-by-crate basis if desired. 203 | workspace-default-features = "allow" 204 | # The default lint level for `default` features for external crates that are not 205 | # members of the workspace. This can be overridden by allowing/denying `default` 206 | # on a crate-by-crate basis if desired. 207 | external-default-features = "allow" 208 | # List of crates that are allowed. Use with care! 209 | allow = [ 210 | #{ name = "ansi_term", version = "=0.11.0" }, 211 | ] 212 | # List of crates to deny 213 | deny = [ 214 | # Each entry the name of a crate and a version range. If version is 215 | # not specified, all versions will be matched. 216 | #{ name = "ansi_term", version = "=0.11.0" }, 217 | # 218 | # Wrapper crates can optionally be specified to allow the crate when it 219 | # is a direct dependency of the otherwise banned crate 220 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 221 | ] 222 | 223 | # List of features to allow/deny 224 | # Each entry the name of a crate and a version range. If version is 225 | # not specified, all versions will be matched. 226 | #[[bans.features]] 227 | #name = "reqwest" 228 | # Features to not allow 229 | #deny = ["json"] 230 | # Features to allow 231 | #allow = [ 232 | # "rustls", 233 | # "__rustls", 234 | # "__tls", 235 | # "hyper-rustls", 236 | # "rustls", 237 | # "rustls-pemfile", 238 | # "rustls-tls-webpki-roots", 239 | # "tokio-rustls", 240 | # "webpki-roots", 241 | #] 242 | # If true, the allowed features must exactly match the enabled feature set. If 243 | # this is set there is no point setting `deny` 244 | #exact = true 245 | 246 | # Certain crates/versions that will be skipped when doing duplicate detection. 247 | skip = [ 248 | #{ name = "ansi_term", version = "=0.11.0" }, 249 | ] 250 | # Similarly to `skip` allows you to skip certain crates during duplicate 251 | # detection. Unlike skip, it also includes the entire tree of transitive 252 | # dependencies starting at the specified crate, up to a certain depth, which is 253 | # by default infinite. 254 | skip-tree = [ 255 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 256 | ] 257 | 258 | # This section is considered when running `cargo deny check sources`. 259 | # More documentation about the 'sources' section can be found here: 260 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 261 | [sources] 262 | # Lint level for what to happen when a crate from a crate registry that is not 263 | # in the allow list is encountered 264 | unknown-registry = "warn" 265 | # Lint level for what to happen when a crate from a git repository that is not 266 | # in the allow list is encountered 267 | unknown-git = "warn" 268 | # List of URLs for allowed crate registries. Defaults to the crates.io index 269 | # if not specified. If it is specified but empty, no registries are allowed. 270 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 271 | # List of URLs for allowed Git repositories 272 | allow-git = [] 273 | 274 | [sources.allow-org] 275 | # 1 or more github.com organizations to allow git sources for 276 | github = [""] 277 | # 1 or more gitlab.com organizations to allow git sources for 278 | gitlab = [""] 279 | # 1 or more bitbucket.org organizations to allow git sources for 280 | bitbucket = [""] 281 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | To use Jolly, simply run the `jolly` executable. 2 | 3 | ```bash 4 | # Run Jolly with jolly.toml in the current directory 5 | jolly 6 | ``` 7 | 8 | To use a config file that is not in the current directory, pass its path on the command line: 9 | 10 | ```bash 11 | # Run Jolly with a custom config file 12 | jolly /path/to/custom/jolly.toml 13 | ``` 14 | 15 | For more details on how Jolly finds its config file, see the 16 | [documentation](file-format.md#locations). 17 | 18 | By default, Jolly won't show any results: just tell you how many entries it has loaded: 19 | 20 | ![startup page](static/startup.png) 21 | 22 | You can search for an entry by typing in text: Jolly will use the 23 | title of the entry and any [tags](file-format.md#tags) associated 24 | with the entry to find results: 25 | 26 | ![startup page](static/basic-search.png) 27 | 28 | To open the entry, you can select it using the arrow and enter keys, 29 | or click it with the mouse. 30 | 31 | To learn more about the file format used by Jolly, see the [file-format](file-format.md) page. 32 | 33 | To learn more about changing settings for Jolly, including how to customize the theme, see the [config](config.md) page. 34 | 35 | To learn more advanced tips and tricks, see the [advanced](advanced.md) usage page. 36 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | Below are a couple of advanced tips and tricks for using Jolly: 2 | 3 | # Troubleshooting 4 | 5 | Don't forget that Jolly can log detailed trace events of its performance using `env_logger`. For more details, see the [logging documentation](config.md#log) 6 | 7 | # Copying Links 8 | 9 | Sometimes you don't need to open a Jolly entry, just determine the location 10 | that that entry points to. 11 | 12 | You can have Jolly copy the entry target to your system clipboard by 13 | holding down the Control key (Command on MacOS) when selecting the 14 | entry: 15 | 16 | ![copying](static/clipboard.png) 17 | 18 | # Entry Ranking Algorithm 19 | 20 | Below are some details about how the Jolly chooses to rank and display entries. 21 | 22 | Searches are presumed to be case insensitive, unless the search query 23 | has an uppercase letter in it, in which case the ranking is done in a 24 | case sensitive manner. 25 | 26 | Each entry in the configuration file is assigned a score based on how 27 | well its title and tags match the search query. 28 | 29 | Ties in score are broken by reverse entry order in the 30 | [jolly.toml](file-format.md) file. That is, the later an entry appears 31 | in the `jolly.toml` file, the higher its appearance in the results 32 | list. This is because we assume that users will add newer entries to 33 | the bottom of the configuration file, and new entries should be ranked 34 | higher than older ones. 35 | 36 | ## Score Calculation 37 | 38 | First, the search query is split into tokens based on [whitespace 39 | boundaries](https://doc.rust-lang.org/std/primitive.str.html#method.split_whitespace). 40 | 41 | Each token of the search query is considered to be an additional 42 | filter on the search results: That is, each token is ANDed together to 43 | only show results that best match all of the search tokens. 44 | 45 | The score for each token is calculated by seeing how well it matches each of the following heuristics: 46 | 47 | | Heuristic Name | Current Weight | Description of Heuristic | 48 | |------------------|----------------|--------------------------------------------------------------| 49 | | FULL_KEYWORD_W | 100 | Does the first token exactly match this entry's keyword tag? | 50 | | PARTIAL_NAME_W | 3 | Does the entry name contain this token? | 51 | | FULL_NAME_W | 10 | Does the entry name match this token? | 52 | | PARTIAL_TAG_W | 2 | Do any of the entry's tags contain this token? | 53 | | STARTSWITH_TAG_W | 4 | Do any of the entry's tags start with this token? | 54 | | FULL_TAG_W | 6 | Do any of the entry's tags match this token? | 55 | 56 | The best score from each of these heuristics is chosen for each token, 57 | and then the minimum score from each token is taken as the overall 58 | score for the entry. 59 | 60 | 61 | Note: the keyword entry heuristic is a special case, since it is only 62 | calculated for the first token. If the entry is a [keyword 63 | entry](file-format.md#keyword), and the first token in the search 64 | query matches the keyword key, the results is assigned a fixed score 65 | `FULL_KEYWORD_W`. 66 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuring Jolly 2 | Configuration settings for Jolly are stored in the main `jolly.toml` 3 | file. 4 | 5 | They are stored in a special table called `[config]`. This means that 6 | `config` cannot be used as a key for an Entry. 7 | 8 | There are many settings in Jolly that are split into sub-tables. 9 | 10 | The subtables are described below in sections. 11 | 12 | Please note that all of these settings are optional. If there are no 13 | keys set, then Jolly will merely load the default configuration. 14 | 15 | Jolly entries are separately described in [file-format.md](file-format.md) 16 | 17 | Below is an example `config` section that could be in `jolly.toml`: 18 | 19 | ```toml 20 | # set some settings for jolly 21 | 22 | [config.ui] 23 | width = 1000 # specify extra wide window 24 | max_results = 7 # allow extra results 25 | 26 | [config.ui.theme] 27 | base = "dark" 28 | accent_color= "orange" 29 | 30 | [config.ui.search] 31 | text_size = 40 # make search window bigger 32 | 33 | [config.log] 34 | file = 'path/to/logfile' 35 | filters = "debug" 36 | 37 | ### Jolly entries below... 38 | 39 | ``` 40 | 41 | 42 | # [config.ui] 43 | the `config.ui` table contains settings that control the appearance of 44 | the Jolly window. 45 | 46 | Below is more detail about the available settings: 47 | 48 | 49 | | field name | data type | description | 50 | |---------------|-----------|--------------------------------| 51 | | `width` | *integer* | width of Jolly Window | 52 | | `theme` | *table* | customize the theme of Jolly | 53 | | `search` | *table* | customize search field | 54 | | `results` | *table* | customize results display | 55 | | `entry` | *table* | customize result entries | 56 | | `text_size` | *integer* | font size for UI. | 57 | | `max_results` | *integer* | max number of results to show. | 58 | | `icon` | *table* | customize the display of icons | 59 | 60 | 61 | 62 | ## `width` — *integer* 63 | 64 | Determines the width of the Jolly window. Defined in virtual units as used by `iced` 65 | 66 | 67 | ## `search` — *table* 68 | 69 | This table contains additional settings. See below for details. 70 | 71 | ## `results` — *table* 72 | 73 | This table contains additional settings. See below for details. 74 | 75 | ## `entry` — *table* 76 | 77 | This table contains additional settings. See below for details. 78 | 79 | ## `text_size` — *integer* 80 | 81 | Specify the font size used for text in the Jolly UI. This is a special 82 | setting parameter, because it also exists in the sub-tables `search` 83 | and `entry`, in case you want to specify uniquely different text sizes for those UI elements 84 | 85 | Default text size is 20. 86 | 87 | ## `max_results` — *integer* 88 | 89 | Specify the maximum number of results to show in the Jolly search results window. 90 | 91 | Defaults to 5 entries. 92 | 93 | 94 | # [config.ui.theme] 95 | 96 | These parameters control the theme of Jolly. Right now, theming 97 | support is pretty basic and only supports setting the following parameters: 98 | 99 | 100 | | field name | data type | description | 101 | |-----------------------|----------------|--------------------------------| 102 | | `base` | *string* | base theme to use | 103 | | `accent_color` | *color string* | color to use as main accent | 104 | | `background_color` | *color string* | color to use for background | 105 | | `text_color` | *color string* | color to use for text | 106 | | `selected_text_color` | *color string* | color to use for selected_text | 107 | 108 | 109 | ## `base` — *'light'|'dark'* 110 | 111 | Determine what base theme to use for Jolly UI. Currently, the only 112 | options are 'dark' and 'light'. 113 | 114 | If this variable is not set, Jolly will attempt to determine if the 115 | current window manager is in a dark or light mode using 116 | [dark-light](https://crates.io/crates/dark-light). 117 | 118 | If `dark-light` is not successful, then the 'light' theme will be used 119 | as a default. 120 | 121 | If any of the other `config.ui.theme` parameters are set, they will 122 | override the base values set by this variable. 123 | 124 | The default theme palette is described below: 125 | 126 | ### 'light' Theme 127 | | field name | value | 128 | |-----------------------|----------------------| 129 | | `accent_color` | *see below* | 130 | | `background_color` | 'white' | 131 | | `text_color` | 'black' | 132 | | `selected_text_color` | 'white' | 133 | 134 | ### 'dark' Theme 135 | | field name | value | 136 | |-----------------------|----------------------| 137 | | `accent_color` | *see below* | 138 | | `background_color` | '#202225 | 139 | | `text_color` | '#B3B3B3' | 140 | | `selected_text_color` | 'black | 141 | 142 | 143 | 144 | ## `accent_color` — *color string* 145 | 146 | Specify the accent color to use for the Jolly interface. 147 | 148 | This parameter is a string, but it is interpreted as an HTML color 149 | using [csscolorparser](https://crates.io/crates/csscolorparser). This 150 | means that [HTML named 151 | colors](https://www.w3.org/TR/css-color-4/#named-colors) as well as 152 | RGB values can be used to specify the accent color. 153 | 154 | If the `accent_color` is left unspecified, then the behavior is platform specific: 155 | 156 | | Platform | Behavior | 157 | |---------------------|-----------------------------------------| 158 | | Windows | Uses `UIColorType::Accent` if available | 159 | | All other Platforms | Uses default `iced` palette: '#5E7CE2' | 160 | 161 | 162 | ## `background_color` — *color string* 163 | 164 | Specify the background color to use for the Jolly interface. 165 | 166 | This parameter is a string, but it is interpreted as an HTML color 167 | using [csscolorparser](https://crates.io/crates/csscolorparser). This 168 | means that [HTML named 169 | colors](https://www.w3.org/TR/css-color-4/#named-colors) as well as 170 | RGB values can be used to specify the accent color. 171 | 172 | If the `background_color` is left unspecified, then Jolly will use the 173 | color specified by the `base` theme. 174 | 175 | ## `text_color` — *color string* 176 | 177 | Specify the text color to use for the Jolly interface. 178 | 179 | This parameter is a string, but it is interpreted as an HTML color 180 | using [csscolorparser](https://crates.io/crates/csscolorparser). This 181 | means that [HTML named 182 | colors](https://www.w3.org/TR/css-color-4/#named-colors) as well as 183 | RGB values can be used to specify the accent color. 184 | 185 | If the `text_color` is left unspecified, then Jolly will use the 186 | color specified by the `base` theme. 187 | 188 | ## `selected_text_color` — *color string* 189 | 190 | Specify the text color to use for the current selected Jolly Entry. 191 | 192 | When using a custom `accent_color` value, it maybe necessary to tweak 193 | this color to have enough contrast between the text and its 194 | background. 195 | 196 | This parameter is a string, but it is interpreted as an HTML color 197 | using [csscolorparser](https://crates.io/crates/csscolorparser). This 198 | means that [HTML named 199 | colors](https://www.w3.org/TR/css-color-4/#named-colors) as well as 200 | RGB values can be used to specify the accent color. 201 | 202 | If the `text_color` is left unspecified, then Jolly will use the 203 | color specified by the `base` theme. 204 | 205 | # [config.ui.search] 206 | 207 | This table contains settings that control the search text window. 208 | 209 | Currently it only has one setting: 210 | 211 | | field name | data type | description | 212 | |----------------|----------------|-----------------------------| 213 | | `text_size` | *integer* | font size for UI. | 214 | 215 | 216 | ## `text_size` — *integer* 217 | 218 | Specify the font size used for text used by the search window. If the 219 | key `config.ui.text_size` is already set, this key will override the 220 | size only for the search text window. 221 | 222 | Default text size is 20. 223 | 224 | # [config.ui.entry] 225 | 226 | This table contains settings that control the entry results window 227 | 228 | Currently it only has one setting: 229 | 230 | | field name | data type | description | 231 | |----------------|----------------|-----------------------------| 232 | | `text_size` | *integer* | font size for UI. | 233 | 234 | 235 | ## `text_size` — *integer* 236 | 237 | Specify the font size used for text used by each entry result. If the 238 | key `config.ui.text_size` is already set, this key will override the 239 | size only for the entry results. 240 | 241 | Default text size is 20. 242 | 243 | # [config.ui.icon] 244 | 245 | *Only valid for Linux and BSD platforms* 246 | 247 | This table contains settings for customizing how icons are displayed in Jolly. 248 | 249 | Currently there is only one field available: 250 | 251 | | field name | data type | description | 252 | |------------|-----------|--------------------------------------| 253 | | `theme` | *string* | icon theme to use (Freedesktop only) | 254 | 255 | ## `theme` — *string* 256 | 257 | The value of this option should be the name of a Freedesktop icon 258 | theme to use on Linux and BSD platforms. There is no standard way to 259 | specify which icon theme the user is using, so they should specify it 260 | using this option. If this option is not set, then Jolly will use a 261 | compile-time default, currently the `"gnome"` theme. If this theme is 262 | not installed, then a fallback blank grey icon will be used for all 263 | icons. 264 | 265 | If you would like to change the compile time default theme, you can 266 | use the environment variable `JOLLY_DEFAULT_THEME`. 267 | 268 | For example, to build jolly with a default theme of "Adwaita": 269 | 270 | ``` 271 | JOLLY_DEFAULT_THEME=Adwaita cargo build 272 | ``` 273 | 274 | As a general rule, the jolly build script will warn if the 275 | `JOLLY_DEFAULT_THEME` doesn't seem to be installed at compile time. 276 | 277 | # [config.log] 278 | 279 | The `[config.log]` table contains settings that control error logging 280 | and debugging of Jolly. By default, Jolly does not perform any 281 | logging, but this behavior can be customized. 282 | 283 | **Important Note** *When you are trying to troubleshoot a bug in 284 | Jolly, you may be asked to supply logfiles. Please be aware that at 285 | higher log levels, Jolly will include the Jolly entry targets, and 286 | whichever text was entered in the search window. This may be 287 | considered sensitive information and you should always review logs 288 | before sharing them.* 289 | 290 | To customize behavior, use the following fields: 291 | 292 | | field name | data type | description | 293 | |------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------| 294 | | `file` | *string* | Filename to write logs to. Always appends. Can be `'stdout'` or `'stderr'` to write to that stream | 295 | | `filters` | *string* OR *string array* | [env_logger](https://docs.rs/env_logger/latest/env_logger/index.html#enabling-logging) filters for logging, by default, only log `error` | 296 | 297 | 298 | As an example log file configuration, consider the following snippet: 299 | 300 | ```toml 301 | [config.log] 302 | 303 | # Jolly logs are stored in log file below 304 | file = 'path/to/logfile' 305 | 306 | # messages from jolly crate at debug level, and cosmic_text crate at trace level 307 | filters = ["jolly=debug", "cosmic_text=trace"] 308 | 309 | ``` 310 | 311 | 312 | ## `file` — *string* 313 | 314 | Specify a filename for Jolly to write logs to. If the file cannot be 315 | accessed then an error is returned. Jolly will always append to this 316 | file if it already exists. 317 | 318 | Jolly treats a file named `'stderr'` `'stdout'` as special: If file is 319 | set to one of these values, it will write to the corresponding stream. 320 | 321 | 322 | ## `filters` — *string* OR *string array* 323 | 324 | The `filters` key can be used to specify one or more 325 | [env_logger](https://docs.rs/env_logger/latest/env_logger/index.html#enabling-logging) 326 | filters. These filters are used to determine which log level is 327 | set. By default, only `errors` are logged, which also generally would 328 | appear in the UI. 329 | 330 | -------------------------------------------------------------------------------- /docs/file-format.md: -------------------------------------------------------------------------------- 1 | # Jolly File Format 2 | 3 | 4 | Jolly expects there to be a single file that contains database 5 | entries, named `jolly.toml`. This file must have entries encoded using 6 | the [TOML](https://toml.io) markup language. 7 | 8 | You can find out more details about the syntax of TOML on the above 9 | webpage, but the basics are defined below. 10 | 11 | ## Config File Locations 12 | 13 | Jolly searches for a config file in the following locations: 14 | 15 | 1. A custom file location specified via the command line, such as `jolly /path/to/custom/jolly.toml` 16 | 2. A file named `jolly.toml` in the current working directory 17 | 3. A file named `jolly.toml` in the *config directory* 18 | 19 | *config directory* is defined with different paths depending on the platform: 20 | 21 | | Platform | Value | Example | 22 | |----------|---------------------------------------|------------------------------------------| 23 | | Linux | `$XDG_CONFIG_HOME` or `$HOME`/.config | /home/alice/.config | 24 | | macOS | `$HOME`/Library/Application Support | /Users/Alice/Library/Application Support | 25 | | Windows | `{FOLDERID_LocalAppData}` | C:\Users\Alice\AppData\Local | 26 | 27 | If a `jolly.toml` config file cannot be located, Jolly will show an error message and exit. 28 | 29 | ## Example Config 30 | 31 | For the purposes of this example, we will refer to an example `jolly.toml` file located in this documentation (you can find a full version of the file [here](jolly.toml)): 32 | 33 | ```toml 34 | # Example Jolly File 35 | ['Edit Jolly Configuration'] 36 | location = 'jolly.toml' 37 | description = "use your system's default program to open this jolly database" 38 | 39 | ['Jolly Quick Start Guide'] 40 | location = 'https://github.com/apgoetz/jolly/tree/main/docs' 41 | desc = "Open the Jolly manual in your web browser" 42 | 43 | ['(W)ikipedia: %s'] 44 | url = 'https://en.wikipedia.org/w/index.php?title=Special:Search&search=%s' 45 | keyword = 'w' 46 | desc = "Search Wikipedia" 47 | 48 | ['Open Calculator'] 49 | system = 'calc.exe' 50 | tags = ['math', 'work'] 51 | desc = """Open the calculator app. This example uses Window's 52 | calc.exe, but for other OS's you may need change this entry. 53 | 54 | For example, on Debian, you can use 'gnome-calculator' and on MacOs you can use 55 | '/System/Applications/Calculator.app/Contents/MacOS/Calculator' 56 | """ 57 | 58 | ['Send email to important recipient'] 59 | location = 'mailto:noreply@example.com' 60 | tags = ['email'] 61 | desc = """Jolly entries don't just have to be web urls. 62 | Any protocol handler supported by your OS can be an entry. """ 63 | ``` 64 | 65 | ## Jolly Entries 66 | 67 | 68 | A jolly entry at its most basic is composed of a *name* and an *entry 69 | target*. The *name* represents a userfriendly title of the entry, 70 | whereas the *entry target* represents the description of how jolly can access that 71 | entry. 72 | 73 | Each entry can also have an optional [description](#desc) that provides more detailed information about the entry. 74 | 75 | Each entry can also have an optional [icon](#icon) field, which allows overriding the icon image to use for that entry. 76 | 77 | Jolly treats each table in the TOML file as its own entry, and the key of the table is treated as its *name*. 78 | 79 | The *entry target* of an entry is specified using a special key in the TOML table. The various types of *entry targets* are described below. 80 | 81 | For example, in the following entry: 82 | 83 | ```toml 84 | ['Edit Jolly Configuration'] 85 | location = 'jolly.toml' 86 | ``` 87 | 88 | The *name* of the entry would be "Edit Jolly Configuration". This is 89 | the text that would be displayed to the user if the entry is selected. 90 | 91 | The *entry target* in this case would be of type `location`, and points to a local file called `jolly.toml`. 92 | 93 | 94 | **Important Note** *It is best practice to surround the entry *name* 95 | in single quotes in your toml file. This is because if your entry name 96 | contains a dot (.) the TOML parser will interpret the entry as a 97 | hierarchical table, which will mess up Jolly's parsing* 98 | 99 | **Important Note** The Settings of Jolly are specified in a special 100 | table called `config` and are described in [config.md](config.md) 101 | 102 | 103 | 104 | ## Tags 105 | 106 | 107 | In order to help with finding entries in a large `jolly.toml` file, Jolly supports using *tags* to provide more description about an entry. 108 | 109 | Tags are specified in a [TOML array](https://toml.io/en/v1.0.0#array) in the table entry. 110 | 111 | For example: 112 | 113 | ```toml 114 | ['Open Calculator'] 115 | system = 'calc.exe' 116 | tags = ['math', 'work'] 117 | ``` 118 | 119 | In this entry, the *tags* are `math` and `work`. The user could search 120 | for this entry using any of the phrases 'open', 'math', 'work', and 121 | see the entry selected. 122 | 123 | ## Description 124 | 125 | The *description* field can be used to provide an additional 126 | description of the Entry. This field is optional, but if it is 127 | present, the description text will be displyed under the entry in the 128 | results. 129 | 130 | This field is also aliased as *desc*, so either of the following will work: 131 | 132 | ```toml 133 | ['Edit Jolly Configuration'] 134 | description = "Use your system's default program to open this Jolly database" 135 | location = 'jolly.toml' 136 | ``` 137 | 138 | ```toml 139 | ['Edit Jolly Configuration'] 140 | desc = "Use your system's default program to open this Jolly database" 141 | location = 'jolly.toml' 142 | ``` 143 | 144 | *Description* fields can be multiple lines. In this case, line breaks 145 | are skipped unless there are two newlines. (Same behavior as markdown 146 | paragraphs). If you try to use any other markdown syntax, Jolly will 147 | render your description as a unformatted text block (with hard newlines). 148 | 149 | Lastly, *description* fields do not support using `%s` as a keyword 150 | parameter, unlike the title field. 151 | 152 | ## Icon 153 | 154 | Jolly entries are displayed with an icon image next to them. The icon 155 | that is displayed is queried with OS-specific APIs, and should be the 156 | same icon that is displayed in the platform's file explorer. If you 157 | would like to override the icon that is chosen to be displayed, you 158 | can use the `icon` field to specify a path to an image to use for that 159 | icon. Jolly uses the [image](https://crates.io/crates/image) crate to 160 | load images, which means that only image formats supported by that 161 | crate can be used as icons with jolly. Additionally, Jolly is built 162 | with SVG support (handled separately). This means that the image type 163 | must be one of the following: 164 | 165 | | Support Image Type | Recognized File Extensions | 166 | |--------------------|----------------------------| 167 | | PNG | .png | 168 | | JPEG | .jpg, .jpeg | 169 | | GIF | .gif | 170 | | WEBP | .webp | 171 | | Netpbm | .pbm, .pam, .ppm, .pgm | 172 | | TIFF | .tiff, .tif | 173 | | TGA | .tga | 174 | | DDS | .dds | 175 | | Bitmap | .bmp | 176 | | Icon | .ico | 177 | | HDR | .hdr | 178 | | OpenEXR | .exr | 179 | | Farbfeld | .ff | 180 | | QOI | .qoi | 181 | | SVG | .svg | 182 | 183 | 184 | ## Jolly Entry Target Types 185 | 186 | 187 | Jolly supports the following types of *entry targets*. to specify an 188 | *entry target* type, create a key with the corresponding name in the 189 | entry table. 190 | 191 | + `location` 192 | + `system` 193 | + `keyword` entries 194 | + `url` 195 | 196 | ### `location` Entry 197 | 198 | 199 | A `location` entry is the most common type of entry for use with Jolly. The target of a location entry will be opened up using your systems default opening program. 200 | 201 | Importantly, this target does not just need to be a file, it can be any URL or URI that your system understands. (This is known as a *protocol handler*) 202 | 203 | For example, to refer to an important PDF document, located in a deep directory: 204 | 205 | ```toml 206 | [My very important file] 207 | location = 'C:\Very\Deep\And\Hard\To\Remember\Directory\Document.PDF' 208 | ``` 209 | 210 | Or you could have a location that points to a website: 211 | 212 | ```toml 213 | ['Jolly Homepage'] 214 | location = 'https://github.com/apgoetz/jolly' 215 | tags = ['docs','jolly'] 216 | ``` 217 | 218 | Or an entry that pops up your mail program to compose a message to a commonly used email address: 219 | 220 | ```toml 221 | ['Complain to Jolly Developers'] 222 | location = 'mailto:example@example.com' 223 | ``` 224 | 225 | The location entry is what makes Jolly powerful, because it inherits every type of protocol handler that your operating system understands. 226 | 227 | Many applications will register their own protocol handlers, which means that you will be able to use these links in Jolly to trigger those applications. For example, Microsoft defines a list of protocol handlers for Office products [here](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes). 228 | 229 | To learn more about protocol handlers on different operating systems, please see the following documentation: 230 | 231 | + [Windows](https://www.howto-connect.com/choose-default-apps-by-protocol-in-windows-10/) 232 | + [MacOS](https://superuser.com/questions/498943/directory-of-url-schemes-for-mac-apps) 233 | + [Linux](https://wiki.archlinux.org/title/XDG_MIME_Applications#Shared_MIME_database) 234 | 235 | 236 | 237 | 238 | ### `system` Entry 239 | 240 | A system entry allows the user to run an arbitrary program using their system's shell. 241 | 242 | For example: 243 | 244 | ```toml 245 | # open system calculator (windows specific) 246 | ['Open Calculator'] 247 | system = 'calc.exe' 248 | ``` 249 | 250 | In this entry, the calculator program will be opened. (This example 251 | works for Windows: for other operating systems you will need to 252 | replace the executable with OS's specific calculator program). 253 | 254 | ### `keyword` Entry 255 | 256 | 257 | `keyword` entries are a little bit different than the other type of 258 | Jolly entries. Instead of pointing to a specific destination, a 259 | keyword entry is allowed to have a parameter which is included in the target. 260 | 261 | This is similar to [keyword 262 | bookmarks](https://www-archive.mozilla.org/docs/end-user/keywords.html) 263 | or [custom search 264 | engines](https://support.google.com/chrome/answer/95426), in a web 265 | browser. 266 | 267 | To specify that a Jolly entry is a `keyword` entry, just include an 268 | extra key in the entry table that describes what shortcut to 269 | use. Then, in the *entry name* or *entry target*, you can use the 270 | string `%s` to indicate where the parameter for the keyword should be 271 | inserted: 272 | 273 | ```toml 274 | ['Search DuckDuckGo: %s'] 275 | location = 'https://duckduckgo.com/?q=%s' 276 | keyword = 'ddg' 277 | escape = true 278 | ``` 279 | 280 | In this example, we can type the text `ddg` into the Jolly search 281 | window, followed by a space, and then whatever is typed afterwards 282 | will be used to search the web using DuckDuckGo. 283 | 284 | You will notice that this entry has another key in it that we haven't 285 | talked about yet: `escape`. By default, Jolly will put whatever text 286 | you type in the window into the `%s` parameter location in the 287 | target. This works okay for some entries, but web urls typically need 288 | to be [percent 289 | encoded](https://en.wikipedia.org/wiki/Percent-encoding). You can have 290 | Jolly percent-encode the keyword parameter by including the key entry `escape`. 291 | 292 | *Search Order* If the user types a string of text that matches the 293 | shortcut for a `keyword entry`, Jolly will rank this as the most 294 | relevant search result. This will bypass any other entries even if 295 | they have a better score based on tags. For more details, see the 296 | search algorithm documentation. 297 | 298 | ### `url` Entry 299 | 300 | 301 | Syntatic sugar for specifying an `escape = true` `location` entry. 302 | 303 | 304 | A `url` entry is almost exactly like a location entry, except that if 305 | the url is used as a keyword entry, it defaults to having the keyword 306 | parameter be percent encoded. If you want this entry to be a keyword 307 | entry, and it is pointing at a website, you will generally want to use 308 | a url entry instead of a location entry. 309 | 310 | The previous example of a keyword entry could therefore be written 311 | more compactly like this: 312 | 313 | ```toml 314 | ['Search DuckDuckGo: %s'] 315 | url = 'https://duckduckgo.com/?q=%s' 316 | keyword = 'ddg' 317 | ``` 318 | 319 | 320 | # Errors 321 | Sometimes Jolly will encounter an error can cannot proceed. Usually, 322 | in this situation, the normal Jolly window will still show, but the 323 | search box will be read only, and the text of the error will be shown 324 | in the box. Below are some descriptions of some errors that you might 325 | see. 326 | 327 | ## TOML Error 328 | If the `jolly.toml` file contains a syntax error, and is not a valid 329 | TOML file, then jolly will show an error window instead of the normal startup screen. 330 | 331 | ![toml-error](static/toml-error.png) 332 | 333 | Fix the syntax error in order to proceed. 334 | -------------------------------------------------------------------------------- /docs/jolly.toml: -------------------------------------------------------------------------------- 1 | # Example Jolly File 2 | ['Edit Jolly Configuration'] 3 | location = 'jolly.toml' 4 | description = "use your system's default program to open this jolly database" 5 | 6 | ['Jolly Quick Start Guide'] 7 | location = 'https://github.com/apgoetz/jolly/tree/main/docs' 8 | desc = "Open the Jolly manual in your web browser" 9 | 10 | ['(W)ikipedia: %s'] 11 | url = 'https://en.wikipedia.org/w/index.php?title=Special:Search&search=%s' 12 | keyword = 'w' 13 | desc = "Search Wikipedia" 14 | 15 | ['Open Calculator'] 16 | system = 'calc.exe' 17 | tags = ['math', 'work'] 18 | desc = """Open the calculator app. This example uses Window's 19 | calc.exe, but for other OS's you may need change this entry. 20 | 21 | For example, on Debian, you can use 'gnome-calculator' and on MacOs you can use 22 | '/System/Applications/Calculator.app/Contents/MacOS/Calculator' 23 | """ 24 | 25 | ['Send email to important recipient'] 26 | location = 'mailto:example@example.com' 27 | tags = ['email'] 28 | desc = """Jolly entries don't just have to be web urls. 29 | Any protocol handler supported by your OS can be an entry. """ -------------------------------------------------------------------------------- /docs/static/basic-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apgoetz/jolly/3e88cf4decec7bd5f23ef5483e106ba24e50a62b/docs/static/basic-search.png -------------------------------------------------------------------------------- /docs/static/clipboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apgoetz/jolly/3e88cf4decec7bd5f23ef5483e106ba24e50a62b/docs/static/clipboard.png -------------------------------------------------------------------------------- /docs/static/startup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apgoetz/jolly/3e88cf4decec7bd5f23ef5483e106ba24e50a62b/docs/static/startup.png -------------------------------------------------------------------------------- /docs/static/toml-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apgoetz/jolly/3e88cf4decec7bd5f23ef5483e106ba24e50a62b/docs/static/toml-error.png -------------------------------------------------------------------------------- /icon/README.md: -------------------------------------------------------------------------------- 1 | # Icon 2 | Jolly icon is CC BY 3.0, originally created by [Daniele De 3 | Santis](https://www.danieledesantis.net/). The original icon was 4 | rotated and scaled for this application. 5 | -------------------------------------------------------------------------------- /icon/jolly.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | // command line parsing for jolly 2 | use std::process::ExitCode; 3 | 4 | fn help() { 5 | let description = env!("CARGO_PKG_DESCRIPTION"); 6 | let exe = option_env!("CARGO_BIN_NAME").unwrap_or("jolly"); 7 | let name = env!("CARGO_PKG_NAME"); 8 | println!( 9 | r#"{description} 10 | 11 | Usage: {exe} [OPTIONS] [CONFIG FILE] 12 | 13 | Options: 14 | -V, --version Print version info and exit 15 | -h, --help Print this help and exit 16 | 17 | Use the optional parameter [CONFIG FILE] to use a non-default config file 18 | 19 | For more details, see the {name} docs: https://github.com/apgoetz/jolly/blob/main/docs/README.md 20 | "# 21 | ); 22 | } 23 | 24 | fn version() { 25 | let version = env!("CARGO_PKG_VERSION"); 26 | let name = env!("CARGO_PKG_NAME"); 27 | let date = env!("JOLLY_BUILD_DATE"); 28 | 29 | println!("{name} {version} {date}"); 30 | } 31 | 32 | fn err_help() { 33 | let name = option_env!("CARGO_BIN_NAME").unwrap_or("jolly"); 34 | eprintln!("Try '{name} --help' for more information"); 35 | } 36 | 37 | #[derive(Default)] 38 | pub struct ParsedArgs { 39 | pub config: Option, 40 | } 41 | 42 | pub fn parse_args>(args: I) -> Result { 43 | let mut parsed_args = ParsedArgs::default(); 44 | 45 | for arg in args.skip(1) { 46 | if arg == "-V" || arg == "-v" || arg == "--version" { 47 | version(); 48 | return Err(ExitCode::SUCCESS); 49 | } 50 | 51 | if arg == "-h" || arg == "--help" { 52 | help(); 53 | return Err(ExitCode::SUCCESS); 54 | } 55 | 56 | if arg.starts_with("-") { 57 | eprintln!("Invalid option '{arg}'"); 58 | err_help(); 59 | return Err(ExitCode::FAILURE); 60 | } 61 | 62 | if parsed_args.config.is_none() { 63 | parsed_args.config = Some(arg) 64 | } else { 65 | eprintln!("Multiple config files passed, only one config file supported at this time"); 66 | err_help(); 67 | return Err(ExitCode::FAILURE); 68 | } 69 | } 70 | Ok(parsed_args) 71 | } 72 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // contains logic for parsing jolly config file. 2 | // a config file consists of settings and a store 3 | // settings are parameters for the program 4 | // store represents the links that are stored in jolly 5 | 6 | use crate::{error::Error, settings::Settings, store::Store}; 7 | use serde::Deserialize; 8 | use std::{fs, path}; 9 | use toml; 10 | 11 | pub const LOGFILE_NAME: &str = "jolly.toml"; 12 | 13 | // helper enum to allow decoding a scalar into a single vec 14 | // original hint from here: 15 | // https://github.com/Mingun/ksc-rs/blob/8532f701e660b07b6d2c74963fdc0490be4fae4b/src/parser.rs#L18-L42 16 | // (MIT LICENSE) 17 | #[derive(Clone, Debug, Deserialize, PartialEq)] 18 | #[serde(untagged)] 19 | enum OneOrMany { 20 | /// Single value 21 | One(T), 22 | /// Array of values 23 | Vec(Vec), 24 | } 25 | impl From> for Vec { 26 | fn from(from: OneOrMany) -> Self { 27 | match from { 28 | OneOrMany::One(val) => vec![val], 29 | OneOrMany::Vec(vec) => vec, 30 | } 31 | } 32 | } 33 | 34 | pub fn one_or_many<'de, T: Deserialize<'de>, D: serde::Deserializer<'de>>( 35 | d: D, 36 | ) -> Result, D::Error> { 37 | OneOrMany::deserialize(d).map(Vec::from) 38 | } 39 | 40 | // represents the data that is loaded from the main configuration file 41 | // it will always have some settings internally, even if the config 42 | // file is not found. if there is an error parsing the config, a 43 | // default value for settings will be used so at least a window will 44 | // show 45 | #[derive(Debug)] 46 | pub struct Config { 47 | pub settings: Settings, 48 | pub store: Result, 49 | } 50 | 51 | impl Default for Config { 52 | fn default() -> Self { 53 | Self { 54 | settings: Settings::default(), 55 | store: Err(Error::CustomError("".to_string())), 56 | } 57 | } 58 | } 59 | 60 | impl Config { 61 | pub fn custom_load(path: String) -> Self { 62 | let config = load_path(path); 63 | match config { 64 | Err(e) => Self { 65 | settings: Default::default(), 66 | store: Err(e), 67 | }, 68 | Ok(c) => c, 69 | } 70 | } 71 | 72 | pub fn load() -> Self { 73 | match get_logfile().map(load_path) { 74 | Ok(config) => config.unwrap_or_else(|e| Self { 75 | settings: Settings::default(), 76 | store: Err(e), 77 | }), 78 | Err(e) => Self { 79 | settings: Settings::default(), 80 | store: Err(e), 81 | }, 82 | } 83 | } 84 | } 85 | 86 | fn get_logfile() -> Result { 87 | let local_path = path::Path::new(LOGFILE_NAME); 88 | if local_path.exists() { 89 | return Ok(local_path.to_path_buf()); 90 | } 91 | 92 | let config_dir = dirs::config_dir().ok_or(Error::CustomError( 93 | "Cannot Determine Config Dir".to_string(), 94 | ))?; 95 | let config_path = config_dir.join(LOGFILE_NAME); 96 | if config_path.exists() { 97 | Ok(config_path) 98 | } else { 99 | Err(Error::CustomError(format!("Cannot find {}", LOGFILE_NAME))) 100 | } 101 | } 102 | 103 | pub fn load_path>(path: P) -> Result { 104 | let txt = fs::read_to_string(&path) 105 | .map_err(|e| Error::IoError(Some(path.as_ref().display().to_string()), e))?; 106 | load_txt(&txt) 107 | .map_err(|e| Error::ContextParseError(path.as_ref().display().to_string(), e.to_string())) 108 | } 109 | 110 | fn load_txt(txt: &str) -> Result { 111 | let value: toml::Value = 112 | toml::from_str(txt).map_err(|e| Error::ParseError(e.message().to_string()))?; 113 | 114 | let mut parsed_config = match value { 115 | toml::Value::Table(t) => t, 116 | _ => return Err(Error::ParseError("entry is not a Table".to_string())), 117 | }; 118 | 119 | // if we have a settings entry use it, otherwise deserialize something empty and rely on serde defaults 120 | 121 | let mut settings = match parsed_config.remove("config") { 122 | Some(config) => { 123 | Settings::deserialize(config).map_err(|e| Error::ParseError(e.message().to_string()))? 124 | } 125 | None => Settings::default(), 126 | }; 127 | 128 | settings.ui.propagate(); 129 | 130 | // get config as table of top level entries 131 | let store = Store::build(parsed_config.into_iter()).map_err(Error::StoreError); 132 | 133 | Ok(Config { settings, store }) 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use super::*; 139 | 140 | #[test] 141 | fn empty_file_is_valid() { 142 | let config = load_txt("").unwrap(); 143 | assert_eq!(config.settings, Settings::default()); 144 | } 145 | 146 | #[test] 147 | fn partial_settings_uses_default() { 148 | let toml = r#"[config] 149 | ui = {width = 42 }"#; 150 | 151 | let config = load_txt(toml).unwrap(); 152 | 153 | assert_eq!( 154 | config.settings.ui.search.padding, 155 | Settings::default().ui.search.padding 156 | ); 157 | assert_ne!(config.settings.ui.width, Settings::default().ui.width); 158 | } 159 | 160 | #[test] 161 | fn invalid_entry_keeps_non_default_settings() { 162 | let toml = r#"a = 1 163 | [config.ui] 164 | width = 42"#; 165 | 166 | let config = load_txt(toml).unwrap(); 167 | 168 | assert_ne!(config.settings, Settings::default()); 169 | assert!(matches!(config.store, Err(Error::StoreError(_)))); 170 | } 171 | 172 | #[test] 173 | fn extraneous_setting_allowed() { 174 | let toml = r#"[config] 175 | not_a_real_setting = 42"#; 176 | 177 | let config = load_txt(toml).unwrap(); 178 | 179 | assert_eq!(config.settings, Settings::default()); 180 | } 181 | 182 | #[test] 183 | fn nonexistent_path() { 184 | let result = load_path("nonexistentfile.toml"); 185 | assert!(matches!(result, Err(Error::IoError(_, _)))); 186 | } 187 | 188 | #[test] 189 | fn child_settings_override() { 190 | let toml = r#"[config.ui.search] 191 | text_size = 42"#; 192 | 193 | let settings = load_txt(toml).unwrap().settings; 194 | 195 | assert_eq!(settings.ui.common, Default::default()); 196 | 197 | assert_eq!(settings.ui.entry, Default::default()); 198 | 199 | assert_ne!(settings.ui.search, Default::default()); 200 | } 201 | 202 | #[test] 203 | fn parent_settings_inherit() { 204 | let toml = r#"[config.ui] 205 | text_size = 42"#; 206 | 207 | let settings = load_txt(toml).unwrap().settings; 208 | 209 | assert_ne!(settings.ui.common, Default::default()); 210 | 211 | assert_ne!(settings.ui.entry, Default::default()); 212 | 213 | assert_ne!(settings.ui.search, Default::default()); 214 | } 215 | 216 | #[test] 217 | fn test_one_or_many() { 218 | use super::one_or_many; 219 | use serde::de::value::{MapDeserializer, SeqDeserializer, UnitDeserializer}; 220 | 221 | let _: Vec<()> = one_or_many(UnitDeserializer::::new()).unwrap(); 222 | 223 | let seq_de = SeqDeserializer::<_, serde::de::value::Error>::new(std::iter::once(())); 224 | one_or_many::, _>(seq_de).unwrap(); 225 | 226 | let map_de = 227 | MapDeserializer::<_, serde::de::value::Error>::new(std::iter::once(("a", "b"))); 228 | one_or_many::, _>(map_de).unwrap_err(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/custom/measured_container.rs: -------------------------------------------------------------------------------- 1 | //! Container that determines the min size of its content, and returns 2 | //! it to the application to allow it to adjust window size 3 | 4 | use iced::event; 5 | 6 | use iced::advanced::widget::{tree, Operation, Tree}; 7 | use iced::advanced::{self, overlay}; 8 | use iced::advanced::{layout, renderer, Clipboard, Layout, Shell, Widget}; 9 | use iced::mouse; 10 | use iced::{Element, Event, Length, Rectangle, Size}; 11 | 12 | pub struct MeasuredContainer<'a, Message, Renderer, F> 13 | where 14 | F: 'static + Copy + Fn(f32, f32) -> Message, 15 | { 16 | content: Element<'a, Message, Renderer>, 17 | msg_builder: F, 18 | } 19 | 20 | impl<'a, Message, Renderer, F> MeasuredContainer<'a, Message, Renderer, F> 21 | where 22 | F: 'static + Copy + Fn(f32, f32) -> Message, 23 | { 24 | /// Creates a [`MeasuredContainer`] with the given content. 25 | pub fn new(content: impl Into>, callback: F) -> Self { 26 | MeasuredContainer { 27 | content: content.into(), 28 | msg_builder: callback, 29 | } 30 | } 31 | } 32 | 33 | #[derive(Default)] 34 | struct State(Option); // no state 35 | 36 | impl<'a, Message, Renderer, F> Widget 37 | for MeasuredContainer<'a, Message, Renderer, F> 38 | where 39 | Renderer: advanced::Renderer, 40 | Message: Clone, 41 | F: 'static + Copy + Fn(f32, f32) -> Message, 42 | { 43 | fn tag(&self) -> tree::Tag { 44 | tree::Tag::of::() 45 | } 46 | 47 | fn state(&self) -> tree::State { 48 | tree::State::new(State::default()) 49 | } 50 | 51 | fn children(&self) -> Vec { 52 | vec![Tree::new(&self.content)] 53 | } 54 | 55 | fn diff(&self, tree: &mut Tree) { 56 | tree.diff_children(std::slice::from_ref(&self.content)); 57 | } 58 | 59 | fn width(&self) -> Length { 60 | self.content.as_widget().width() 61 | } 62 | 63 | fn height(&self) -> Length { 64 | self.content.as_widget().height() 65 | } 66 | 67 | fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { 68 | self.content.as_widget().layout(renderer, limits) 69 | } 70 | 71 | fn operate( 72 | &self, 73 | tree: &mut Tree, 74 | layout: Layout<'_>, 75 | renderer: &Renderer, 76 | operation: &mut dyn Operation, 77 | ) { 78 | self.content 79 | .as_widget() 80 | .operate(&mut tree.children[0], layout, renderer, operation); 81 | } 82 | 83 | // when we receive events, we check to see if the minimum layout 84 | // of the content matches the current window dimensions. If it disagrees, we inform the main application 85 | fn on_event( 86 | &mut self, 87 | tree: &mut Tree, 88 | event: Event, 89 | layout: Layout<'_>, 90 | cursor_position: mouse::Cursor, 91 | renderer: &Renderer, 92 | clipboard: &mut dyn Clipboard, 93 | shell: &mut Shell<'_, Message>, 94 | viewport: &Rectangle, 95 | ) -> event::Status { 96 | let state: &mut State = tree.state.downcast_mut(); 97 | 98 | let (orig_width, orig_height) = match state.0 { 99 | None => (layout.bounds().width, layout.bounds().height), 100 | Some(r) => (r.width, r.height), 101 | }; 102 | 103 | let limits = layout::Limits::new(Size::ZERO, Size::new(orig_width, f32::INFINITY)); 104 | 105 | let bounds = self.layout(renderer, &limits).bounds(); 106 | let new_width = bounds.width; 107 | let new_height = bounds.height; 108 | 109 | if new_height != orig_height || new_width != orig_width { 110 | shell.publish((self.msg_builder)(new_width, new_height)); 111 | state.0 = Some(bounds); 112 | } 113 | 114 | if let event::Status::Captured = self.content.as_widget_mut().on_event( 115 | &mut tree.children[0], 116 | event.clone(), 117 | layout, 118 | cursor_position, 119 | renderer, 120 | clipboard, 121 | shell, 122 | viewport, 123 | ) { 124 | return event::Status::Captured; 125 | } 126 | event::Status::Ignored 127 | } 128 | 129 | fn mouse_interaction( 130 | &self, 131 | tree: &Tree, 132 | layout: Layout<'_>, 133 | cursor_position: mouse::Cursor, 134 | viewport: &Rectangle, 135 | renderer: &Renderer, 136 | ) -> mouse::Interaction { 137 | self.content.as_widget().mouse_interaction( 138 | &tree.children[0], 139 | layout, 140 | cursor_position, 141 | viewport, 142 | renderer, 143 | ) 144 | } 145 | 146 | fn draw( 147 | &self, 148 | tree: &Tree, 149 | renderer: &mut Renderer, 150 | theme: &Renderer::Theme, 151 | renderer_style: &renderer::Style, 152 | layout: Layout<'_>, 153 | cursor_position: mouse::Cursor, 154 | viewport: &Rectangle, 155 | ) { 156 | self.content.as_widget().draw( 157 | &tree.children[0], 158 | renderer, 159 | theme, 160 | renderer_style, 161 | layout, 162 | cursor_position, 163 | viewport, 164 | ); 165 | } 166 | 167 | fn overlay<'b>( 168 | &'b mut self, 169 | tree: &'b mut Tree, 170 | layout: Layout<'_>, 171 | renderer: &Renderer, 172 | ) -> Option> { 173 | self.content 174 | .as_widget_mut() 175 | .overlay(&mut tree.children[0], layout, renderer) 176 | } 177 | } 178 | 179 | impl<'a, Message, Renderer, F> From> 180 | for Element<'a, Message, Renderer> 181 | where 182 | Message: 'a + Clone, 183 | Renderer: 'a + advanced::Renderer, 184 | F: 'static + Copy + Fn(f32, f32) -> Message, 185 | { 186 | fn from(area: MeasuredContainer<'a, Message, Renderer, F>) -> Element<'a, Message, Renderer> { 187 | Element::new(area) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/custom/mod.rs: -------------------------------------------------------------------------------- 1 | // Custom widgets 2 | 3 | mod measured_container; 4 | pub use measured_container::MeasuredContainer; 5 | 6 | mod mouse_area; 7 | pub use mouse_area::MouseArea; 8 | -------------------------------------------------------------------------------- /src/custom/mouse_area.rs: -------------------------------------------------------------------------------- 1 | //! the iced mouse_area with hover support 2 | //! A container for capturing mouse events. 3 | 4 | use iced::advanced::widget::{tree, Operation, Tree}; 5 | use iced::advanced::{layout, mouse, overlay, renderer}; 6 | use iced::advanced::{Clipboard, Layout, Shell, Widget}; 7 | use iced::event::{self, Event}; 8 | use iced::{touch, Element, Length, Rectangle}; 9 | 10 | /// Emit messages on mouse events. 11 | #[allow(missing_debug_implementations)] 12 | pub struct MouseArea<'a, Message, Renderer> { 13 | content: Element<'a, Message, Renderer>, 14 | on_press: Option, 15 | on_release: Option, 16 | on_right_press: Option, 17 | on_right_release: Option, 18 | on_middle_press: Option, 19 | on_middle_release: Option, 20 | on_mouse_enter: Option, 21 | on_mouse_exit: Option, 22 | } 23 | 24 | impl<'a, Message, Renderer> MouseArea<'a, Message, Renderer> { 25 | /// The message to emit on a left button press. 26 | #[must_use] 27 | pub fn on_press(mut self, message: Message) -> Self { 28 | self.on_press = Some(message); 29 | self 30 | } 31 | 32 | /// The message to emit on a left button release. 33 | #[must_use] 34 | pub fn on_release(mut self, message: Message) -> Self { 35 | self.on_release = Some(message); 36 | self 37 | } 38 | 39 | /// The message to emit on a right button press. 40 | #[must_use] 41 | pub fn on_right_press(mut self, message: Message) -> Self { 42 | self.on_right_press = Some(message); 43 | self 44 | } 45 | 46 | /// The message to emit on a right button release. 47 | #[must_use] 48 | pub fn on_right_release(mut self, message: Message) -> Self { 49 | self.on_right_release = Some(message); 50 | self 51 | } 52 | 53 | /// The message to emit on a middle button press. 54 | #[must_use] 55 | pub fn on_middle_press(mut self, message: Message) -> Self { 56 | self.on_middle_press = Some(message); 57 | self 58 | } 59 | 60 | /// The message to emit on a middle button release. 61 | #[must_use] 62 | pub fn on_middle_release(mut self, message: Message) -> Self { 63 | self.on_middle_release = Some(message); 64 | self 65 | } 66 | 67 | /// The message to emit on mouse enter. 68 | #[must_use] 69 | pub fn on_mouse_enter(mut self, message: Message) -> Self { 70 | self.on_mouse_enter = Some(message); 71 | self 72 | } 73 | 74 | /// The message to emit on mouse exit. 75 | #[must_use] 76 | pub fn on_mouse_exit(mut self, message: Message) -> Self { 77 | self.on_mouse_exit = Some(message); 78 | self 79 | } 80 | } 81 | 82 | /// Local state of the [`MouseArea`]. 83 | #[derive(Default)] 84 | struct State { 85 | // TODO: Support on_mouse_enter and on_mouse_exit 86 | hovered: bool, 87 | } 88 | 89 | impl<'a, Message, Renderer> MouseArea<'a, Message, Renderer> { 90 | /// Creates a [`MouseArea`] with the given content. 91 | pub fn new(content: impl Into>) -> Self { 92 | MouseArea { 93 | content: content.into(), 94 | on_press: None, 95 | on_release: None, 96 | on_right_press: None, 97 | on_right_release: None, 98 | on_middle_press: None, 99 | on_middle_release: None, 100 | on_mouse_enter: None, 101 | on_mouse_exit: None, 102 | } 103 | } 104 | } 105 | 106 | impl<'a, Message, Renderer> Widget for MouseArea<'a, Message, Renderer> 107 | where 108 | Renderer: renderer::Renderer, 109 | Message: Clone, 110 | { 111 | fn tag(&self) -> tree::Tag { 112 | tree::Tag::of::() 113 | } 114 | 115 | fn state(&self) -> tree::State { 116 | tree::State::new(State::default()) 117 | } 118 | 119 | fn children(&self) -> Vec { 120 | vec![Tree::new(&self.content)] 121 | } 122 | 123 | fn diff(&self, tree: &mut Tree) { 124 | tree.diff_children(std::slice::from_ref(&self.content)); 125 | } 126 | 127 | fn width(&self) -> Length { 128 | self.content.as_widget().width() 129 | } 130 | 131 | fn height(&self) -> Length { 132 | self.content.as_widget().height() 133 | } 134 | 135 | fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { 136 | self.content.as_widget().layout(renderer, limits) 137 | } 138 | 139 | fn operate( 140 | &self, 141 | tree: &mut Tree, 142 | layout: Layout<'_>, 143 | renderer: &Renderer, 144 | operation: &mut dyn Operation, 145 | ) { 146 | self.content 147 | .as_widget() 148 | .operate(&mut tree.children[0], layout, renderer, operation); 149 | } 150 | 151 | fn on_event( 152 | &mut self, 153 | tree: &mut Tree, 154 | event: Event, 155 | layout: Layout<'_>, 156 | cursor: mouse::Cursor, 157 | renderer: &Renderer, 158 | clipboard: &mut dyn Clipboard, 159 | shell: &mut Shell<'_, Message>, 160 | viewport: &Rectangle, 161 | ) -> event::Status { 162 | if let event::Status::Captured = self.content.as_widget_mut().on_event( 163 | &mut tree.children[0], 164 | event.clone(), 165 | layout, 166 | cursor, 167 | renderer, 168 | clipboard, 169 | shell, 170 | viewport, 171 | ) { 172 | return event::Status::Captured; 173 | } 174 | 175 | update( 176 | self, 177 | &event, 178 | layout, 179 | cursor, 180 | shell, 181 | tree.state.downcast_mut(), 182 | ) 183 | } 184 | 185 | fn mouse_interaction( 186 | &self, 187 | tree: &Tree, 188 | layout: Layout<'_>, 189 | cursor: mouse::Cursor, 190 | viewport: &Rectangle, 191 | renderer: &Renderer, 192 | ) -> mouse::Interaction { 193 | self.content.as_widget().mouse_interaction( 194 | &tree.children[0], 195 | layout, 196 | cursor, 197 | viewport, 198 | renderer, 199 | ) 200 | } 201 | 202 | fn draw( 203 | &self, 204 | tree: &Tree, 205 | renderer: &mut Renderer, 206 | theme: &Renderer::Theme, 207 | renderer_style: &renderer::Style, 208 | layout: Layout<'_>, 209 | cursor: mouse::Cursor, 210 | viewport: &Rectangle, 211 | ) { 212 | self.content.as_widget().draw( 213 | &tree.children[0], 214 | renderer, 215 | theme, 216 | renderer_style, 217 | layout, 218 | cursor, 219 | viewport, 220 | ); 221 | } 222 | 223 | fn overlay<'b>( 224 | &'b mut self, 225 | tree: &'b mut Tree, 226 | layout: Layout<'_>, 227 | renderer: &Renderer, 228 | ) -> Option> { 229 | self.content 230 | .as_widget_mut() 231 | .overlay(&mut tree.children[0], layout, renderer) 232 | } 233 | } 234 | 235 | impl<'a, Message, Renderer> From> 236 | for Element<'a, Message, Renderer> 237 | where 238 | Message: 'a + Clone, 239 | Renderer: 'a + renderer::Renderer, 240 | { 241 | fn from(area: MouseArea<'a, Message, Renderer>) -> Element<'a, Message, Renderer> { 242 | Element::new(area) 243 | } 244 | } 245 | 246 | /// Processes the given [`Event`] and updates the [`State`] of an [`MouseArea`] 247 | /// accordingly. 248 | fn update( 249 | widget: &mut MouseArea<'_, Message, Renderer>, 250 | event: &Event, 251 | layout: Layout<'_>, 252 | cursor: mouse::Cursor, 253 | shell: &mut Shell<'_, Message>, 254 | state: &mut State, 255 | ) -> event::Status { 256 | let mut final_status = event::Status::Ignored; 257 | 258 | if !cursor.is_over(layout.bounds()) { 259 | if state.hovered { 260 | state.hovered = false; 261 | if let Some(message) = widget.on_mouse_exit.as_ref() { 262 | shell.publish(message.clone()); 263 | return event::Status::Captured; 264 | } 265 | } 266 | return event::Status::Ignored; 267 | } 268 | 269 | if !state.hovered { 270 | state.hovered = true; 271 | if let Some(message) = widget.on_mouse_enter.as_ref() { 272 | shell.publish(message.clone()); 273 | 274 | // we have to set a variable and not return since 275 | // theoretically one of the other events below could also 276 | // fire during the same update cycle 277 | final_status = event::Status::Captured; 278 | } 279 | } 280 | 281 | if let Some(message) = widget.on_press.as_ref() { 282 | if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) 283 | | Event::Touch(touch::Event::FingerPressed { .. }) = event 284 | { 285 | shell.publish(message.clone()); 286 | 287 | return event::Status::Captured; 288 | } 289 | } 290 | 291 | if let Some(message) = widget.on_release.as_ref() { 292 | if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) 293 | | Event::Touch(touch::Event::FingerLifted { .. }) = event 294 | { 295 | shell.publish(message.clone()); 296 | 297 | return event::Status::Captured; 298 | } 299 | } 300 | 301 | if let Some(message) = widget.on_right_press.as_ref() { 302 | if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event { 303 | shell.publish(message.clone()); 304 | 305 | return event::Status::Captured; 306 | } 307 | } 308 | 309 | if let Some(message) = widget.on_right_release.as_ref() { 310 | if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) = event { 311 | shell.publish(message.clone()); 312 | 313 | return event::Status::Captured; 314 | } 315 | } 316 | 317 | if let Some(message) = widget.on_middle_press.as_ref() { 318 | if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) = event { 319 | shell.publish(message.clone()); 320 | 321 | return event::Status::Captured; 322 | } 323 | } 324 | 325 | if let Some(message) = widget.on_middle_release.as_ref() { 326 | if let Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) = event { 327 | shell.publish(message.clone()); 328 | 329 | return event::Status::Captured; 330 | } 331 | } 332 | 333 | final_status 334 | } 335 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | // jolly error types 2 | 3 | use super::entry; 4 | use super::platform; 5 | use iced; 6 | use std::error; 7 | use std::fmt; 8 | use std::io; 9 | #[derive(Debug)] 10 | pub enum Error { 11 | StoreError(entry::Error), 12 | IcedError(iced::Error), 13 | IoError(Option, io::Error), 14 | ParseError(String), 15 | ContextParseError(String, String), 16 | PlatformError(platform::Error), 17 | CustomError(String), 18 | FinalMessage(String), 19 | } 20 | 21 | impl fmt::Display for Error { 22 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | match self { 24 | Error::StoreError(e) => { 25 | write!(f, "while parsing jolly.toml: \n")?; 26 | e.fmt(f) 27 | } 28 | Error::IcedError(e) => e.fmt(f), 29 | Error::IoError(file, e) => { 30 | if let Some(file) = file { 31 | write!(f, "with file '{file}': \n")?; 32 | } else { 33 | f.write_str("IO Error:\n")?; 34 | } 35 | e.fmt(f) 36 | } 37 | Error::ParseError(e) => f.write_str(e), 38 | Error::ContextParseError(file, e) => { 39 | write!(f, "while parsing '{file}':\n")?; 40 | e.fmt(f) 41 | } 42 | Error::PlatformError(e) => e.fmt(f), 43 | 44 | Error::CustomError(s) => f.write_str(s), 45 | // not really an error, used to represent final message in UI 46 | Error::FinalMessage(s) => f.write_str(s), 47 | } 48 | } 49 | } 50 | 51 | impl error::Error for Error {} 52 | -------------------------------------------------------------------------------- /src/icon/linux_and_friends.rs: -------------------------------------------------------------------------------- 1 | #![cfg(all(unix, not(target_os = "macos")))] 2 | // for now, this covers linux and the bsds 3 | use super::{icon_from_svg, Context, Icon, IconError, DEFAULT_ICON_SIZE}; 4 | 5 | use serde; 6 | use std::io::Read; 7 | use xdg_mime::SharedMimeInfo; 8 | 9 | // set in build script 10 | pub const DEFAULT_THEME: &str = env!("JOLLY_DEFAULT_THEME"); 11 | 12 | // TODO make mime sniff size a config parameter? 13 | const SNIFFSIZE: usize = 8 * 1024; 14 | 15 | #[derive(serde::Deserialize, Debug, Clone, PartialEq)] 16 | #[serde(default)] 17 | pub struct Os { 18 | pub theme: String, 19 | xdg_folder: Option, 20 | } 21 | 22 | impl Default for Os { 23 | fn default() -> Self { 24 | Self { 25 | theme: DEFAULT_THEME.into(), 26 | xdg_folder: None, 27 | } 28 | } 29 | } 30 | 31 | impl super::IconInterface for Os { 32 | fn get_default_icon(&self) -> Result { 33 | self.get_icon_for_iname("text-x-generic") 34 | } 35 | 36 | fn get_icon_for_file>(&self, path: P) -> Result { 37 | let path = path.as_ref(); 38 | let inames = self.get_iname_for_file(path)?; 39 | 40 | for iname in &inames { 41 | let icon = self.get_icon_for_iname(iname); 42 | if icon.is_ok() { 43 | return icon; 44 | } 45 | } 46 | Err(format!("No valid icon. inames were {:?}", inames).into()) 47 | } 48 | 49 | fn get_icon_for_url(&self, url: &str) -> Result { 50 | let iname = self.get_iname_for_url(url)?; 51 | self.get_icon_for_iname(&iname) 52 | } 53 | 54 | // for linux apps we need to make sure there are some default mime 55 | // types specified since CI is run headless 56 | } 57 | 58 | impl Os { 59 | fn get_iname_for_url(&self, p: &str) -> Result { 60 | use url::Url; 61 | 62 | let url = Url::parse(p).context("Url is not valid: {p}")?; 63 | 64 | use std::process::Command; 65 | let mut cmd = Command::new("xdg-settings"); 66 | 67 | cmd.arg("get") 68 | .arg("default-url-scheme-handler") 69 | .arg(url.scheme()); 70 | 71 | // if xdg folder is specified, we use this to override the 72 | // location we look for url settings 73 | if let Some(f) = &self.xdg_folder { 74 | cmd.env("XDG_DATA_HOME", f); 75 | cmd.env("XDG_DATA_DIRS", f); 76 | cmd.env("HOME", f); 77 | cmd.env("DE", "generic"); 78 | } 79 | 80 | let output = cmd.output().context("xdg-settings unsuccessful")?; 81 | 82 | // assume that we got back utf8 for the application name 83 | let mut handler = 84 | String::from_utf8(output.stdout).context("invalid utf8 from xdg-settings")?; 85 | if handler.ends_with("\n") { 86 | handler.pop(); 87 | } 88 | 89 | if handler.is_empty() { 90 | Err("no scheme handler found".into()) 91 | } else { 92 | Ok(handler) 93 | } 94 | } 95 | 96 | fn get_iname_for_file>( 97 | &self, 98 | path: P, 99 | ) -> Result, IconError> { 100 | let filename = path 101 | .as_ref() 102 | .as_os_str() 103 | .to_str() 104 | .context("filename not valid unicode")?; 105 | 106 | use once_cell::sync::OnceCell; 107 | 108 | static MIMEINFO: OnceCell = OnceCell::new(); 109 | 110 | // if xdg folder is specified, we use this to override the 111 | // location we look for mimetype settings 112 | let mimeinfo = match &self.xdg_folder { 113 | Some(f) => { 114 | let m = Box::new(SharedMimeInfo::new_for_directory(f)); 115 | Box::leak(m) // ok because only used in testing 116 | } 117 | None => MIMEINFO.get_or_init(SharedMimeInfo::new), 118 | }; 119 | 120 | let data: Option>; 121 | 122 | // TODO, handle files we can see but not read 123 | if let Ok(mut file) = std::fs::File::open(filename) { 124 | let mut buf = vec![0u8; SNIFFSIZE]; 125 | if let Ok(numread) = file.read(buf.as_mut_slice()) { 126 | buf.truncate(numread); 127 | data = Some(buf); 128 | } else { 129 | data = None; 130 | } 131 | } else { 132 | data = None; 133 | } 134 | 135 | // this next part is a little gross, but xdg_mime currently 136 | // hardcodes a mimetype of application/x-zerosize if a file is 137 | // empty. So we need to run the mime sniffing 2 different 138 | // ways, once with data and once without, and then lump them 139 | // all together to find whichever one creates an icon 140 | 141 | let guess = match data { 142 | Some(buf) => mimeinfo.guess_mime_type().path(filename).data(&buf).guess(), 143 | None => mimeinfo.guess_mime_type().path(filename).guess(), 144 | }; 145 | 146 | let fn_guess = mimeinfo.get_mime_types_from_file_name(filename); 147 | 148 | let allmimes = std::iter::once(guess.mime_type().clone()).chain(fn_guess.into_iter()); 149 | 150 | let allparents = allmimes 151 | .clone() 152 | .flat_map(|m| mimeinfo.get_parents(&m).unwrap_or_default().into_iter()); 153 | 154 | Ok(allmimes 155 | .chain(allparents) 156 | .flat_map(|m| mimeinfo.lookup_icon_names(&m).into_iter()) 157 | .collect()) 158 | } 159 | 160 | fn get_icon_for_iname(&self, icon_name: &str) -> Result { 161 | use freedesktop_icons::lookup; 162 | 163 | let icon_name = icon_name.strip_suffix(".desktop").unwrap_or(icon_name); 164 | 165 | let icon_path = lookup(icon_name) 166 | .with_size(DEFAULT_ICON_SIZE) 167 | .with_theme(&self.theme) 168 | .find() 169 | .ok_or("Could not lookup icon")?; 170 | 171 | // TODO handle other supported icon types 172 | if icon_path 173 | .extension() 174 | .is_some_and(|e| e.eq_ignore_ascii_case("png")) 175 | { 176 | Ok(iced::widget::image::Handle::from_path(icon_path)) 177 | } else if icon_path 178 | .extension() 179 | .is_some_and(|e| e.eq_ignore_ascii_case("svg")) 180 | { 181 | icon_from_svg(&icon_path) 182 | } else { 183 | Err(format!( 184 | "unsupported icon file type for icon {}", 185 | icon_path.to_string_lossy() 186 | ) 187 | .into()) 188 | } 189 | } 190 | } 191 | 192 | #[cfg(test)] 193 | mod tests { 194 | 195 | use std::fs::{create_dir, write}; 196 | use std::process::Command; 197 | use tempfile; 198 | 199 | use super::*; 200 | use iced::advanced::image::Data; 201 | 202 | // helper struct to allow building mock xdg data for testing 203 | struct MockXdg(tempfile::TempDir); 204 | 205 | impl MockXdg { 206 | fn new() -> Self { 207 | let dir = tempfile::tempdir().unwrap(); 208 | 209 | let p = dir.path(); 210 | create_dir(p.join("applications")).unwrap(); 211 | create_dir(p.join("mime")).unwrap(); 212 | Self(dir) 213 | } 214 | 215 | fn add_app(&self, appname: &str) { 216 | let p = self.0.path().join(format!("applications/{}", appname)); 217 | write(p, b"[Desktop Entry]\nExec=/bin/sh").unwrap(); 218 | } 219 | 220 | fn register_url(&self, url: &str, appname: &str) { 221 | let out = Command::new("xdg-settings") 222 | .args(["set", "default-url-scheme-handler", url, appname]) 223 | .env("XDG_DATA_HOME", self.0.path()) 224 | .env("XDG_DATA_DIRS", self.0.path()) 225 | .env("HOME", self.0.path()) 226 | .env("DE", "generic") 227 | .env("XDG_UTILS_DEBUG_LEVEL", "2") 228 | .output() 229 | .unwrap(); 230 | println!( 231 | "registering_url: {} -> {} status: {} {}", 232 | url, 233 | appname, 234 | out.status, 235 | String::from_utf8(out.stderr).unwrap() 236 | ); 237 | } 238 | 239 | fn register_mime(&self, mimetype: &str, extension: &str) { 240 | let filename = mimetype.split("/").last().unwrap(); 241 | let filename = self.0.path().join(format!("{}.xml", filename)); 242 | let text = format!( 243 | r#" 244 | 245 | 246 | 247 | 248 | 249 | "#, 250 | mimetype, extension 251 | ); 252 | 253 | write(&filename, text.as_bytes()).unwrap(); 254 | let out = Command::new("xdg-mime") 255 | .args([ 256 | "install", 257 | "--novendor", 258 | "--mode", 259 | "user", 260 | filename.to_str().unwrap(), 261 | ]) 262 | .env("XDG_DATA_HOME", self.0.path()) 263 | .env("XDG_DATA_DIRS", self.0.path()) 264 | .env("HOME", self.0.path()) 265 | .env("DE", "generic") 266 | .env("XDG_UTILS_DEBUG_LEVEL", "2") 267 | .output() 268 | .unwrap(); 269 | println!( 270 | "registering_mime: {} status: {} {}", 271 | mimetype, 272 | out.status, 273 | String::from_utf8(out.stderr).unwrap() 274 | ); 275 | } 276 | 277 | fn os(&self, theme: &str) -> Os { 278 | Os { 279 | theme: theme.into(), 280 | xdg_folder: Some(self.0.path().to_str().unwrap().into()), 281 | } 282 | } 283 | } 284 | 285 | #[test] 286 | fn test_load_icon() { 287 | // build a mock xdg with the ability to handle telephone and nothing else 288 | let xdg = MockXdg::new(); 289 | xdg.add_app("test.desktop"); 290 | xdg.register_url("tel", "test.desktop"); 291 | xdg.register_mime("text/x-rust", "rs"); 292 | let os = xdg.os(DEFAULT_THEME); 293 | 294 | assert!(os.get_iname_for_url("http://google.com").is_err()); 295 | assert!(os.get_iname_for_url("tel:12345").is_ok()); 296 | } 297 | 298 | #[test] 299 | fn test_load_file() { 300 | let dir = tempfile::tempdir().unwrap(); 301 | // build a mock xdg with the ability to handle rust source and nothing else 302 | let xdg = MockXdg::new(); 303 | xdg.register_mime("text/x-rust", "rs"); 304 | let os = xdg.os(DEFAULT_THEME); 305 | let file = dir.path().join("test.rs"); 306 | std::fs::File::create(&file).unwrap(); 307 | let mimetypes = os.get_iname_for_file(file).unwrap(); 308 | assert!( 309 | mimetypes.contains(&"text-x-rust".into()), 310 | "actual {:?}", 311 | mimetypes 312 | ); 313 | } 314 | 315 | #[test] 316 | fn can_load_svg_icons() { 317 | // freedesktop_icons falls back to using the icon name as a 318 | // file path if it cant find it otherwise. so we can use this 319 | // to force loading the jolly svg icon 320 | let svg_icon = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) 321 | .join("icon/jolly"); 322 | 323 | let icon = Os::default() 324 | .get_icon_for_iname(svg_icon.as_os_str().to_str().unwrap()) 325 | .unwrap(); 326 | // expect pixel data from the icon 327 | assert!(matches!( 328 | icon.data(), 329 | Data::Rgba { 330 | width: _, 331 | height: _, 332 | pixels: _ 333 | } 334 | )); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/icon/macos.rs: -------------------------------------------------------------------------------- 1 | #![cfg(target_os = "macos")] 2 | 3 | use super::{Context, Icon, IconError, IconInterface, DEFAULT_ICON_SIZE}; 4 | use core_graphics::geometry::{CGPoint, CGRect, CGSize}; 5 | use core_graphics::image::CGImageRef; 6 | use objc::rc::StrongPtr; 7 | use objc::runtime::Object; 8 | use objc::{class, msg_send, sel, sel_impl}; 9 | use serde; 10 | use url::Url; 11 | 12 | #[derive(serde::Deserialize, Debug, Clone, PartialEq, Default)] 13 | pub struct Os; 14 | 15 | impl IconInterface for Os { 16 | fn get_default_icon(&self) -> Result { 17 | let ident: NSString = "public.item".into(); 18 | 19 | unsafe { 20 | let typ: *mut Object = msg_send![class!(UTType), typeWithIdentifier: ident]; 21 | let workspace = get_workspace()?; 22 | 23 | let icon: *mut Object = msg_send![workspace, iconForContentType: typ]; 24 | let icon = icon.as_mut().context("iconForContentType was null")?; 25 | image2icon(icon).context("Could not get default icon") 26 | } 27 | } 28 | 29 | fn get_icon_for_file>(&self, path: P) -> Result { 30 | // now we have an icon! At this point, we can start 31 | // using the nicer wrappers from core_graphics-rs 32 | unsafe { icon_for_file(path.as_ref().as_os_str().into()) } 33 | } 34 | 35 | fn get_icon_for_url(&self, url: &str) -> Result { 36 | Url::parse(url).context("url is not valid")?; // TODO, hoist this out of all 3 implementations 37 | 38 | unsafe { 39 | let webstr: NSString = url.into(); 40 | 41 | let nsurl = class!(NSURL); 42 | 43 | // for full url 44 | let url: *mut Object = msg_send![nsurl, URLWithString: webstr]; 45 | 46 | if url.is_null() { 47 | return Err("Could not make NSURL".into()); 48 | } 49 | 50 | let workspace = get_workspace()?; 51 | 52 | // get app url 53 | let appurl: *mut Object = msg_send![workspace, URLForApplicationToOpenURL: url]; 54 | 55 | if appurl.is_null() { 56 | return Err("Could not get url of app for opening url".into()); 57 | } 58 | 59 | let path: *mut Object = msg_send![appurl, path]; 60 | 61 | if path.is_null() { 62 | return Err("Could not get path of app url".into()); 63 | } 64 | 65 | icon_for_file(path.try_into()?) 66 | } 67 | // convert to URL. Determine application url, get path to application, get icon for file 68 | 69 | // if we cannot convert to URL, assume it is a file. 70 | 71 | // if the file exists, use iconForFile 72 | 73 | // if the file does not exist, take its extension, and use typeWithFilenameExtention, and then iconForContentType 74 | } 75 | } 76 | 77 | unsafe fn get_workspace() -> Result<&'static mut Object, IconError> { 78 | let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace]; 79 | workspace 80 | .as_mut() 81 | .context("Could not get sharedWorkspace: Null") 82 | } 83 | 84 | unsafe fn image2icon(image: &mut Object) -> Result { 85 | let rect = CGRect { 86 | origin: CGPoint::new(0.0, 0.0), 87 | size: CGSize::new(DEFAULT_ICON_SIZE as f64, DEFAULT_ICON_SIZE as f64), 88 | }; 89 | 90 | let cgicon: *mut CGImageRef = msg_send![image, CGImageForProposedRect:&rect context:0 hints:0]; 91 | let cgicon = cgicon.as_ref().ok_or("Cannot get CGImage")?; 92 | 93 | // we dont know for sure we got RGBA data but we assume it for the rest of this function 94 | let bpc = cgicon.bits_per_component(); 95 | let bpp = cgicon.bits_per_pixel(); 96 | if bpc != 8 || bpp != 32 { 97 | return Err(format!("CGImage does not have 32bit depth: bpc: {bpc} bpp: {bpp}").into()); 98 | } 99 | 100 | let h = cgicon.height() as u32; 101 | let w = cgicon.width() as u32; 102 | 103 | // copies 104 | let pixels = Vec::from(cgicon.data().bytes()); 105 | 106 | Ok(Icon::from_pixels(h, w, pixels.leak())) 107 | } 108 | 109 | unsafe fn icon_for_file(path: NSString) -> Result { 110 | let workspace = get_workspace()?; 111 | 112 | let icon: *mut Object = msg_send![workspace, iconForFile: path]; 113 | 114 | image2icon(icon.as_mut().context("Could not get iconForFile: null")?) 115 | } 116 | 117 | struct NSString(StrongPtr); 118 | 119 | impl NSString { 120 | unsafe fn from_raw(b: *const u8, len: usize) -> Self { 121 | let nsstring = class!(NSString); 122 | let obj = StrongPtr::new(msg_send![nsstring, alloc]); 123 | if obj.is_null() { 124 | panic!("failed to alloc NSString") 125 | } 126 | let outstr = StrongPtr::new(msg_send![*obj, initWithBytes:b length:len encoding:4]); 127 | 128 | outstr.as_ref().expect("Could not init NSString"); 129 | 130 | Self(outstr) 131 | } 132 | } 133 | 134 | use std::ffi::OsStr; 135 | impl From<&OsStr> for NSString { 136 | fn from(s: &OsStr) -> NSString { 137 | use std::os::unix::ffi::OsStrExt; 138 | unsafe { 139 | let b = s.as_bytes(); 140 | NSString::from_raw(b.as_ptr(), b.len()) 141 | } 142 | } 143 | } 144 | 145 | impl std::ops::Deref for NSString { 146 | type Target = *mut Object; 147 | fn deref(&self) -> &*mut Object { 148 | self.0.deref() 149 | } 150 | } 151 | 152 | use std::fmt::{Formatter, Pointer}; 153 | impl Pointer for NSString { 154 | fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { 155 | self.0.fmt(f) 156 | } 157 | } 158 | 159 | impl From<&str> for NSString { 160 | fn from(s: &str) -> NSString { 161 | let b = s.as_bytes(); 162 | unsafe { NSString::from_raw(b.as_ptr(), b.len()) } 163 | } 164 | } 165 | 166 | impl TryFrom<*mut Object> for NSString { 167 | type Error = IconError; 168 | fn try_from(s: *mut Object) -> Result { 169 | use objc::runtime::{BOOL, NO}; 170 | unsafe { 171 | let p = s.as_ref().context("Null Ptr While converting NSString")?; 172 | 173 | let result: BOOL = msg_send![p, isKindOfClass: class!(NSString)]; 174 | 175 | if result == NO { 176 | return Err("Conversion error: Object is not isKindOfClass NSString".into()); 177 | } 178 | 179 | Ok(NSString(StrongPtr::new(s))) 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/icon/mod.rs: -------------------------------------------------------------------------------- 1 | // contains logic for loading icons for entries 2 | // 3 | // different implementations for macOs, windows, and linux. (linux and 4 | // BSDs assumed to use freedesktop compatible icon standards) 5 | 6 | // general overview and requirements for icon usage: 7 | // 8 | // Icons are mandatory for usage with Jolly. 9 | // Icons are generated based on the entry type. 10 | // 11 | // location entries get searched as a file. If the file does not 12 | // exist, a default icon may be returned 13 | // 14 | // url-like entries get searched as a protocol handler 15 | // 16 | // system entries are currently treated as file entries, which means 17 | // they usually fail to load since the system command is not usually a 18 | // file on disk. 19 | // 20 | // keyword entries are parsed by filling in their customizable value with a blank string "" 21 | // 22 | // If an icon cannot be loaded, we fall back to a platform specific default icon. 23 | // 24 | // If that default icon cannot be loaded, there is an extremely boring 25 | // (grey square) icon that is used instead. 26 | // 27 | // The default icon is always cached staticly (one use per crate) 28 | // 29 | // All of the other icon lookups can be optionally cached using the IconCache struct 30 | 31 | use std::collections::HashMap; 32 | use std::hash::Hash; 33 | 34 | use std::error; 35 | use url::Url; 36 | 37 | mod linux_and_friends; 38 | mod macos; 39 | mod windows; 40 | 41 | use lazy_static::lazy_static; 42 | lazy_static! { 43 | static ref FALLBACK_ICON: Icon = Icon::from_pixels(1, 1, &[127, 127, 127, 255]); 44 | } 45 | 46 | // TODO 47 | // 48 | // This is a list of supported icon formats by iced_graphics. 49 | // This is based on iced_graphics using image-rs to load images, and 50 | // looking at what features are enabled on that package. In the future 51 | // we may not compile support for all formats but (for now) we have a 52 | // comprehensive list here 53 | const SUPPORTED_ICON_EXTS: &[&str] = &[ 54 | "png", "jpg", "jpeg", "gif", "webp", "pbm", "pam", "ppm", "pgm", "tiff", "tif", "tga", "dds", 55 | "bmp", "ico", "hdr", "exr", "ff", "qoi", 56 | ]; 57 | 58 | const DEFAULT_ICON_SIZE: u16 = 48; // TODO, support other icon sizes 59 | 60 | #[cfg(target_os = "macos")] 61 | pub use macos::Os as IconSettings; 62 | 63 | #[cfg(all(unix, not(target_os = "macos")))] 64 | pub use linux_and_friends::Os as IconSettings; 65 | 66 | #[cfg(target_os = "windows")] 67 | pub use self::windows::Os as IconSettings; 68 | 69 | #[derive(Debug)] 70 | struct IconError(String, Option>); 71 | 72 | use std::fmt; 73 | impl fmt::Display for IconError { 74 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 75 | f.write_str(&self.0) 76 | } 77 | } 78 | 79 | impl error::Error for IconError { 80 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 81 | self.1.as_deref() 82 | } 83 | } 84 | 85 | impl + fmt::Display> From for IconError { 86 | fn from(value: S) -> Self { 87 | Self(value.to_string(), None) 88 | } 89 | } 90 | 91 | trait Context { 92 | fn context + fmt::Display>(self, msg: S) -> Result; 93 | } 94 | 95 | impl Context for Result { 96 | fn context + fmt::Display>(self, msg: S) -> Result { 97 | self.map_err(|e| IconError(msg.to_string(), Some(Box::new(e)))) 98 | } 99 | } 100 | 101 | impl Context for Option { 102 | fn context + fmt::Display>(self, msg: S) -> Result { 103 | self.ok_or(IconError(msg.to_string(), None)) 104 | } 105 | } 106 | 107 | trait IconImpl {} 108 | 109 | // defines functions that must be implemented for every operating 110 | // system in order implement icons in jolly 111 | trait IconInterface { 112 | // default icon to use if icon cannot be loaded. 113 | // must be infallible 114 | // the output is cached by icon module, so it should not be used be other logic 115 | fn get_default_icon(&self) -> Result; 116 | 117 | // icon that would be used for a path that must exist 118 | // path is guaranteed to be already canonicalized 119 | fn get_icon_for_file>(&self, path: P) -> Result; 120 | 121 | // icon to use for a specific url or protocol handler. 122 | fn get_icon_for_url(&self, url: &str) -> Result; 123 | 124 | // provided method: version of get_default_icon that caches its 125 | // value. One value for lifetime of application 126 | fn cached_default(&self) -> Icon { 127 | use once_cell::sync::OnceCell; 128 | static DEFAULT_ICON: OnceCell = OnceCell::new(); 129 | 130 | DEFAULT_ICON 131 | .get_or_init(|| self.get_default_icon().unwrap_or(FALLBACK_ICON.clone())) 132 | .clone() 133 | } 134 | 135 | // provided method: uses icon interfaces to turn icontype into icon 136 | fn load_icon(&self, itype: IconType) -> Icon { 137 | let icon = self.try_load_icon(itype); 138 | icon.unwrap_or(self.cached_default()) 139 | } 140 | 141 | // convert an icontype into an icon 142 | fn try_load_icon(&self, itype: IconType) -> Result { 143 | match itype.0 { 144 | IconVariant::Url(u) => self.get_icon_for_url(u.as_str()), 145 | IconVariant::File(p) => { 146 | if p.exists() { 147 | if let Ok(p) = p.canonicalize() { 148 | self.get_icon_for_file(p) 149 | } else { 150 | Err("File Icon does not exist".into()) 151 | } 152 | } else { 153 | Err("Cannot load icon for nonexistant file".into()) // TODO handle file type lookup by extension 154 | } 155 | } 156 | IconVariant::CustomIcon(p) => { 157 | let ext = p.extension().context("No extension on custom icon file")?; 158 | if SUPPORTED_ICON_EXTS 159 | .iter() 160 | .find(|s| ext.eq_ignore_ascii_case(s)) 161 | .is_some() 162 | { 163 | Ok(Icon::from_path(p)) 164 | } else if ext.eq_ignore_ascii_case("svg") { 165 | icon_from_svg(&p) 166 | } else { 167 | Err("is unsupported icon type".into()) 168 | } 169 | } 170 | IconVariant::System(command) => { 171 | // heuristic: dont use full path but only first 172 | // word in system entry 173 | use which::which; 174 | 175 | if let Some(exe) = command.split(" ").next().and_then(|e| which(e).ok()) { 176 | self.try_load_icon(IconType::file(exe)) 177 | } else if let Some(exe) = command 178 | .split(" ") 179 | .next() 180 | .and_then(|exe| self.try_load_icon(IconType::file(exe)).ok()) 181 | { 182 | Ok(exe) 183 | } else { 184 | self.try_load_icon(IconType::file(command)) 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | // sufficient for now, until we implement SVG support 192 | pub type Icon = iced::widget::image::Handle; 193 | 194 | #[derive(Debug, Clone, Hash, Eq, PartialEq)] 195 | pub struct IconType(IconVariant); 196 | 197 | impl IconType { 198 | pub fn custom>(path: P) -> Self { 199 | Self(IconVariant::CustomIcon(path.as_ref().into())) 200 | } 201 | pub fn url(url: Url) -> Self { 202 | // hack to make paths that start with disk drives not show up as URLs 203 | #[cfg(target_os = "windows")] 204 | if url.scheme().len() == 1 205 | && "abcdefghijklmnopqrstuvwxyz".contains(url.scheme().chars().next().unwrap()) 206 | { 207 | return Self(IconVariant::File(url.as_ref().into())); 208 | } 209 | 210 | Self(IconVariant::Url(url)) 211 | } 212 | pub fn file>(path: P) -> Self { 213 | Self(IconVariant::File(path.as_ref().into())) 214 | } 215 | 216 | pub fn system(cmd: S) -> Self { 217 | Self(IconVariant::System(cmd.to_string())) 218 | } 219 | } 220 | 221 | // represents the necessary information in an entry to look up an icon 222 | // type. Importantly, url based entries are assumed to have the same 223 | // icon if they have the same protocol (for example, all web links) 224 | #[derive(Debug, Clone)] 225 | enum IconVariant { 226 | // render using icon for protocol of url 227 | Url(url::Url), 228 | // render using icon for path 229 | File(std::path::PathBuf), 230 | // Like a file, but uses heuristics in case command has arguments 231 | System(String), 232 | // override "normal" icon and use icon from this path 233 | CustomIcon(std::path::PathBuf), 234 | } 235 | 236 | impl Hash for IconVariant { 237 | fn hash(&self, state: &mut H) { 238 | match self { 239 | IconVariant::Url(u) => u.scheme().hash(state), 240 | IconVariant::File(p) => p.hash(state), 241 | IconVariant::CustomIcon(p) => p.hash(state), 242 | IconVariant::System(p) => p.hash(state), 243 | } 244 | } 245 | } 246 | 247 | impl Eq for IconVariant {} 248 | impl PartialEq for IconVariant { 249 | fn eq(&self, other: &Self) -> bool { 250 | match self { 251 | IconVariant::Url(s) => { 252 | if let IconVariant::Url(o) = other { 253 | s.scheme() == o.scheme() 254 | } else { 255 | false 256 | } 257 | } 258 | IconVariant::File(s) => { 259 | if let IconVariant::File(o) = other { 260 | s == o 261 | } else { 262 | false 263 | } 264 | } 265 | IconVariant::CustomIcon(s) => { 266 | if let IconVariant::CustomIcon(o) = other { 267 | s == o 268 | } else { 269 | false 270 | } 271 | } 272 | IconVariant::System(s) => { 273 | if let IconVariant::System(o) = other { 274 | s == o 275 | } else { 276 | false 277 | } 278 | } 279 | } 280 | } 281 | } 282 | 283 | pub fn default_icon(is: &IconSettings) -> Icon { 284 | is.cached_default() 285 | } 286 | 287 | use crate::Message; 288 | use iced::futures::channel::mpsc; 289 | 290 | // represents an icon cache that can look up icons in a deferred worker thread 291 | #[derive(Default)] 292 | pub struct IconCache { 293 | cmd: Option>, 294 | cache: HashMap>, 295 | } 296 | 297 | impl IconCache { 298 | pub fn new() -> Self { 299 | Self { 300 | cmd: None, 301 | cache: HashMap::new(), 302 | } 303 | } 304 | 305 | pub fn get(&mut self, it: &IconType) -> Option { 306 | // if the key is the cache, either we have the icon or it has 307 | // already been scheduled. either way, send it. 308 | if let Some(icon) = self.cache.get(it) { 309 | return icon.clone(); 310 | } 311 | 312 | // if we have a reference the iconwork command channel, then 313 | // we kick off a request to lookup the new icontype 314 | if let Some(cmd) = &self.cmd { 315 | cmd.send(IconCommand::LoadIcon(it.clone())) 316 | .expect("Could not send new icon lookup command"); 317 | self.cache.insert(it.clone(), None); 318 | } 319 | 320 | // at this point, we know we had a cache miss 321 | None 322 | } 323 | 324 | pub fn add_icon(&mut self, it: IconType, i: Icon) { 325 | self.cache.insert(it, Some(i)); 326 | } 327 | 328 | pub fn set_cmd(&mut self, cmd: std::sync::mpsc::Sender) { 329 | self.cmd = Some(cmd); 330 | } 331 | } 332 | 333 | #[derive(Debug)] 334 | pub enum IconCommand { 335 | LoadSettings(IconSettings), 336 | LoadIcon(IconType), 337 | } 338 | 339 | pub fn icon_worker() -> mpsc::Receiver { 340 | // todo: fix magic for channel size 341 | let (mut output, sub_stream) = mpsc::channel(100); 342 | 343 | std::thread::spawn(move || { 344 | let (input, command_stream) = std::sync::mpsc::channel(); 345 | 346 | // send the application a channel to provide us icon work 347 | // TODO: implement error checking if we cant send 348 | output 349 | .try_send(Message::StartedIconWorker(input)) 350 | .expect("Could not send iconworker back to application"); 351 | 352 | let command = match command_stream.recv() { 353 | Ok(i) => i, 354 | _ => return, 355 | }; 356 | let settings = if let IconCommand::LoadSettings(settings) = command { 357 | settings 358 | } else { 359 | return; 360 | }; 361 | 362 | loop { 363 | let command = match command_stream.recv() { 364 | Ok(i) => i, 365 | _ => break, 366 | }; 367 | 368 | match command { 369 | IconCommand::LoadIcon(icontype) => { 370 | // todo: handle error 371 | output 372 | .try_send(Message::IconReceived( 373 | icontype.clone(), 374 | settings.load_icon(icontype), 375 | )) 376 | .expect("Could not send icon back application"); 377 | } 378 | _ => break, 379 | } 380 | } 381 | }); 382 | sub_stream 383 | } 384 | 385 | // convert an svg file into a pixmap 386 | fn icon_from_svg(path: &std::path::Path) -> Result { 387 | use resvg::usvg::TreeParsing; 388 | let svg_data = std::fs::read(path).context("could not open file")?; 389 | let utree = resvg::usvg::Tree::from_data(&svg_data, &Default::default()) 390 | .context("could not parse svg")?; 391 | 392 | let icon_size = DEFAULT_ICON_SIZE as u32; 393 | 394 | let mut pixmap = 395 | resvg::tiny_skia::Pixmap::new(icon_size, icon_size).context("could not create pixmap")?; 396 | 397 | let rtree = resvg::Tree::from_usvg(&utree); 398 | 399 | // we have non-square svg 400 | if rtree.size.width() != rtree.size.height() { 401 | return Err("SVG icons must be square".into()); 402 | } 403 | 404 | let scalefactor = icon_size as f32 / rtree.size.width(); 405 | let transform = resvg::tiny_skia::Transform::from_scale(scalefactor, scalefactor); 406 | 407 | rtree.render(transform, &mut pixmap.as_mut()); 408 | 409 | Ok(Icon::from_pixels( 410 | icon_size, 411 | icon_size, 412 | pixmap.take().leak(), 413 | )) 414 | } 415 | 416 | #[cfg(test)] 417 | mod tests { 418 | use crate::icon::IconType; 419 | 420 | use super::{Icon, IconError, IconInterface, IconSettings}; 421 | use iced::advanced::image; 422 | 423 | pub(crate) fn hash_eq_icon(icon: &Icon, ficon: &Icon) -> bool { 424 | use std::collections::hash_map::DefaultHasher; 425 | use std::hash::{Hash, Hasher}; 426 | 427 | let mut ihash = DefaultHasher::new(); 428 | let mut fhash = DefaultHasher::new(); 429 | icon.hash(&mut ihash); 430 | ficon.hash(&mut fhash); 431 | ihash.finish() == fhash.finish() 432 | } 433 | 434 | fn iconlike(icon: Icon, err_msg: &str) { 435 | match icon.data() { 436 | image::Data::Path(p) => { 437 | assert!(p.exists()) 438 | } 439 | image::Data::Bytes(bytes) => { 440 | assert!(bytes.len() > 0) 441 | } 442 | image::Data::Rgba { 443 | width, 444 | height, 445 | pixels, 446 | } => { 447 | let num_pixels = width * height; 448 | 449 | assert!(num_pixels > 0, "zero pixels: {}", err_msg); 450 | assert_eq!( 451 | (num_pixels * 4) as usize, 452 | pixels.len(), 453 | "incorrect buffer size: {}", 454 | err_msg 455 | ) 456 | } 457 | }; 458 | 459 | assert!( 460 | !hash_eq_icon(&icon, &super::FALLBACK_ICON), 461 | "icon hash matches fallback icon, should not occur during happycase" 462 | ); 463 | } 464 | 465 | #[test] 466 | fn default_icon_is_iconlike() { 467 | iconlike( 468 | IconSettings::default().get_default_icon().unwrap(), 469 | "for default icon", 470 | ); 471 | } 472 | 473 | // ignore on linux since exes are all detected as libraries which 474 | // dont have a default icon 475 | #[test] 476 | #[cfg(any(target_os = "windows", target_os = "macos"))] 477 | fn executable_is_iconlike() { 478 | let cur_exe = std::env::current_exe().unwrap(); 479 | 480 | iconlike( 481 | IconSettings::default().get_icon_for_file(&cur_exe).unwrap(), 482 | "for current executable", 483 | ); 484 | } 485 | 486 | #[test] 487 | fn paths_are_canonicalized() { 488 | struct MockIcon; 489 | 490 | impl IconInterface for MockIcon { 491 | fn get_default_icon(&self) -> Result { 492 | Ok(super::Icon::from_pixels(1, 1, &[1, 1, 1, 1])) 493 | } 494 | 495 | fn get_icon_for_file>( 496 | &self, 497 | path: P, 498 | ) -> Result { 499 | let path = path.as_ref(); 500 | assert!(path.as_os_str() == path.canonicalize().unwrap().as_os_str()); 501 | self.get_default_icon() 502 | } 503 | 504 | fn get_icon_for_url(&self, _url: &str) -> Result { 505 | panic!("expected file, not url") 506 | } 507 | } 508 | 509 | use tempfile; 510 | let curdir = std::env::current_dir().unwrap(); 511 | let dir = tempfile::tempdir_in(&curdir).unwrap(); 512 | let dirname = dir.path().strip_prefix(curdir).unwrap(); 513 | 514 | let filename = dirname.join("test.txt"); 515 | 516 | let _file = std::fs::File::create(&filename).unwrap(); 517 | 518 | let icon_type = super::IconType(super::IconVariant::File(filename)); 519 | let mock = MockIcon; 520 | mock.load_icon(icon_type); 521 | } 522 | 523 | #[test] 524 | fn common_urls_are_iconlike() { 525 | use crate::icon::Context; 526 | // test urls that default macos has support for 527 | #[cfg(any(target_os = "macos", target_os = "windows"))] 528 | let happycase_urls = vec!["http://example.com", "https://example.com"]; 529 | 530 | #[cfg(all(unix, not(target_os = "macos")))] 531 | let happycase_urls: Vec<&str> = Vec::new(); 532 | 533 | #[cfg(windows)] 534 | let happycase_urls: Vec<_> = happycase_urls 535 | .into_iter() 536 | .chain( 537 | [ 538 | "accountpicturefile:", 539 | "AudioCD:", 540 | "batfile:", 541 | "fonfile:", 542 | "hlpfile:", 543 | "regedit:", 544 | ] 545 | .into_iter(), 546 | ) 547 | .collect(); 548 | 549 | let sadcase_urls = vec![ 550 | "totallynonexistantprotocol:", 551 | "http:", // malformed url 552 | ]; 553 | 554 | #[cfg(windows)] 555 | let sadcase_urls: Vec<_> = sadcase_urls 556 | .into_iter() 557 | .chain( 558 | [ 559 | "anifile:", // uses %1 as the icon 560 | "tel:", // defined but empty on windows 561 | ] 562 | .into_iter(), 563 | ) 564 | .collect(); 565 | 566 | let os = IconSettings::default(); 567 | 568 | let failed_results: Vec<_> = happycase_urls 569 | .into_iter() 570 | .filter_map(|u| { 571 | os.get_icon_for_url(&u) 572 | .context(format!("failed to load '{u}'")) 573 | .err() 574 | .map(|e| e.to_string()) 575 | }) 576 | .chain(sadcase_urls.into_iter().filter_map(|u| { 577 | os.get_icon_for_url(&u) 578 | .ok() 579 | .map(|_| format!("successfully loaded '{u}'")) 580 | })) 581 | .collect(); 582 | 583 | assert!( 584 | failed_results.is_empty(), 585 | "some default urls were loaded incorrectly: {:?}", 586 | failed_results 587 | ); 588 | } 589 | 590 | #[test] 591 | fn common_files_are_iconlike() { 592 | let dir = tempfile::tempdir().unwrap(); 593 | let files = ["foo.txt", "bar.html", "baz.png", "bat.pdf"]; 594 | 595 | let os = IconSettings::default(); 596 | 597 | os.get_icon_for_file(dir.path()) 598 | .expect("No Icon for folder".into()); 599 | 600 | for f in files { 601 | let path = dir.path().join(f); 602 | let file = std::fs::File::create(&path).unwrap(); 603 | file.sync_all().unwrap(); 604 | 605 | assert!(path.exists()); 606 | os.get_icon_for_file(&path) 607 | .expect(&format!("No Icon for file: {f}")); 608 | } 609 | } 610 | 611 | #[test] 612 | fn load_custom_icons() { 613 | use super::*; 614 | // example test pbm image 615 | let pbm_bytes = "P1\n2 2\n1 0 1 0".as_bytes(); 616 | // example test svg 617 | let test_svg = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) 618 | .join("icon/jolly.svg"); 619 | 620 | let dir = tempfile::tempdir().unwrap(); 621 | 622 | let pbm_fn = dir.path().join("test.pbm"); 623 | 624 | std::fs::write(&pbm_fn, pbm_bytes).unwrap(); 625 | 626 | let os = IconSettings::default(); 627 | 628 | let pbm_icon = os.try_load_icon(IconType::custom(pbm_fn)).unwrap(); 629 | assert!(matches!(pbm_icon.data(), image::Data::Path(_))); 630 | 631 | os.try_load_icon(IconType::custom("file_with_no_extension")) 632 | .unwrap_err(); 633 | 634 | os.try_load_icon(IconType::custom("unsupported_icon_type.pdf")) 635 | .unwrap_err(); 636 | 637 | let svg_icon = os.try_load_icon(IconType::custom(test_svg)).unwrap(); 638 | assert!(matches!( 639 | svg_icon.data(), 640 | image::Data::Rgba { 641 | width: w, 642 | height: h, 643 | pixels: _ 644 | } 645 | if *w == DEFAULT_ICON_SIZE as u32 && *h == DEFAULT_ICON_SIZE as u32 646 | )); 647 | } 648 | 649 | #[test] 650 | fn system_entry_heuristic() { 651 | use tempfile::tempdir; 652 | 653 | #[cfg(windows)] 654 | let exe = "cmd.exe"; 655 | #[cfg(not(windows))] 656 | let exe = "echo"; 657 | 658 | let os = IconSettings::default(); 659 | 660 | os.try_load_icon(IconType::system(format!("{exe}"))) 661 | .unwrap(); 662 | os.try_load_icon(IconType::system(format!("{exe} a b c"))) 663 | .unwrap(); 664 | 665 | let dir = tempdir().unwrap(); 666 | 667 | let exe = dir.path().join("test.txt").display().to_string(); 668 | std::fs::File::create(&exe).unwrap(); 669 | 670 | assert!( 671 | !exe.contains(" "), 672 | "test requires that temp dir path not have spaces: actual path: {exe}" 673 | ); 674 | 675 | os.try_load_icon(IconType::system(format!("{exe}"))) 676 | .unwrap(); 677 | os.try_load_icon(IconType::system(format!("{exe} a b c"))) 678 | .unwrap(); 679 | 680 | let exe = dir.path().join("test with spaces.py").display().to_string(); 681 | std::fs::File::create(&exe).unwrap(); 682 | 683 | os.try_load_icon(IconType::system(format!("{exe}"))) 684 | .unwrap(); 685 | os.try_load_icon(IconType::system(format!("{exe} a b c"))) 686 | .unwrap_err(); 687 | } 688 | } 689 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Jolly is a binary crate that is not intended to be used as a 2 | //! library. Its API is unstable and undocumented, and it only exists 3 | //! in order to support certain integration testing and benchmarking. 4 | //! 5 | //! You can find documentation for the Jolly crate at its homepage, 6 | //! [https://github.com/apgoetz/jolly](https://github.com/apgoetz/jolly) 7 | 8 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 9 | 10 | use ::log::trace; 11 | use iced::widget::text::Shaping; 12 | use iced::widget::text_input; 13 | use iced::widget::{Text, TextInput}; 14 | use iced::{clipboard, event, keyboard, subscription, widget, window}; 15 | use iced::{executor, Application, Command, Element, Length, Renderer, Size}; 16 | use lazy_static; 17 | use std::sync::mpsc; 18 | 19 | pub mod cli; 20 | pub mod config; 21 | mod custom; 22 | mod entry; 23 | pub mod error; 24 | mod icon; 25 | mod log; 26 | mod platform; 27 | mod search_results; 28 | mod settings; 29 | pub mod store; 30 | mod theme; 31 | mod ui; 32 | 33 | lazy_static::lazy_static! { 34 | static ref TEXT_INPUT_ID : text_input::Id = text_input::Id::unique(); 35 | } 36 | #[derive(Debug, Clone)] 37 | pub enum Message { 38 | SearchTextChanged(String), 39 | ExternalEvent(event::Event), 40 | EntrySelected(entry::EntryId), 41 | EntryHovered(entry::EntryId), 42 | DimensionsChanged(f32, f32), 43 | StartedIconWorker(mpsc::Sender), 44 | IconReceived(icon::IconType, icon::Icon), 45 | } 46 | 47 | #[derive(Debug)] 48 | enum StoreLoadedState { 49 | Pending, 50 | Finished(error::Error), 51 | LoadSucceeded(store::Store, String), 52 | } 53 | 54 | impl Default for StoreLoadedState { 55 | fn default() -> Self { 56 | StoreLoadedState::Pending 57 | } 58 | } 59 | 60 | #[derive(Default)] 61 | pub struct Jolly { 62 | searchtext: String, 63 | store_state: StoreLoadedState, 64 | search_results: search_results::SearchResults, 65 | modifiers: keyboard::Modifiers, 66 | settings: settings::Settings, 67 | icache: icon::IconCache, 68 | bounds: iced::Rectangle, 69 | focused_once: bool, // for some reason gnome defocusses 70 | // the jolly window when launching, so we have to ignore 71 | // defocus events until we receive a focus event. 72 | } 73 | 74 | impl Jolly { 75 | fn move_to_err(&mut self, err: error::Error) -> Command<::Message> { 76 | ::log::error!("{err}"); 77 | self.store_state = StoreLoadedState::Finished(err); 78 | Command::none() 79 | } 80 | 81 | fn handle_selection(&mut self, id: entry::EntryId) -> Command<::Message> { 82 | // we can only continue if the store is loaded 83 | let store = match &self.store_state { 84 | StoreLoadedState::LoadSucceeded(s, _) => s, 85 | _ => return Command::none(), 86 | }; 87 | 88 | let entry = store.get(id); 89 | 90 | // if the user is pressing the command key, we want to copy to 91 | // clipboard instead of opening the link 92 | if self.modifiers.command() { 93 | let result = entry.format_selection(&self.searchtext); 94 | let msg = format!("copied to clipboard: {}", &result); 95 | 96 | ::log::info!("{msg}"); 97 | 98 | let cmds = [ 99 | clipboard::write(result), 100 | self.move_to_err(error::Error::FinalMessage(msg)), 101 | ]; 102 | Command::batch(cmds) 103 | } else { 104 | let result = entry.handle_selection(&self.searchtext); 105 | 106 | if let Err(e) = result.map_err(error::Error::StoreError) { 107 | self.move_to_err(e) 108 | } else { 109 | iced::window::close() 110 | } 111 | } 112 | } 113 | } 114 | 115 | impl Application for Jolly { 116 | type Executor = executor::Default; 117 | type Message = Message; 118 | type Theme = theme::Theme; 119 | type Flags = config::Config; 120 | 121 | fn new(config: Self::Flags) -> (Self, Command) { 122 | let mut jolly = Self::default(); 123 | 124 | jolly.settings = config.settings; 125 | 126 | jolly.bounds.width = jolly.settings.ui.width as f32; 127 | 128 | jolly.store_state = match config.store { 129 | Ok(store) => { 130 | let msg = format!("Loaded {} entries", store.len()); 131 | 132 | StoreLoadedState::LoadSucceeded(store, msg) 133 | } 134 | Err(e) => { 135 | ::log::error!("{e}"); 136 | StoreLoadedState::Finished(e) 137 | } 138 | }; 139 | ( 140 | jolly, 141 | Command::batch([ 142 | window::change_mode(window::Mode::Windowed), 143 | text_input::focus(TEXT_INPUT_ID.clone()), 144 | // steal focus after startup: fixed bug on windows where it is possible to start jolly without focus 145 | window::gain_focus(), 146 | ]), 147 | ) 148 | } 149 | 150 | fn title(&self) -> String { 151 | String::from("jolly") 152 | } 153 | 154 | fn update(&mut self, message: Self::Message) -> Command { 155 | trace!("Received Message::{:?}", message); 156 | 157 | // first, match the messages that would cause us to quit regardless of application state 158 | match message { 159 | Message::ExternalEvent(event::Event::Keyboard(e)) => { 160 | if let keyboard::Event::KeyReleased { 161 | key_code: key, 162 | modifiers: _, 163 | } = e 164 | { 165 | if key == keyboard::KeyCode::Escape { 166 | return iced::window::close(); 167 | } 168 | } 169 | } 170 | Message::ExternalEvent(event::Event::Window(w)) if w == window::Event::Focused => { 171 | self.focused_once = true; 172 | return Command::none(); 173 | } 174 | 175 | Message::ExternalEvent(event::Event::Window(w)) 176 | if w == window::Event::Unfocused && self.focused_once => 177 | { 178 | return iced::window::close(); 179 | } 180 | 181 | // handle height change even if UI has failed to load 182 | Message::DimensionsChanged(width, height) => { 183 | let width = if matches!(self.store_state, StoreLoadedState::Finished(_)) { 184 | width 185 | } else { 186 | self.settings.ui.width as _ 187 | }; 188 | 189 | self.bounds.width = width; 190 | self.bounds.height = height; 191 | 192 | return window::resize(Size::new(width.ceil() as u32, height.ceil() as u32)); 193 | } 194 | _ => (), // dont care at this point about other messages 195 | }; 196 | 197 | // then, check if we are loaded. ifwe have failed to laod, we stop processing messages 198 | let store = match &mut self.store_state { 199 | StoreLoadedState::LoadSucceeded(s, _) => s, 200 | _ => return Command::none(), 201 | }; 202 | 203 | // if we are here, we are loaded and we dont want to quit 204 | match message { 205 | Message::SearchTextChanged(txt) => { 206 | self.searchtext = txt; 207 | 208 | let matches = store.find_matches(&self.searchtext).into_iter(); 209 | 210 | // todo: determine which entries need icons 211 | let new_results = search_results::SearchResults::new(matches, &self.settings.ui); 212 | 213 | // load icons of whatever matches are being displayed 214 | store.load_icons(new_results.entries(), &mut self.icache); 215 | 216 | self.search_results = new_results; 217 | 218 | Command::none() 219 | } 220 | Message::ExternalEvent(event::Event::Window(window::Event::FileDropped(path))) => { 221 | println!("{:?}", path); 222 | Command::none() 223 | } 224 | Message::ExternalEvent(event::Event::Keyboard(e)) => { 225 | if let keyboard::Event::KeyReleased { 226 | key_code: key, 227 | modifiers: _, 228 | } = e 229 | { 230 | if key == keyboard::KeyCode::Escape { 231 | return iced::window::close(); 232 | } else if key == keyboard::KeyCode::NumpadEnter 233 | || key == keyboard::KeyCode::Enter 234 | { 235 | let cmd = if let Some(id) = self.search_results.selected() { 236 | self.handle_selection(id) 237 | } else { 238 | iced::window::close() 239 | }; 240 | return cmd; 241 | } 242 | } 243 | 244 | if keyboard::Event::CharacterReceived('\r') == e { 245 | let cmd = if let Some(id) = self.search_results.selected() { 246 | self.handle_selection(id) 247 | } else { 248 | iced::window::close() 249 | }; 250 | return cmd; 251 | } 252 | 253 | if let keyboard::Event::ModifiersChanged(m) = e { 254 | self.modifiers = m; 255 | } 256 | 257 | self.search_results.handle_kb(e); 258 | Command::none() 259 | } 260 | Message::EntryHovered(entry) => { 261 | self.search_results.set_selection(entry); 262 | Command::none() 263 | } 264 | Message::EntrySelected(entry) => self.handle_selection(entry), 265 | Message::StartedIconWorker(worker) => { 266 | worker 267 | .send(icon::IconCommand::LoadSettings( 268 | self.settings.ui.icon.clone(), 269 | )) 270 | .expect("Could not send message to iconworker"); 271 | self.icache.set_cmd(worker); 272 | 273 | Command::none() 274 | } 275 | Message::IconReceived(it, icon) => { 276 | self.icache.add_icon(it, icon); 277 | 278 | store.load_icons(self.search_results.entries(), &mut self.icache); 279 | 280 | Command::none() 281 | } 282 | _ => Command::none(), 283 | } 284 | } 285 | 286 | fn view(&self) -> Element<'_, Message, Renderer> { 287 | use StoreLoadedState::*; 288 | 289 | let ui: Element<_, Renderer> = match &self.store_state { 290 | LoadSucceeded(store, msg) => widget::Column::new() 291 | .push( 292 | TextInput::new(msg, &self.searchtext) 293 | .on_input(Message::SearchTextChanged) 294 | .size(self.settings.ui.search.common.text_size()) 295 | .id(TEXT_INPUT_ID.clone()) 296 | .padding(self.settings.ui.search.padding), 297 | ) 298 | .push( 299 | self.search_results 300 | .view(&self.searchtext, store, Message::EntrySelected), 301 | ) 302 | .into(), 303 | Pending => Text::new("Loading Bookmarks...").into(), 304 | Finished(err) => { 305 | let errtext = Text::new(err.to_string()).shaping(Shaping::Advanced); 306 | let style; 307 | let children; 308 | if let error::Error::FinalMessage(_) = err { 309 | style = theme::ContainerStyle::Transparent; 310 | children = vec![errtext.into()]; 311 | } else { 312 | style = theme::ContainerStyle::Error; 313 | let title = Text::new("Oops, Jolly has encountered an Error...") 314 | .style(iced::theme::Text::Color( 315 | ui::Color::from_str("#D64541").into(), 316 | )) 317 | .size(2 * self.settings.ui.search.common.text_size()); 318 | children = vec![title.into(), errtext.into()]; 319 | } 320 | 321 | let col = widget::Column::with_children(children).spacing(5); 322 | 323 | iced::widget::container::Container::new(col) 324 | .style(style) 325 | .padding(5) 326 | .width(Length::Fill) 327 | .into() 328 | } 329 | }; 330 | 331 | custom::MeasuredContainer::new(ui, Message::DimensionsChanged).into() 332 | } 333 | 334 | fn theme(&self) -> Self::Theme { 335 | self.settings.ui.theme.clone() 336 | } 337 | 338 | fn subscription(&self) -> iced::Subscription { 339 | let channel = subscription::run(icon::icon_worker); 340 | let external = subscription::events().map(Message::ExternalEvent); 341 | subscription::Subscription::batch([channel, external].into_iter()) 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use env_logger::Builder; 2 | use serde::Deserialize; 3 | 4 | use crate::config::one_or_many; 5 | use crate::error; 6 | 7 | #[derive(Deserialize, Debug, Clone, PartialEq, Default)] 8 | #[serde(default)] 9 | pub struct LogSettings { 10 | file: Option, 11 | #[serde(deserialize_with = "one_or_many")] 12 | filters: Vec, 13 | } 14 | 15 | impl LogSettings { 16 | pub fn init_logger(&self) -> Result<(), error::Error> { 17 | self.build_logger().map(|b| { 18 | if let Some(mut b) = b { 19 | b.init() 20 | } 21 | }) 22 | } 23 | 24 | fn build_logger(&self) -> Result, error::Error> { 25 | use env_logger::fmt::Target; 26 | 27 | let mut builder = Builder::new(); 28 | builder 29 | .parse_filters(&self.filters.join(",")) 30 | .format_timestamp_micros(); 31 | 32 | if let Some(filename) = &self.file { 33 | if filename == "stdout" { 34 | builder.target(Target::Stdout); 35 | } else if filename == "stderr" { 36 | builder.target(Target::Stderr); 37 | } else { 38 | let f = std::fs::OpenOptions::new() 39 | .append(true) 40 | .create(true) 41 | .open(filename) 42 | .map_err(|e| error::Error::IoError(self.file.clone(), e))?; 43 | builder.target(env_logger::fmt::Target::Pipe(Box::new(f))); 44 | } 45 | Ok(Some(builder)) 46 | } else { 47 | Ok(None) 48 | } 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::LogSettings; 55 | use crate::error; 56 | use ::log; 57 | use env_logger::Builder; 58 | use log::Log; 59 | use tempfile; 60 | 61 | fn file_logger>(f: F) -> Result { 62 | LogSettings { 63 | file: Some(f.as_ref().to_string_lossy().to_string()), 64 | filters: vec!["trace".into()], 65 | } 66 | .build_logger() 67 | .map(Option::unwrap) 68 | } 69 | 70 | #[test] 71 | fn test_log_appends() { 72 | let dir = tempfile::tempdir().unwrap(); 73 | 74 | let filename = dir.path().join("a"); 75 | 76 | let record = log::RecordBuilder::new().build(); 77 | 78 | for i in 1..3 { 79 | let logger = file_logger(&filename).unwrap().build(); 80 | logger.log(&record); 81 | 82 | std::mem::drop(logger); 83 | 84 | let linecount = std::fs::read_to_string(&filename).unwrap().lines().count(); 85 | 86 | assert_eq!(linecount, i); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | use iced::{Application, Settings}; 4 | use jolly::{cli, config, Jolly}; 5 | use std::process::ExitCode; 6 | use std::time::Instant; 7 | 8 | pub fn main() -> ExitCode { 9 | let custom_config = match cli::parse_args(std::env::args()) { 10 | Ok(c) => c.config, 11 | Err(e) => return e, 12 | }; 13 | 14 | let now = Instant::now(); 15 | 16 | let mut config = if let Some(path) = custom_config { 17 | config::Config::custom_load(path) 18 | } else { 19 | config::Config::load() 20 | }; 21 | 22 | let elapsed = now.elapsed(); 23 | 24 | // if we could not initialize the logger, we set the store to 25 | // error, so the ui shows the issue 26 | if let Err(e) = config.settings.log.init_logger() { 27 | config.store = Err(e); 28 | } 29 | 30 | if let Ok(s) = &config.store { 31 | ::log::debug!( 32 | "Loaded {} entries in {:.6} sec", 33 | s.len(), 34 | elapsed.as_secs_f32() 35 | ); 36 | } 37 | 38 | let mut settings = Settings::default(); 39 | settings.window.size = ( 40 | config.settings.ui.width, 41 | config.settings.ui.search.starting_height(), 42 | ); 43 | settings.window.decorations = false; 44 | settings.window.visible = false; 45 | settings.default_text_size = config.settings.ui.common.text_size().into(); 46 | settings.flags = config; 47 | 48 | Jolly::run(settings) 49 | .map(|_| ExitCode::SUCCESS) 50 | .unwrap_or(ExitCode::FAILURE) 51 | } 52 | -------------------------------------------------------------------------------- /src/platform.rs: -------------------------------------------------------------------------------- 1 | use crate::ui; 2 | use opener; 3 | use std::error; 4 | use std::ffi::OsStr; 5 | use std::fmt; 6 | use std::io; 7 | use std::path::Path; 8 | 9 | #[derive(Debug)] 10 | pub enum Error { 11 | OpenerError(opener::OpenError), 12 | IoError(io::Error), 13 | } 14 | 15 | impl fmt::Display for Error { 16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 17 | match &self { 18 | Error::OpenerError(err) => { 19 | if let opener::OpenError::ExitStatus { stderr: e, .. } = err { 20 | f.write_str(e) 21 | } else { 22 | err.fmt(f) 23 | } 24 | } 25 | Error::IoError(err) => err.fmt(f), 26 | } 27 | } 28 | } 29 | 30 | impl error::Error for Error {} 31 | 32 | const DEFAULT_ACCENT_COLOR: ui::Color = ui::Color(csscolorparser::Color { 33 | r: 0x5E as f64 / 255.0, 34 | g: 0x7C as f64 / 255.0, 35 | b: 0xE2 as f64 / 255.0, 36 | a: 1.0, 37 | }); 38 | 39 | // based on subprocess crate 40 | #[cfg(unix)] 41 | pub(crate) mod os { 42 | use crate::ui; 43 | use std::ffi::OsStr; 44 | use std::process::Command; 45 | 46 | pub const SHELL: [&str; 2] = ["sh", "-c"]; 47 | pub const ACCENT_COLOR: &'static ui::Color = &super::DEFAULT_ACCENT_COLOR; 48 | 49 | // run a subshell and interpret results 50 | pub fn system(cmdstr: impl AsRef) -> std::io::Result { 51 | Command::new(SHELL[0]).args(&SHELL[1..]).arg(cmdstr).spawn() 52 | } 53 | } 54 | 55 | #[cfg(windows)] 56 | pub(crate) mod os { 57 | use crate::ui; 58 | use std::ffi::OsStr; 59 | use std::os::windows::process::CommandExt; 60 | use std::process::Command; 61 | use windows::UI::ViewManagement::{UIColorType, UISettings}; 62 | 63 | pub const SHELL: [&str; 2] = ["cmd.exe", "/c"]; 64 | 65 | // try and get the windows accent color. This wont work for 66 | // windows < 10 67 | fn try_get_color() -> Option { 68 | let settings = UISettings::new().ok()?; 69 | let color = settings.GetColorValue(UIColorType::Accent).ok()?; 70 | Some(ui::Color(csscolorparser::Color::from_rgba8( 71 | color.R, color.G, color.B, 255, 72 | ))) 73 | } 74 | 75 | lazy_static::lazy_static! { 76 | pub static ref ACCENT_COLOR : ui::Color = { 77 | // if we cannot get the windows accent color, default to the unix one 78 | if let Some(color) = try_get_color() { 79 | color 80 | } else { 81 | super::DEFAULT_ACCENT_COLOR 82 | } 83 | }; 84 | } 85 | 86 | // run a subshell and interpret results 87 | pub fn system(cmdstr: impl AsRef) -> std::io::Result { 88 | Command::new(SHELL[0]) 89 | //spawn the command window without a console (CREATE_NO_WINDOW) 90 | // see https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags 91 | .creation_flags(0x08000000) 92 | .args(&SHELL[1..]) 93 | .arg(cmdstr) 94 | .spawn() 95 | } 96 | } 97 | 98 | pub fn system(cmdstr: impl AsRef) -> Result<(), Error> { 99 | os::system(cmdstr).map(|_| ()).map_err(Error::IoError) 100 | } 101 | 102 | pub fn accent_color() -> ui::Color { 103 | os::ACCENT_COLOR.clone() 104 | } 105 | 106 | pub fn open_file>(path: P) -> Result<(), Error> { 107 | opener::open(path.as_ref().as_os_str()).map_err(Error::OpenerError) 108 | } 109 | -------------------------------------------------------------------------------- /src/search_results.rs: -------------------------------------------------------------------------------- 1 | use iced::{advanced, keyboard, widget}; 2 | 3 | use crate::custom; 4 | use crate::entry; 5 | use crate::store; 6 | use crate::theme; 7 | use crate::ui; 8 | 9 | const PADDING: u16 = 2; 10 | 11 | #[derive(Default)] 12 | pub struct SearchResults { 13 | entries: Vec, 14 | selected: usize, 15 | settings: ui::UISettings, 16 | } 17 | 18 | impl std::hash::Hash for SearchResults { 19 | fn hash(&self, state: &mut H) 20 | where 21 | H: std::hash::Hasher, 22 | { 23 | self.entries.hash(state); 24 | self.selected.hash(state); 25 | } 26 | } 27 | 28 | impl std::cmp::PartialEq for SearchResults { 29 | fn eq(&self, other: &Self) -> bool { 30 | use std::hash::{Hash, Hasher}; 31 | 32 | let mut s = std::collections::hash_map::DefaultHasher::new(); 33 | self.hash(&mut s); 34 | 35 | let mut o = std::collections::hash_map::DefaultHasher::new(); 36 | other.hash(&mut o); 37 | s.finish() == o.finish() 38 | } 39 | } 40 | 41 | impl SearchResults { 42 | pub fn new(results: impl Iterator, settings: &ui::UISettings) -> Self { 43 | SearchResults { 44 | entries: results.take(settings.max_results).collect(), 45 | selected: 0, 46 | settings: settings.clone(), 47 | } 48 | } 49 | 50 | pub fn set_selection(&mut self, id: entry::EntryId) { 51 | if id < self.entries.len() { 52 | self.selected = id; 53 | } 54 | } 55 | 56 | pub fn selected(&self) -> Option { 57 | self.entries.get(self.selected).map(|e| *e) 58 | } 59 | 60 | pub fn handle_kb(&mut self, event: keyboard::Event) { 61 | let code = match event { 62 | keyboard::Event::KeyPressed { 63 | key_code: code, 64 | modifiers: _, 65 | } => code, 66 | _ => return, 67 | }; 68 | 69 | if code == keyboard::KeyCode::Up { 70 | if self.selected > 0 { 71 | self.selected -= 1; 72 | } 73 | } 74 | if code == keyboard::KeyCode::Down { 75 | let max_num = self.entries.len(); 76 | if self.selected + 1 < max_num { 77 | self.selected += 1; 78 | } 79 | } 80 | } 81 | 82 | pub fn view<'a, F, Renderer>( 83 | &'a self, 84 | searchtext: &str, 85 | store: &'a store::Store, 86 | f: F, 87 | ) -> iced::Element<'a, crate::Message, Renderer> 88 | where 89 | F: 'static + Copy + Fn(entry::EntryId) -> crate::Message, 90 | Renderer: advanced::renderer::Renderer + 'a, 91 | Renderer: advanced::text::Renderer, 92 | Renderer: advanced::image::Renderer, 93 | { 94 | // if we dont have any entries, return an empty search results 95 | // (if we dont do this, the empty column will still show its 96 | // padding 97 | if self.entries.is_empty() { 98 | return widget::Space::with_height(0).into(); 99 | } 100 | 101 | let mut column = widget::Column::new().padding(PADDING); 102 | for (i, e) in self.entries.iter().enumerate() { 103 | let entry = store.get(*e); 104 | // unwrap will never panic since UI_MAX_RESULTS is const 105 | let entry_widget = 106 | entry.build_entry(f, searchtext, &self.settings, i == self.selected, *e); 107 | 108 | let mouse_area = custom::MouseArea::new(entry_widget) 109 | .on_mouse_enter(crate::Message::EntryHovered(i)); 110 | 111 | column = column.push(mouse_area); 112 | } 113 | let element: iced::Element<'_, _, _> = column.into(); 114 | element 115 | } 116 | 117 | pub fn entries(&self) -> &[entry::EntryId] { 118 | &self.entries 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::{log, ui}; 2 | use serde; 3 | 4 | #[derive(serde::Deserialize, Debug, Clone, PartialEq, Default)] 5 | #[serde(default)] 6 | pub struct Settings { 7 | pub ui: ui::UISettings, 8 | pub log: log::LogSettings, 9 | } 10 | -------------------------------------------------------------------------------- /src/store.rs: -------------------------------------------------------------------------------- 1 | // contains logic to parse the saved links for jolly 2 | // basic format of jolly storage: 3 | // 4 | // ['filename.txt'] # filename or name for bookmark, can also contain path 5 | // tags = ['foo', 'text', 'baz'] 6 | // location = '/optional/path/to/filename' 7 | // url = 'http://example.com' #can contain mozilla style query string (single %s). Defaults to escape = true 8 | // system = 'cmd to run'# can contain mozilla style query string (single %s) 9 | // keyword = 'k' # keyword used for mozilla style query strings 10 | // escape = true # only valid for keyword entries, determines if query string is escaped. 11 | 12 | use toml; 13 | 14 | use crate::{entry, icon}; 15 | 16 | #[derive(Debug, Default, Clone)] 17 | pub struct Store { 18 | entries: Vec, 19 | } 20 | 21 | impl Store { 22 | pub fn build<'a, E: Iterator>( 23 | serialized_entries: E, 24 | ) -> Result { 25 | Ok(Store { 26 | entries: serialized_entries 27 | .map(|(k, v)| entry::StoreEntry::from_value(k, v)) 28 | .collect::, _>>()?, 29 | }) 30 | } 31 | 32 | pub fn get(&self, id: entry::EntryId) -> &entry::StoreEntry { 33 | &self.entries[id] 34 | } 35 | 36 | pub fn get_mut(&mut self, id: entry::EntryId) -> &mut entry::StoreEntry { 37 | &mut self.entries[id] 38 | } 39 | 40 | pub fn find_matches(&self, query: &str) -> Vec { 41 | // get indicies of all entries with scores greater than zero 42 | let mut matches: Vec<_> = self 43 | .entries 44 | .iter() 45 | .map(|entry| entry.score(query)) 46 | .enumerate() 47 | .filter(|s| s.1 > 0) 48 | .rev() // flip order: now we prefer LAST entries in file 49 | .collect::>(); 50 | 51 | // sort by score 52 | matches.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); 53 | 54 | // get references to entries in sorted order 55 | matches.iter().map(|s| s.0).collect() 56 | } 57 | 58 | pub fn load_icons(&mut self, entries: &[entry::EntryId], icache: &mut icon::IconCache) { 59 | for e in entries { 60 | let entry = &mut self.entries[*e]; 61 | if !entry.icon_loaded() { 62 | if let Some(icon) = icache.get(&entry.icontype()) { 63 | entry.icon(icon); 64 | } 65 | } 66 | } 67 | } 68 | 69 | pub fn len(&self) -> usize { 70 | self.entries.len() 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | pub mod tests { 76 | use super::*; 77 | 78 | pub fn parse_store(text: &str) -> Result { 79 | let value: toml::Value = toml::from_str(text).unwrap(); 80 | 81 | if let toml::Value::Table(table) = value { 82 | Store::build(table.into_iter()) 83 | } else { 84 | panic!("Toml is not a Table") 85 | } 86 | } 87 | 88 | #[test] 89 | fn parse_empty_file() { 90 | let store = parse_store("").unwrap(); 91 | assert_eq!(store.entries.len(), 0) 92 | } 93 | 94 | #[test] 95 | fn find_entries() { 96 | let toml = r#"['foo'] 97 | tags = ["foo", 'bar', 'quu'] 98 | location = "test/location" 99 | 100 | ['asdf'] 101 | tags = ["bar", "quux"] 102 | location = "test/location""#; 103 | 104 | let store = parse_store(toml).unwrap(); 105 | 106 | let tests = [ 107 | ("fo", vec!["foo"]), 108 | ("foo", vec!["foo"]), 109 | ("bar", vec!["asdf", "foo"]), // all things being equal, prefer "newer" entries 110 | ("asd", vec!["asdf"]), 111 | ("asdf", vec!["asdf"]), 112 | ("quu", vec!["foo", "asdf"]), // since quu is a full match for foo entry, it ranks higher 113 | ("quux", vec!["asdf"]), 114 | ("", vec![]), 115 | ]; 116 | 117 | for (query, results) in tests { 118 | let matches = store.find_matches(query); 119 | assert_eq!( 120 | results.len(), 121 | matches.len(), 122 | "test: {} -> {:?}", 123 | query, 124 | results 125 | ); 126 | 127 | let r_entries = results.into_iter().map(|e| { 128 | store 129 | .entries 130 | .iter() 131 | .find(|e2| e2.format_name(query) == e) 132 | .unwrap() 133 | }); 134 | 135 | for (l, r) in matches.into_iter().zip(r_entries) { 136 | let l = store.get(l); 137 | assert_eq!( 138 | l, 139 | r, 140 | "lscore: {} rscore: {}", 141 | l.score(query), 142 | r.score(query) 143 | ); 144 | } 145 | } 146 | } 147 | 148 | #[test] 149 | fn bare_keys_not_allowed() { 150 | let toml = r#"bare_key = 42"#; 151 | let text = parse_store(toml); 152 | assert!( 153 | matches!(text, Err(entry::Error::ParseError(_))), 154 | "{:?}", 155 | text 156 | ) 157 | } 158 | 159 | #[test] 160 | fn parse_error() { 161 | let tests = [ 162 | r#"['asdf'] 163 | location = 1"#, 164 | r#"['asdf'] 165 | url = 1"#, 166 | r#"['asdf'] 167 | system = 1"#, 168 | r#"['asdf'] 169 | keyword = 1"#, 170 | r#"['asdf'] 171 | escape = 1"#, 172 | r#"['asdf'] 173 | tags = 1"#, 174 | r#"['asdf'] 175 | tags = 'foo'"#, 176 | ]; 177 | 178 | for toml in tests { 179 | assert!(matches!( 180 | parse_store(&toml), 181 | Err(entry::Error::ParseError(_)) 182 | )); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | // contains theme definition for Jolly 2 | use crate::{platform, ui}; 3 | use iced::application; 4 | use iced::overlay::menu; 5 | use iced::widget::button; 6 | use iced::widget::container; 7 | use iced::widget::text; 8 | use iced::widget::text_input; 9 | use serde; 10 | use serde::de::{self, DeserializeSeed, Deserializer, Error, IntoDeserializer, MapAccess, Visitor}; 11 | use serde::Deserialize; 12 | use std::fmt; 13 | use toml; 14 | 15 | #[derive(Debug, Copy, Clone, PartialEq, Deserialize)] 16 | #[serde(rename_all = "lowercase")] 17 | pub enum DefaultTheme { 18 | Light, 19 | Dark, 20 | } 21 | 22 | impl Default for DefaultTheme { 23 | fn default() -> Self { 24 | use lazy_static::lazy_static; 25 | // store default theme in lazy static to avoid generating it more than once 26 | lazy_static! { 27 | static ref DEFAULT_THEME: DefaultTheme = 28 | if dark_light::detect() == dark_light::Mode::Dark { 29 | DefaultTheme::Dark 30 | } else { 31 | DefaultTheme::Light 32 | }; 33 | } 34 | *DEFAULT_THEME 35 | } 36 | } 37 | 38 | // themes for jolly are based on iced themes with some weird 39 | // differences. there is a secret implicit Default Theme that is 40 | // deserialized from jolly.toml before the theme is. this allows us 41 | // to use either the dark or light theme as the "base theme" where any 42 | // set parameters in the theme override those colors 43 | #[derive(Debug, Clone, PartialEq)] 44 | pub struct Theme { 45 | pub background_color: ui::Color, 46 | pub text_color: ui::Color, 47 | pub accent_color: ui::Color, 48 | pub selected_text_color: ui::Color, 49 | } 50 | 51 | impl Theme { 52 | fn palette(&self) -> iced::theme::palette::Palette { 53 | iced::theme::palette::Palette { 54 | background: self.background_color.clone().into(), 55 | text: self.text_color.clone().into(), 56 | primary: self.accent_color.clone().into(), 57 | success: self.accent_color.clone().into(), 58 | danger: self.accent_color.clone().into(), 59 | } 60 | } 61 | 62 | fn extended_palette(&self) -> iced::theme::palette::Extended { 63 | iced::theme::palette::Extended::generate(self.palette()) 64 | } 65 | } 66 | 67 | impl Default for Theme { 68 | fn default() -> Self { 69 | DefaultTheme::default().into() 70 | } 71 | } 72 | 73 | impl<'de> de::DeserializeSeed<'de> for DefaultTheme { 74 | type Value = Theme; 75 | 76 | fn deserialize(self, deserializer: D) -> Result 77 | where 78 | D: Deserializer<'de>, 79 | { 80 | const FIELDS: &'static [&'static str] = &["background", "text", "primary"]; 81 | deserializer.deserialize_struct("Theme", FIELDS, self) 82 | } 83 | } 84 | 85 | impl<'de> Visitor<'de> for DefaultTheme { 86 | type Value = Theme; 87 | 88 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 89 | formatter.write_str("struct Theme") 90 | } 91 | 92 | fn visit_map(self, mut map: V) -> Result 93 | where 94 | V: MapAccess<'de>, 95 | { 96 | #[derive(Deserialize, Debug)] 97 | #[serde(rename_all = "lowercase")] 98 | enum Field { 99 | #[serde(rename = "background_color")] 100 | BackgroundColor, 101 | #[serde(rename = "text_color")] 102 | TextColor, 103 | #[serde(rename = "accent_color")] 104 | AccentColor, 105 | #[serde(rename = "selected_text_color")] 106 | SelectedTextColor, 107 | #[serde(other)] 108 | Other, 109 | } 110 | 111 | let mut theme: Self::Value = self.into(); 112 | let mut background_visited = false; 113 | let mut text_visited = false; 114 | let mut selected_text_visited = false; 115 | let mut accent_visited = false; 116 | while let Some(key) = map.next_key()? { 117 | match key { 118 | Field::BackgroundColor => { 119 | if background_visited { 120 | return Err(de::Error::duplicate_field("background_color")); 121 | } 122 | background_visited = true; 123 | theme.background_color = map.next_value()?; 124 | } 125 | Field::TextColor => { 126 | if text_visited { 127 | return Err(de::Error::duplicate_field("text_color")); 128 | } 129 | text_visited = true; 130 | theme.text_color = map.next_value()?; 131 | } 132 | Field::AccentColor => { 133 | if accent_visited { 134 | return Err(de::Error::duplicate_field("accent_color")); 135 | } 136 | accent_visited = true; 137 | theme.accent_color = map.next_value()?; 138 | } 139 | Field::SelectedTextColor => { 140 | if selected_text_visited { 141 | return Err(de::Error::duplicate_field("selected_text_color")); 142 | } 143 | selected_text_visited = true; 144 | theme.selected_text_color = map.next_value()?; 145 | } 146 | Field::Other => {} 147 | } 148 | } 149 | Ok(theme) 150 | } 151 | } 152 | 153 | impl<'de> de::Deserialize<'de> for Theme { 154 | fn deserialize(deserializer: D) -> Result 155 | where 156 | D: Deserializer<'de>, 157 | { 158 | let map = toml::Value::deserialize(deserializer)?; 159 | let mut map = if let toml::Value::Table(map) = map { 160 | map 161 | } else { 162 | return Err(D::Error::custom("table")); 163 | }; 164 | let default = if let Some(theme) = map.remove("base") { 165 | ::deserialize(theme.into_deserializer()) 166 | .map_err(D::Error::custom)? 167 | } else { 168 | Default::default() 169 | }; 170 | 171 | default 172 | .deserialize(toml::Value::Table(map)) 173 | .map_err(D::Error::custom) 174 | } 175 | } 176 | 177 | // convert default theme enum into appropriate jolly default theme 178 | impl From for Theme { 179 | fn from(f: DefaultTheme) -> Self { 180 | match f { 181 | DefaultTheme::Light => Theme { 182 | background_color: ui::Color::from_str("white"), 183 | text_color: ui::Color::from_str("black"), 184 | accent_color: platform::accent_color(), 185 | selected_text_color: ui::Color::from_str("white"), 186 | }, 187 | 188 | DefaultTheme::Dark => Theme { 189 | background_color: ui::Color::from_str("#202225"), 190 | text_color: ui::Color::from_str("B3B3B3"), 191 | accent_color: platform::accent_color(), 192 | selected_text_color: ui::Color::from_str("black"), 193 | }, 194 | } 195 | } 196 | } 197 | // text_input::StyleSheet 198 | // menu::StyleSheet 199 | // application::Stylesheet 200 | 201 | impl menu::StyleSheet for Theme { 202 | type Style = (); 203 | 204 | fn appearance(&self, _style: &Self::Style) -> menu::Appearance { 205 | let palette = self.extended_palette(); 206 | 207 | menu::Appearance { 208 | text_color: palette.background.base.text, 209 | background: palette.background.weak.color.into(), 210 | border_width: 1.0, 211 | border_radius: 0.0.into(), 212 | border_color: palette.background.strong.color, 213 | selected_text_color: self.selected_text_color.clone().into(), 214 | selected_background: palette.primary.base.color.into(), 215 | } 216 | } 217 | } 218 | 219 | // Stylesheets for Jolly are copied almost exactly verbatim from the iced theme, except for 2 differences 220 | // 221 | // + no support for custom themes per widget (style is nil) 222 | // 223 | // + colors are tweaked to emphasize primary.base color in palettes 224 | // instead of primary.strong. This is so that jolly themes can set an 225 | // accent color that matches their window manager. 226 | impl application::StyleSheet for Theme { 227 | type Style = (); 228 | 229 | fn appearance(&self, _style: &Self::Style) -> application::Appearance { 230 | let palette = self.extended_palette(); 231 | application::Appearance { 232 | background_color: palette.background.base.color, 233 | text_color: palette.background.base.text, 234 | } 235 | } 236 | } 237 | 238 | impl text::StyleSheet for Theme { 239 | type Style = iced::theme::Text; 240 | fn appearance(&self, style: Self::Style) -> text::Appearance { 241 | let color = match style { 242 | iced::theme::Text::Default => Some(self.text_color.clone().into()), 243 | iced::theme::Text::Color(c) => Some(c), 244 | }; 245 | text::Appearance { color: color } 246 | } 247 | } 248 | 249 | #[derive(Default)] 250 | pub enum ButtonStyle { 251 | #[default] 252 | Transparent, 253 | Selected, 254 | } 255 | 256 | impl button::StyleSheet for Theme { 257 | type Style = ButtonStyle; 258 | fn active(&self, style: &Self::Style) -> button::Appearance { 259 | match style { 260 | ButtonStyle::Transparent => button::Appearance { 261 | shadow_offset: iced::Vector::default(), 262 | text_color: self.text_color.clone().into(), 263 | background: None, 264 | border_radius: 0.0.into(), 265 | border_width: 0.0, 266 | border_color: iced::Color::TRANSPARENT, 267 | }, 268 | 269 | ButtonStyle::Selected => { 270 | let accent_color: iced::Color = self.accent_color.clone().into(); 271 | button::Appearance { 272 | shadow_offset: iced::Vector::default(), 273 | text_color: self.selected_text_color.clone().into(), 274 | background: Some(accent_color.into()), 275 | border_radius: 5.0.into(), 276 | border_width: 1.0, 277 | border_color: iced::Color::TRANSPARENT, 278 | } 279 | } 280 | } 281 | } 282 | } 283 | 284 | #[derive(Default)] 285 | pub enum ContainerStyle { 286 | #[default] 287 | Transparent, 288 | Selected, 289 | Error, 290 | } 291 | 292 | impl container::StyleSheet for Theme { 293 | type Style = ContainerStyle; 294 | fn appearance(&self, style: &Self::Style) -> container::Appearance { 295 | match style { 296 | ContainerStyle::Transparent => iced::Theme::default().appearance(&Default::default()), 297 | 298 | ContainerStyle::Selected => { 299 | let accent_color: iced::Color = self.accent_color.clone().into(); 300 | container::Appearance { 301 | text_color: Some(self.selected_text_color.clone().into()), 302 | background: Some(accent_color.into()), 303 | border_radius: 5.0.into(), 304 | border_width: 1.0, 305 | border_color: iced::Color::TRANSPARENT, 306 | } 307 | } 308 | 309 | ContainerStyle::Error => { 310 | let bg_color: iced::Color = self.background_color.clone().into(); 311 | container::Appearance { 312 | text_color: Some(self.text_color.clone().into()), 313 | background: Some(bg_color.into()), 314 | border_radius: 5.0.into(), 315 | border_width: 2.0, 316 | border_color: ui::Color::from_str("#D64541").into(), 317 | } 318 | } 319 | } 320 | } 321 | } 322 | 323 | impl text_input::StyleSheet for Theme { 324 | type Style = (); 325 | 326 | // not used by jolly 327 | fn disabled_color( 328 | &self, 329 | _: &::Style, 330 | ) -> iced::Color { 331 | todo!() 332 | } 333 | 334 | // not used by jolly 335 | fn disabled( 336 | &self, 337 | _: &::Style, 338 | ) -> iced::widget::text_input::Appearance { 339 | todo!() 340 | } 341 | 342 | fn active(&self, _style: &Self::Style) -> text_input::Appearance { 343 | let palette = self.extended_palette(); 344 | 345 | text_input::Appearance { 346 | background: palette.background.base.color.into(), 347 | border_radius: 2.0.into(), 348 | border_width: 1.0, 349 | border_color: palette.background.strong.color, 350 | icon_color: Default::default(), 351 | } 352 | } 353 | 354 | fn hovered(&self, _style: &Self::Style) -> text_input::Appearance { 355 | let palette = self.extended_palette(); 356 | 357 | text_input::Appearance { 358 | background: palette.background.base.color.into(), 359 | border_radius: 2.0.into(), 360 | border_width: 1.0, 361 | border_color: palette.background.base.text, 362 | icon_color: Default::default(), 363 | } 364 | } 365 | 366 | fn focused(&self, _style: &Self::Style) -> text_input::Appearance { 367 | let palette = self.extended_palette(); 368 | 369 | text_input::Appearance { 370 | background: palette.background.base.color.into(), 371 | border_radius: 2.0.into(), 372 | border_width: 1.0, 373 | border_color: palette.primary.base.color, 374 | icon_color: Default::default(), 375 | } 376 | } 377 | 378 | fn placeholder_color(&self, _style: &Self::Style) -> iced::Color { 379 | let palette = self.extended_palette(); 380 | 381 | palette.background.strong.color 382 | } 383 | 384 | fn value_color(&self, _style: &Self::Style) -> iced::Color { 385 | let palette = self.extended_palette(); 386 | 387 | palette.background.base.text 388 | } 389 | 390 | fn selection_color(&self, _style: &Self::Style) -> iced::Color { 391 | let palette = self.extended_palette(); 392 | 393 | palette.primary.weak.color 394 | } 395 | } 396 | 397 | #[cfg(test)] 398 | mod tests { 399 | use super::*; 400 | 401 | #[test] 402 | fn deserialize_all_fields() { 403 | let theme = Theme::default(); 404 | 405 | assert_eq!(theme, toml::from_str("").unwrap()); 406 | 407 | let custom = Theme { 408 | background_color: ui::Color::from_str("red"), 409 | text_color: ui::Color::from_str("orange"), 410 | accent_color: ui::Color::from_str("yellow"), 411 | selected_text_color: ui::Color::from_str("green"), 412 | }; 413 | 414 | let toml = r#" 415 | background_color = "red" 416 | text_color = "orange" 417 | accent_color = "yellow" 418 | selected_text_color = "green" 419 | "#; 420 | 421 | assert_eq!(custom, toml::from_str(toml).unwrap()); 422 | } 423 | 424 | #[test] 425 | fn set_dark_theme() { 426 | let toml = r#" 427 | base = "dark" 428 | "#; 429 | let theme: Theme = DefaultTheme::Dark.into(); 430 | 431 | assert_eq!(theme, toml::from_str(toml).unwrap()); 432 | } 433 | 434 | #[test] 435 | fn set_light_theme() { 436 | let toml = r#" 437 | base = "light" 438 | "#; 439 | let theme: Theme = DefaultTheme::Light.into(); 440 | 441 | assert_eq!(theme, toml::from_str(toml).unwrap()); 442 | } 443 | 444 | #[test] 445 | fn override_custom_default() { 446 | let toml = r#" 447 | base = "dark" 448 | accent_color = "purple" 449 | "#; 450 | 451 | let mut theme: Theme = DefaultTheme::Dark.into(); 452 | 453 | theme.accent_color = ui::Color::from_str("purple"); 454 | 455 | assert_eq!(theme, toml::from_str(toml).unwrap()); 456 | } 457 | 458 | #[test] 459 | fn accent_color_used_for_theme() { 460 | // test that the major accent color we use actually shows up in the theme. 461 | // default iced theme uses tweaked colors that dont match 462 | use iced::overlay::menu::StyleSheet; 463 | use iced::widget::text_input::StyleSheet as TextStyleSheet; 464 | let toml = r#" 465 | accent_color = "darkblue" 466 | "#; 467 | 468 | let color: iced::Color = ui::Color::from_str("darkblue").into(); 469 | 470 | let theme: Theme = toml::from_str(toml).unwrap(); 471 | 472 | let menu_appearance: menu::Appearance = theme.appearance(&()); 473 | let text_appearance: text_input::Appearance = theme.focused(&()); 474 | 475 | assert_eq!( 476 | iced::Background::Color(color), 477 | menu_appearance.selected_background.into() 478 | ); 479 | 480 | assert_eq!(color, text_appearance.border_color); 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | // eventually the jolly main window logic will move here out of main 2 | // but for now it will just hold settings. 3 | 4 | use crate::{entry, icon, theme}; 5 | use csscolorparser; 6 | use iced; 7 | use serde; 8 | use serde::de::value::{StrDeserializer, StringDeserializer}; 9 | use serde::Deserialize; 10 | 11 | #[derive(serde::Deserialize, Debug, Clone, PartialEq)] 12 | #[serde(default)] 13 | pub struct UISettings { 14 | pub width: u32, 15 | 16 | pub theme: theme::Theme, 17 | 18 | #[serde(flatten)] 19 | pub common: InheritedSettings, 20 | 21 | pub search: SearchSettings, 22 | pub entry: entry::EntrySettings, 23 | pub max_results: usize, 24 | pub icon: icon::IconSettings, 25 | } 26 | 27 | #[derive(serde::Deserialize, Debug, Clone, PartialEq, Default)] 28 | #[serde(default)] 29 | pub struct InheritedSettings { 30 | text_size: Option, 31 | } 32 | 33 | impl InheritedSettings { 34 | pub fn text_size(&self) -> u16 { 35 | match self.text_size { 36 | Some(t) => t, 37 | None => 20, 38 | } 39 | } 40 | 41 | pub fn propagate(&mut self, parent: &Self) { 42 | if self.text_size.is_none() { 43 | self.text_size = parent.text_size; 44 | } 45 | } 46 | } 47 | 48 | impl UISettings { 49 | // initial "fixing" of parameters 50 | pub fn propagate(&mut self) { 51 | self.entry.propagate(&self.common); 52 | self.search.propagate(&self.common); 53 | } 54 | } 55 | 56 | impl Default for UISettings { 57 | fn default() -> Self { 58 | Self { 59 | width: 800, 60 | theme: Default::default(), 61 | common: InheritedSettings::default(), 62 | search: SearchSettings::default(), 63 | entry: Default::default(), 64 | max_results: 5, 65 | icon: Default::default(), 66 | } 67 | } 68 | } 69 | 70 | // theme settings for the search window at that top of the screen 71 | #[derive(serde::Deserialize, Debug, Clone, PartialEq)] 72 | #[serde(default)] 73 | pub struct SearchSettings { 74 | pub padding: u16, 75 | #[serde(flatten)] 76 | pub common: InheritedSettings, 77 | } 78 | 79 | impl SearchSettings { 80 | pub fn starting_height(&self) -> u32 { 81 | (self.common.text_size() + 2 * self.padding).into() 82 | } 83 | 84 | fn propagate(&mut self, parent: &InheritedSettings) { 85 | self.common.propagate(parent); 86 | } 87 | } 88 | 89 | impl Default for SearchSettings { 90 | fn default() -> Self { 91 | Self { 92 | padding: 10, 93 | common: Default::default(), 94 | } 95 | } 96 | } 97 | 98 | #[derive(Debug, Clone, PartialEq, Default)] 99 | pub struct Color(pub csscolorparser::Color); 100 | 101 | impl Color { 102 | // panics if input is malformed. so best used for compile time colors 103 | pub fn from_str(s: &str) -> Self { 104 | Color::deserialize(StrDeserializer::::new(s)).unwrap() 105 | } 106 | } 107 | 108 | // use a custom deserializer to provide more info that we dont understand a color 109 | impl<'de> serde::Deserialize<'de> for Color { 110 | fn deserialize(deserializer: D) -> Result 111 | where 112 | D: serde::Deserializer<'de>, 113 | { 114 | use serde::de::Error; 115 | 116 | // deserialize as string first so error message can reference the text 117 | let text = String::deserialize(deserializer)?; 118 | let error = D::Error::custom(format!("Cannot parse color `{}`", &text)); 119 | 120 | let string_deserializer: StringDeserializer = StringDeserializer::new(text); 121 | 122 | csscolorparser::Color::deserialize(string_deserializer) 123 | .map(Self) 124 | .map_err(|_: _| error) 125 | } 126 | } 127 | 128 | impl From for iced::Color { 129 | fn from(value: Color) -> Self { 130 | Self { 131 | r: value.0.r as _, 132 | g: value.0.g as _, 133 | b: value.0.b as _, 134 | a: value.0.a as _, 135 | } 136 | } 137 | } 138 | 139 | #[cfg(test)] 140 | mod tests { 141 | use super::*; 142 | 143 | #[test] 144 | fn missing_inherited_use_parent() { 145 | let mut child = InheritedSettings::default(); 146 | let parent = InheritedSettings { 147 | text_size: Some(99), 148 | ..Default::default() 149 | }; 150 | 151 | assert_ne!(child.text_size(), parent.text_size()); 152 | 153 | child.propagate(&parent); 154 | 155 | assert_eq!(child.text_size(), parent.text_size()); 156 | } 157 | } 158 | --------------------------------------------------------------------------------