├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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