├── .changes ├── 0.2.0.md ├── 0.3.0.md ├── 0.4.0.md ├── 0.5.0.md ├── 0.6.0.md ├── 0.6.1.md ├── 0.6.2.md ├── 0.7.0.md ├── 0.7.1.md ├── 0.8.0.md ├── 0.8.1.md ├── 0.8.2.md ├── config.json ├── initial-release.md └── readme.md ├── .github └── workflows │ ├── audit.yml │ ├── covector-status.yml │ ├── format.yml │ └── test.yml ├── .gitignore ├── Cargo.toml ├── LICENSE.md ├── README.md ├── assets └── screenshot.png ├── examples ├── multi │ ├── .gitignore │ ├── package.json │ ├── public │ │ ├── index.html │ │ └── index.js │ └── src-tauri │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── assets │ │ └── 16x16.png │ │ ├── build.rs │ │ ├── icons │ │ ├── 128x128.png │ │ ├── 128x128@2x.png │ │ ├── 32x32.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ │ ├── rustfmt.toml │ │ ├── src │ │ └── main.rs │ │ └── tauri.conf.json ├── ts-utility │ ├── .gitignore │ ├── esbuild.js │ ├── package.json │ ├── public │ │ └── index.html │ ├── src-tauri │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── assets │ │ │ └── 16x16.png │ │ ├── build.rs │ │ ├── icons │ │ │ ├── 128x128.png │ │ │ ├── 128x128@2x.png │ │ │ ├── 32x32.png │ │ │ ├── icon.icns │ │ │ ├── icon.ico │ │ │ └── icon.png │ │ ├── rustfmt.toml │ │ ├── src │ │ │ └── main.rs │ │ └── tauri.conf.json │ ├── src │ │ ├── index.d.ts │ │ └── index.ts │ └── tsconfig.json └── vanilla │ ├── .gitignore │ ├── package.json │ ├── public │ ├── index.html │ └── index.js │ └── src-tauri │ ├── .gitignore │ ├── Cargo.toml │ ├── assets │ └── 16x16.png │ ├── build.rs │ ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png │ ├── rustfmt.toml │ ├── src │ └── main.rs │ └── tauri.conf.json ├── lerna.json ├── package.json ├── plugin ├── dist │ ├── index.d.ts │ ├── index.js │ ├── index.js.map │ ├── index.spec.d.ts │ └── types.d.ts ├── esbuild.js ├── index.spec.ts ├── index.ts ├── jest.config.cjs ├── package.json ├── tsconfig.json └── types.ts └── src ├── keymap.rs ├── lib.rs ├── linux.rs ├── macos.rs ├── macos_window_holder.rs ├── menu_item.rs ├── theme.rs ├── win.rs └── win_image_handler.rs /.changes/0.2.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "major" 3 | --- 4 | 5 | - Add separator support on MacOS 6 | - Add menuDidClose event on MacOS 7 | - Add icon support on MacOS 8 | - Fix disabled subitems on MacOS 9 | - Update Tauri to 1.4 10 | -------------------------------------------------------------------------------- /.changes/0.3.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "major" 3 | --- 4 | 5 | - Add support for Windows 6 | -------------------------------------------------------------------------------- /.changes/0.4.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | - Add icon support on Windows 6 | - Add shortcut support on Windows 7 | - Add icon-width and icon-height support 8 | - Add is_absolute option to Position 9 | 10 | ### Upgrade from 0.x.x 11 | - Change `icon_path` option to `icon.path` -------------------------------------------------------------------------------- /.changes/0.5.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | - Add payload support #3 6 | - Support more keycodes #6 7 | - Update Tauri to 1.5.0 8 | 9 | ### Upgrade from 0.x.x 10 | - Change `icon_path` option to `icon.path` -------------------------------------------------------------------------------- /.changes/0.6.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | - Add linux support -------------------------------------------------------------------------------- /.changes/0.6.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "patch" 3 | --- 4 | 5 | - Fix #16 -------------------------------------------------------------------------------- /.changes/0.6.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "patch" 3 | --- 4 | 5 | - Fix event warning on linux -------------------------------------------------------------------------------- /.changes/0.7.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | - Add checked items support #10 6 | - Refactoring -------------------------------------------------------------------------------- /.changes/0.7.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "patch" 3 | --- 4 | 5 | - Fix #21 -------------------------------------------------------------------------------- /.changes/0.8.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | - Add support for `theme` option 6 | - Update Tauri to 1.7 -------------------------------------------------------------------------------- /.changes/0.8.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "patch" 3 | --- 4 | - Fix support for external display on MacOS #25 -------------------------------------------------------------------------------- /.changes/0.8.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "patch" 3 | --- 4 | - Fix window crashing on Windows when unfocused #29 -------------------------------------------------------------------------------- /.changes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitSiteUrl": "https://www.github.com/your-org/tauri-plugin-context-menu/", 3 | "pkgManagers": { 4 | "rust": { 5 | "version": true, 6 | "getPublishedVersion": "cargo search ${ pkg.pkg } --limit 1 | sed -nE 's/^[^\"]*\"//; s/\".*//1p' -", 7 | "prepublish": [ 8 | "sudo apt-get update", 9 | "sudo apt-get install -y webkit2gtk-4.0", 10 | "cargo install cargo-audit", 11 | { 12 | "command": "cargo generate-lockfile", 13 | "dryRunCommand": true, 14 | "pipe": true 15 | }, 16 | { 17 | "command": "echo '
\n

Cargo Audit

\n\n```'", 18 | "dryRunCommand": true, 19 | "pipe": true 20 | }, 21 | { 22 | "command": "cargo audit ${ process.env.CARGO_AUDIT_OPTIONS || '' }", 23 | "dryRunCommand": true, 24 | "pipe": true 25 | }, 26 | { 27 | "command": "echo '```\n\n
\n'", 28 | "dryRunCommand": true, 29 | "pipe": true 30 | } 31 | ], 32 | "publish": [ 33 | { 34 | "command": "cargo package --no-verify", 35 | "dryRunCommand": true 36 | }, 37 | { 38 | "command": "echo '
\n

Cargo Publish

\n\n```'", 39 | "dryRunCommand": true, 40 | "pipe": true 41 | }, 42 | { 43 | "command": "cargo publish", 44 | "dryRunCommand": "cargo publish --dry-run", 45 | "pipe": true 46 | }, 47 | { 48 | "command": "echo '```\n\n
\n'", 49 | "dryRunCommand": true, 50 | "pipe": true 51 | } 52 | ] 53 | } 54 | }, 55 | "packages": { 56 | "tauri-plugin-context-menu": { 57 | "path": ".", 58 | "manager": "rust", 59 | "releaseTag": "v${ pkgFile.version }" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.changes/initial-release.md: -------------------------------------------------------------------------------- 1 | --- 2 | "tauri-plugin-context-menu": "minor" 3 | --- 4 | 5 | Initial release. 6 | -------------------------------------------------------------------------------- /.changes/readme.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | ##### via https://github.com/jbolda/covector 3 | 4 | As you create PRs and make changes that require a version bump, please add a new markdown file in this folder. You do not note the version *number*, but rather the type of bump that you expect: major, minor, or patch. The filename is not important, as long as it is a `.md`, but we recommend it represents the overall change for our sanity. 5 | 6 | When you select the version bump required, you do *not* need to consider dependencies. Only note the package with the actual change, and any packages that depend on that package will be bumped automatically in the process. 7 | 8 | Use the following format: 9 | ```md 10 | --- 11 | "tauri-plugin-context-menu": patch 12 | --- 13 | 14 | Change summary goes here 15 | 16 | ``` -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Audit 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - "**/Cargo.lock" 11 | - "**/Cargo.toml" 12 | pull_request: 13 | branches: 14 | - main 15 | paths: 16 | - "**/Cargo.lock" 17 | - "**/Cargo.toml" 18 | 19 | jobs: 20 | audit: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions-rs/audit-check@v1 25 | with: 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/covector-status.yml: -------------------------------------------------------------------------------- 1 | name: covector status 2 | on: [pull_request] 3 | 4 | jobs: 5 | covector: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | fetch-depth: 0 12 | - name: covector status 13 | uses: jbolda/covector/packages/action@covector-v0 14 | with: 15 | command: 'status' 16 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | - dev 11 | 12 | jobs: 13 | format: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Install rustfmt with stable toolchain 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | profile: minimal 24 | toolchain: stable 25 | override: true 26 | components: rustfmt 27 | - uses: actions-rs/cargo@v1 28 | with: 29 | command: fmt 30 | args: --manifest-path=Cargo.toml --all -- --check 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | - dev 11 | paths-ignore: 12 | - 'webview-src/**' 13 | - 'webview-dist/**' 14 | - 'examples/**' 15 | 16 | jobs: 17 | build-and-test: 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ubuntu-latest, macos-latest, windows-latest] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: Install stable toolchain 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | override: true 32 | 33 | - name: Install Linux dependencies 34 | if: matrix.os == 'ubuntu-latest' 35 | run: | 36 | sudo apt-get update 37 | sudo apt-get install -y webkit2gtk-4.0 38 | 39 | - uses: Swatinem/rust-cache@v2 40 | 41 | - name: Run tests 42 | uses: actions-rs/cargo@v1 43 | with: 44 | command: test 45 | args: --manifest-path=Cargo.toml --release 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | .Thumbs.db 4 | package-lock.json 5 | .vscode/ 6 | .yarn 7 | yarn.lock 8 | node_modules/ 9 | Cargo.lock 10 | lerna-debug.log -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tauri-plugin-context-menu" 3 | version = "0.8.2" 4 | authors = [ "c2r0b" ] 5 | description = "Handle native Context Menu in Tauri" 6 | license = "MIT OR Apache-2.0" 7 | edition = "2021" 8 | rust-version = "1.59" 9 | exclude = ["/examples", "/assets", ".DS_Store", "/.github", "/.changes", "/webview-dist", "/webview-src", "package.json", "package-lock.json"] 10 | 11 | [dependencies] 12 | tauri = { version = "1.7" } 13 | serde = { version = "1.0", features = ["derive"] } 14 | lazy_static = "1.4" 15 | time = "0.3.28" 16 | 17 | [target.'cfg(target_os = "windows")'.dependencies] 18 | winapi = { version = "0.3", features = ["winuser"] } 19 | image = "0.24.7" 20 | 21 | [target.'cfg(target_os = "macos")'.dependencies] 22 | cocoa = "0.24.1" 23 | objc = "0.2.7" 24 | libc = "0.2.147" 25 | dispatch = "0.2.0" 26 | 27 | [target.'cfg(target_os = "linux")'.dependencies] 28 | gtk = "0.15.1" 29 | gdk = "0.15.1" 30 | glib = "0.15.1" -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 c2r0b 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tauri v1 Plugin Context Menu 2 | 3 | A Tauri plugin to display native context menu in Tauri v1.x. 4 | The Tauri API v1 does not support native context menu out of the box, so this plugin is created to fill the gap. 5 | 6 | image 7 | 8 | ⚠️ **Maintenance Mode with Community Contributions** ⚠️ 9 | 10 | Tauri v2 [has been released](https://tauri.app/blog/tauri-20/) and it supports creating native context menu without plugins ([here the docs](https://v2.tauri.app/reference/javascript/api/namespacemenu/)). 11 | 12 | Bug fixes will still be implemented for this plugin. 13 | New features will not be developed by the main maintainer, but PRs from the community are still welcome. 14 | 15 | ## Support 16 | | Windows | MacOS | Linux | 17 | | ------- | ----- | ----- | 18 | | ✅ | ✅ | ✅ | 19 | 20 | ## Installation 21 | Crate: https://crates.io/crates/tauri-plugin-context-menu 22 | 23 | `cargo add tauri-plugin-context-menu` to add the package. 24 | 25 | Or add the following to your `Cargo.toml` for the latest unpublished version (not recommanded). 26 | 27 | ```toml 28 | tauri-plugin-context-menu = { git = "https://github.com/c2r0b/tauri-plugin-context-menu", branch = "main" } 29 | ``` 30 | 31 | See ["Using a Plugin" Tauri official guide](https://tauri.app/v1/guides/features/plugin#using-a-plugin) to initialize the plugin. 32 | 33 | This project provides a typescript utility to simplify the usage of the plugin. Run the following to install the JavaScript/TypeScript package: 34 | 35 | ```bash 36 | npm i tauri-plugin-context-menu 37 | ``` 38 | 39 | ## Examples 40 | Check out the `examples` directory for sample usages. 41 | 42 | A vanilla JS example is provided in `examples/vanilla`. 43 | After `npm install`, to run the example use the following command: 44 | 45 | ```bash 46 | npm run examples/vanilla 47 | ``` 48 | 49 | A typescript example using the utility package is provided in `examples/ts-utility`. 50 | You can run it with the same command as above (replace `examples/vanilla` with `examples/ts-utility`). 51 | 52 | ## Sample Usage 53 | ### Without the JS/TS Package 54 | ```ts 55 | import { invoke } from "@tauri-apps/api"; 56 | import { listen } from "@tauri-apps/api/event"; 57 | import { resolveResource } from "@tauri-apps/api/path"; 58 | 59 | // Listen to the event emitted when the first menu item is clicked 60 | listen("item1clicked", (event) => { 61 | alert(event.payload); 62 | }); 63 | 64 | window.addEventListener("contextmenu", async (e) => { 65 | e.preventDefault(); 66 | const iconUrl = await resolveResource('assets/16x16.png'); 67 | 68 | // Show the context menu 69 | invoke("plugin:context_menu|show_context_menu", { 70 | items: [ 71 | { 72 | label: "Item 1", 73 | disabled: false, 74 | event: "item1clicked", 75 | payload: "Hello World!", 76 | shortcut: "ctrl+M", 77 | icon: { 78 | path: iconUrl 79 | }, 80 | subitems: [ 81 | { 82 | label: "Subitem 1", 83 | disabled: true, 84 | event: "subitem1clicked", 85 | }, 86 | { 87 | is_separator: true, 88 | }, 89 | { 90 | label: "Subitem 2", 91 | disabled: false, 92 | checked: true, 93 | event: "subitem2clicked", 94 | } 95 | ] 96 | } 97 | ], 98 | }); 99 | }); 100 | ``` 101 | 102 | ### With the JS/TS Package 103 | ```ts 104 | import { showMenu } from "tauri-plugin-context-menu"; 105 | 106 | showMenu({ 107 | pos: {...}, // Position of the menu (see below for options) 108 | theme: "light", // Theme of the menu 109 | items: [ 110 | ..., 111 | { 112 | ..., // Menu item (see below for options) 113 | event: () => { 114 | // Do something 115 | } 116 | } 117 | ] 118 | }); 119 | ``` 120 | You can also use it to respond to window events with the `onEventShowMenu` function: 121 | ```ts 122 | import { onEventShowMenu } from "tauri-plugin-context-menu"; 123 | onEventShowMenu("contextmenu", (e) => ({ /* menuOptions */ })); 124 | ``` 125 | 126 | ## Options 127 | List of options that can be passed to the plugin. 128 | | Option | Type | Optional | Description | OS compatibility | 129 | | ------ | ----------------- | ---------- | ------------------------------------------------------ | ------------------------------------------------------------------------------ | 130 | | items | `MenuItem[]` | | List of menu items to be displayed. | All | 131 | | pos | `Position` | `optional` | Position of the menu. Defaults to the cursor position. | All | 132 | | theme | `light` \| `dark` | `optional` | Theme of the menu. Defaults to system theme. | MacOS only [#25](https://github.com/c2r0b/tauri-plugin-context-menu/issues/25) | 133 | 134 | ### MenuItem 135 | | Option | Type | Optional | Default | Description | JS/TS pkg | 136 | | ------------ | -------------- | ---------- | ------- | ------------------------------------------------------- | ---------------------------------------------------------------- | 137 | | label | `string` | | | Displayed test of the menu item. | | 138 | | disabled | `boolean` | `optional` | `false` | Whether the menu item is disabled. | 139 | | event | `string` | `optional` | | Event name to be emitted when the menu item is clicked. | You can pass a function to be executed instead of an event name. | 140 | | payload | `string` | `optional` | | Payload to be passed to the event. | You can pass any type of data. | 141 | | checked | `boolean` | `optional` | | Whether the menu item is checked. | 142 | | subitems | `MenuItem[]` | `optional` | `[]` | List of sub menu items to be displayed. | 143 | | shortcut | `string` | `optional` | | Keyboard shortcut displayed on the right. | 144 | | icon | `MenuItemIcon` | `optional` | | Icon to be displayed on the left. | 145 | | is_separator | `boolean` | `optional` | `false` | Whether the menu item is a separator. | 146 | 147 | 148 | ### MenuItemIcon 149 | | Option | Type | Optional | Default | Description | JS/TS pkg | 150 | | ------ | -------- | ---------- | ------- | ------------------------------- | ------------------------------------------------------------------------- | 151 | | path | `string` | | | Absolute path to the icon file. | You can use `assetToPath` to convert a relative path to an absolute path. | 152 | | width | `number` | `optional` | `16` | Width of the icon. | 153 | | height | `number` | `optional` | `16` | Height of the icon. | 154 | 155 | ### Position 156 | Position coordinates must be relative to the currently active window when `is_absolute` is set to `false`. 157 | | Option | Type | Optional | Default | Description | 158 | | ----------- | --------- | ---------- | ------- | --------------------------------------- | 159 | | x | `number` | | | X position of the menu. | 160 | | y | `number` | | | Y position of the menu. | 161 | | is_absolute | `boolean` | `optional` | `false` | Is the position absolute to the screen. | 162 | 163 | ### Modifier Keys 164 | Modifier keys can be used in the `shortcut` option of a menu item to display the corresponding symbol (`⌘`, `⌃`, `⌥`, `⇧`). 165 | 166 | On MacOS this also makes the shortcut work when the modifier key is pressed (since it is handled by default by the OS). 167 | 168 |
169 | Key codes list 170 | 171 | #### Modifiers 172 | - `cmd` 173 | - `cmd_or_ctrl` (Alias for `cmd` and `ctrl`) 174 | - `shift` 175 | - `alt` 176 | - `ctrl` 177 | - `opt` (Alias for `alt`) 178 | - `altgr` 179 | - `super` 180 | - `win` 181 | - `meta` 182 | 183 | #### Keys 184 | - `plus` 185 | - `space` 186 | - `tab` 187 | - `capslock` 188 | - `numlock` 189 | - `scrolllock` 190 | - `backspace` 191 | - `delete` 192 | - `insert` 193 | - `return` 194 | - `enter` 195 | - `up` 196 | - `down` 197 | - `left` 198 | - `right` 199 | - `home` 200 | - `end` 201 | - `pageup` 202 | - `pagedown` 203 | - `escape` 204 | - `esc` 205 | - `num0...9` 206 | - `numdec` 207 | - `numadd` 208 | - `numsub` 209 | - `nummult` 210 | - `numdiv` 211 | - `f1...24` 212 |
213 | 214 | ## Events 215 | ### Item Clicked 216 | Emitted when a menu item is clicked. The event name is the same as the `event` option of the menu item: 217 | 218 | ```ts 219 | import { listen } from "@tauri-apps/api/event"; 220 | import { invoke } from "@tauri-apps/api"; 221 | 222 | listen("[EVENTNAME]", () => { 223 | alert("menu item clicked"); 224 | }); 225 | 226 | invoke(...{ 227 | items: [{ 228 | ... 229 | event: "[EVENTNAME]", 230 | ... 231 | }] 232 | }); 233 | ``` 234 | 235 | ### Menu Did Close 236 | Emitted when the menu is closed. This event is emitted regardless of whether the menu is closed by clicking on a menu item or by clicking outside the menu. 237 | You can catch this event using the following code: 238 | 239 | ```ts 240 | import { listen } from "@tauri-apps/api/event"; 241 | 242 | listen("menu-did-close", () => { 243 | alert("menu closed"); 244 | }); 245 | ``` 246 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/assets/screenshot.png -------------------------------------------------------------------------------- /examples/multi/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json -------------------------------------------------------------------------------- /examples/multi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-example", 3 | "version": "0.8.2", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "tauri": "tauri" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@tauri-apps/cli": "^1.5.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/multi/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 30 | 31 | 32 | 33 |
34 | Right click here 35 |
36 |
37 | Or here 38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/multi/public/index.js: -------------------------------------------------------------------------------- 1 | import * as tauriApi from 'https://esm.run/@tauri-apps/api'; 2 | 3 | document.getElementById('firstBox').addEventListener('contextmenu', (e) => { 4 | e.preventDefault(); 5 | 6 | tauriApi.invoke('plugin:context_menu|show_context_menu', { 7 | theme: 'light', 8 | items: [ 9 | { 10 | label: "This is a menu item", 11 | disabled: false, 12 | } 13 | ] 14 | }); 15 | }); 16 | 17 | document.getElementById('secondBox').addEventListener('contextmenu', (e) => { 18 | e.preventDefault(); 19 | 20 | tauriApi.invoke('plugin:context_menu|show_context_menu', { 21 | theme: 'dark', 22 | items: [ 23 | { 24 | label: "This is ANOTHER menu item", 25 | disabled: true 26 | } 27 | ] 28 | }); 29 | }); -------------------------------------------------------------------------------- /examples/multi/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /examples/multi/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = [ "You" ] 6 | repository = "" 7 | edition = "2021" 8 | rust-version = "1.59" 9 | 10 | [dependencies] 11 | serde_json = "1.0" 12 | serde = { version = "1.0", features = [ "derive" ] } 13 | tauri = { version = "1.5", features = [ "path-all", "dialog-all" ] } 14 | tauri-plugin-context-menu = { path = "../../../" } 15 | 16 | [build-dependencies] 17 | tauri-build = { version = "1.5.0", features = [] } 18 | 19 | [features] 20 | default = [ "custom-protocol" ] 21 | custom-protocol = [ "tauri/custom-protocol" ] 22 | -------------------------------------------------------------------------------- /examples/multi/src-tauri/assets/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/assets/16x16.png -------------------------------------------------------------------------------- /examples/multi/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /examples/multi/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/multi/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /examples/multi/src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 2 4 | newline_style = "Auto" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2021" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /examples/multi/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .plugin(tauri_plugin_context_menu::init()) 9 | .run(tauri::generate_context!()) 10 | .expect("error while running tauri application"); 11 | } 12 | -------------------------------------------------------------------------------- /examples/multi/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "app", 4 | "version": "0.1.0" 5 | }, 6 | "build": { 7 | "distDir": "../public", 8 | "devPath": "../public" 9 | }, 10 | "tauri": { 11 | "bundle": { 12 | "active": true, 13 | "targets": "all", 14 | "identifier": "com.tauri.context-menu", 15 | "icon": [ 16 | "icons/32x32.png", 17 | "icons/128x128.png", 18 | "icons/128x128@2x.png", 19 | "icons/icon.icns", 20 | "icons/icon.ico" 21 | ], 22 | "resources": [ 23 | "assets/**/*" 24 | ], 25 | "externalBin": [], 26 | "copyright": "", 27 | "category": "DeveloperTool", 28 | "shortDescription": "", 29 | "longDescription": "", 30 | "deb": { 31 | "depends": [] 32 | }, 33 | "macOS": { 34 | "frameworks": [], 35 | "exceptionDomain": "", 36 | "signingIdentity": null, 37 | "entitlements": null 38 | }, 39 | "windows": { 40 | "certificateThumbprint": null, 41 | "digestAlgorithm": "sha256", 42 | "timestampUrl": "" 43 | } 44 | }, 45 | "updater": { 46 | "active": false 47 | }, 48 | "allowlist": { 49 | "all": false, 50 | "dialog": { 51 | "all": true 52 | }, 53 | "path": { 54 | "all": true 55 | } 56 | }, 57 | "windows": [ 58 | { 59 | "title": "app", 60 | "width": 800, 61 | "height": 600, 62 | "resizable": true, 63 | "fullscreen": false 64 | } 65 | ], 66 | "security": { 67 | "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/ts-utility/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | public/*.js 4 | public/*.js.map -------------------------------------------------------------------------------- /examples/ts-utility/esbuild.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | esbuild.build({ 4 | entryPoints: ['./src/index.ts'], 5 | outdir: './public', 6 | bundle: true, 7 | format: 'esm', 8 | splitting: true, 9 | chunkNames: '[name]', 10 | minify: true, 11 | loader: { 12 | '.ts': 'ts' 13 | }, 14 | tsconfig: './tsconfig.json', 15 | platform: 'node', 16 | sourcemap: true 17 | }).catch(() => process.exit(1)); 18 | -------------------------------------------------------------------------------- /examples/ts-utility/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-utility-example", 3 | "version": "0.8.2", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "node ./esbuild.js", 9 | "tauri": "npm run build && tauri" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@tauri-apps/cli": "^1.5.0", 15 | "tauri-plugin-context-menu": "file:../../plugin" 16 | }, 17 | "devDependencies": { 18 | "esbuild": "^0.19.3", 19 | "tslib": "^2.6.2", 20 | "typescript": "^5.2.2" 21 | }, 22 | "description": "" 23 | } 24 | -------------------------------------------------------------------------------- /examples/ts-utility/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Right click anywhere
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = [ "You" ] 6 | repository = "" 7 | edition = "2021" 8 | rust-version = "1.59" 9 | 10 | [dependencies] 11 | serde_json = "1.0" 12 | serde = { version = "1.0", features = [ "derive" ] } 13 | tauri = { version = "1.7", features = [ "path-all", "dialog-all" ] } 14 | tauri-plugin-context-menu = { path = "../../../" } 15 | 16 | [build-dependencies] 17 | tauri-build = { version = "1.5", features = [] } 18 | 19 | [features] 20 | default = [ "custom-protocol" ] 21 | custom-protocol = [ "tauri/custom-protocol" ] 22 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/assets/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/assets/16x16.png -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/ts-utility/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 2 4 | newline_style = "Auto" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2021" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .plugin(tauri_plugin_context_menu::init()) 9 | .run(tauri::generate_context!()) 10 | .expect("error while running tauri application"); 11 | } 12 | -------------------------------------------------------------------------------- /examples/ts-utility/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "app", 4 | "version": "0.1.0" 5 | }, 6 | "build": { 7 | "distDir": "../public", 8 | "devPath": "../public" 9 | }, 10 | "tauri": { 11 | "bundle": { 12 | "active": true, 13 | "targets": "all", 14 | "identifier": "com.tauri.context-menu", 15 | "icon": [ 16 | "icons/32x32.png", 17 | "icons/128x128.png", 18 | "icons/128x128@2x.png", 19 | "icons/icon.icns", 20 | "icons/icon.ico" 21 | ], 22 | "resources": [ 23 | "assets/**/*" 24 | ], 25 | "externalBin": [], 26 | "copyright": "", 27 | "category": "DeveloperTool", 28 | "shortDescription": "", 29 | "longDescription": "", 30 | "deb": { 31 | "depends": [] 32 | }, 33 | "macOS": { 34 | "frameworks": [], 35 | "exceptionDomain": "", 36 | "signingIdentity": null, 37 | "entitlements": null 38 | }, 39 | "windows": { 40 | "certificateThumbprint": null, 41 | "digestAlgorithm": "sha256", 42 | "timestampUrl": "" 43 | } 44 | }, 45 | "updater": { 46 | "active": false 47 | }, 48 | "allowlist": { 49 | "all": false, 50 | "dialog": { 51 | "all": true 52 | }, 53 | "path": { 54 | "all": true 55 | } 56 | }, 57 | "windows": [ 58 | { 59 | "title": "app", 60 | "width": 800, 61 | "height": 600, 62 | "resizable": true, 63 | "fullscreen": false 64 | } 65 | ], 66 | "security": { 67 | "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/ts-utility/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tauri-plugin-context-menu'; -------------------------------------------------------------------------------- /examples/ts-utility/src/index.ts: -------------------------------------------------------------------------------- 1 | import { assetToPath, onEventShowMenu } from 'tauri-plugin-context-menu'; 2 | 3 | onEventShowMenu('contextmenu', async (_e:MouseEvent) => { 4 | const options = { 5 | theme: 'dark', 6 | items: [ 7 | { 8 | label: "My first item", 9 | disabled: false, 10 | event: (e:any) => { 11 | alert(e.payload?.message); 12 | }, 13 | payload: { message: "Hello from the payload!" }, 14 | shortcut: "alt+m", 15 | icon: { 16 | path: await assetToPath('assets/16x16.png'), 17 | width: 32, 18 | height: 32 19 | } 20 | }, 21 | { 22 | is_separator: true 23 | }, 24 | { 25 | label: "My second item", 26 | disabled: false, 27 | event: "my_second_item", 28 | shortcut: "cmd+C" 29 | }, 30 | { 31 | label: "My third item", 32 | disabled: false, 33 | subitems: [ 34 | { 35 | label: "My first subitem", 36 | checked: true, 37 | event: () => { 38 | alert('My first subitem clicked'); 39 | }, 40 | shortcut: "ctrl+m" 41 | }, 42 | { 43 | label: "My second subitem", 44 | checked: false, 45 | disabled: true 46 | } 47 | ] 48 | } 49 | ] 50 | }; 51 | return options; 52 |  }); -------------------------------------------------------------------------------- /examples/ts-utility/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "strict": true, 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "types": ["@types"] 10 | }, 11 | "declaration": true, 12 | "declarationDir": "./public" 13 | }, 14 | "include": [ 15 | "./src/index.ts", 16 | "./src/index.d.ts" 17 | ] 18 | } -------------------------------------------------------------------------------- /examples/vanilla/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json -------------------------------------------------------------------------------- /examples/vanilla/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla-example", 3 | "version": "0.8.2", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "tauri": "tauri" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@tauri-apps/cli": "^1.5.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/vanilla/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Right click anywhere
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/vanilla/public/index.js: -------------------------------------------------------------------------------- 1 | import * as tauriApi from 'https://esm.run/@tauri-apps/api'; 2 | import * as tauriEvent from 'https://esm.run/@tauri-apps/api/event'; 3 | import * as tauriApiPath from 'https://esm.run/@tauri-apps/api/path'; 4 | 5 | async function registerListeners() { 6 | // on context menu item click 7 | await tauriEvent.listen('my_first_item', (event) => { 8 | alert(event.payload); 9 | }); 10 | 11 | // on context menu item click 12 | await tauriEvent.listen('my_second_item', (event) => { 13 | alert(event.event); 14 | }); 15 | 16 | // on context menu item click 17 | await tauriEvent.listen('my_first_subitem', (event) => { 18 | alert(event.event); 19 | }); 20 | 21 | // on context menu item closed 22 | await tauriEvent.listen('menu-did-close', (event) => { 23 | alert(event.event); 24 | }); 25 | } 26 | registerListeners(); // Register event listeners once 27 | 28 | window.addEventListener('contextmenu', (e) => { 29 | e.preventDefault(); 30 | 31 | // get icon path 32 | (tauriApiPath.resolveResource('assets/16x16.png')).then((assetUrl) => { 33 | // show context menu 34 | tauriApi.invoke('plugin:context_menu|show_context_menu', { 35 | pos: { 36 | x: e.clientX, 37 | y: e.clientY 38 | }, 39 | theme: 'light', 40 | items: [ 41 | { 42 | label: "My first item", 43 | disabled: false, 44 | event: "my_first_item", 45 | payload: "Hello from Tauri!", 46 | shortcut: "alt+m", 47 | icon: { 48 | path: assetUrl, 49 | width: 32, 50 | height: 32 51 | } 52 | }, 53 | { 54 | is_separator: true 55 | }, 56 | { 57 | label: "My second item", 58 | disabled: false, 59 | event: "my_second_item", 60 | shortcut: "cmd_or_ctrl+backspace" 61 | }, 62 | { 63 | label: "My third item", 64 | disabled: false, 65 | subitems: [ 66 | { 67 | label: "My first subitem", 68 | event: "my_first_subitem", 69 | checked: true, 70 | shortcut: "ctrl+m" 71 | }, 72 | { 73 | label: "My second subitem", 74 | checked: false, 75 | disabled: true 76 | } 77 | ] 78 | } 79 | ] 80 | }); 81 | }); 82 | }); -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = [ "You" ] 6 | repository = "" 7 | edition = "2021" 8 | rust-version = "1.59" 9 | 10 | [dependencies] 11 | serde_json = "1.0" 12 | serde = { version = "1.0", features = [ "derive" ] } 13 | tauri = { version = "1.5", features = [ "path-all", "dialog-all" ] } 14 | tauri-plugin-context-menu = { path = "../../../" } 15 | 16 | [build-dependencies] 17 | tauri-build = { version = "1.5.0", features = [] } 18 | 19 | [features] 20 | default = [ "custom-protocol" ] 21 | custom-protocol = [ "tauri/custom-protocol" ] 22 | -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/assets/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/assets/16x16.png -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c2r0b/tauri-plugin-context-menu/41926bd19c9defde81ce393ab3bcf5806ced1af7/examples/vanilla/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 2 4 | newline_style = "Auto" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2021" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .plugin(tauri_plugin_context_menu::init()) 9 | .run(tauri::generate_context!()) 10 | .expect("error while running tauri application"); 11 | } 12 | -------------------------------------------------------------------------------- /examples/vanilla/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "app", 4 | "version": "0.1.0" 5 | }, 6 | "build": { 7 | "distDir": "../public", 8 | "devPath": "../public" 9 | }, 10 | "tauri": { 11 | "bundle": { 12 | "active": true, 13 | "targets": "all", 14 | "identifier": "com.tauri.context-menu", 15 | "icon": [ 16 | "icons/32x32.png", 17 | "icons/128x128.png", 18 | "icons/128x128@2x.png", 19 | "icons/icon.icns", 20 | "icons/icon.ico" 21 | ], 22 | "resources": [ 23 | "assets/**/*" 24 | ], 25 | "externalBin": [], 26 | "copyright": "", 27 | "category": "DeveloperTool", 28 | "shortDescription": "", 29 | "longDescription": "", 30 | "deb": { 31 | "depends": [] 32 | }, 33 | "macOS": { 34 | "frameworks": [], 35 | "exceptionDomain": "", 36 | "signingIdentity": null, 37 | "entitlements": null 38 | }, 39 | "windows": { 40 | "certificateThumbprint": null, 41 | "digestAlgorithm": "sha256", 42 | "timestampUrl": "" 43 | } 44 | }, 45 | "updater": { 46 | "active": false 47 | }, 48 | "allowlist": { 49 | "all": false, 50 | "dialog": { 51 | "all": true 52 | }, 53 | "path": { 54 | "all": true 55 | } 56 | }, 57 | "windows": [ 58 | { 59 | "title": "app", 60 | "width": 800, 61 | "height": 600, 62 | "resizable": true, 63 | "fullscreen": false 64 | } 65 | ], 66 | "security": { 67 | "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "0.8.2" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tauri-plugin-context-menu", 3 | "version": "0.8.2", 4 | "author": "c2r0b", 5 | "description": "", 6 | "homepage": "https://github.com/c2r0b/tauri-plugin-context-menu", 7 | "scripts": { 8 | "examples/vanilla": "npm run tauri dev --workspace examples/vanilla", 9 | "examples/multi": "npm run tauri dev --workspace examples/multi", 10 | "examples/ts-utility": "npm run tauri dev --workspace examples/ts-utility" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/c2r0b/tauri-plugin-context-menu.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/c2r0b/tauri-plugin-context-menu/issues" 18 | }, 19 | "dependencies": {}, 20 | "devDependencies": { 21 | "lerna": "^7.3.0" 22 | }, 23 | "workspaces": [ 24 | "plugin", 25 | "examples/vanilla", 26 | "examples/multi", 27 | "examples/ts-utility" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /plugin/dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as ContextMenu from './types'; 2 | export type { ContextMenu }; 3 | export declare function assetToPath(asset: string): Promise; 4 | export declare function showMenu(options: ContextMenu.Options): Promise; 5 | export declare function onEventShowMenu(eventName: string, options: ContextMenu.EventOptions): void; 6 | -------------------------------------------------------------------------------- /plugin/dist/index.js: -------------------------------------------------------------------------------- 1 | function R(){return window.crypto.getRandomValues(new Uint32Array(1))[0]}function u(t,e=!1){let a=R(),i=`_${a}`;return Object.defineProperty(window,i,{value:r=>(e&&Reflect.deleteProperty(window,i),t?.(r)),writable:!1,configurable:!0}),a}async function _(t,e={}){return new Promise((a,i)=>{let r=u(l=>{a(l),Reflect.deleteProperty(window,`_${o}`)},!0),o=u(l=>{i(l),Reflect.deleteProperty(window,`_${r}`)},!0);window.__TAURI_IPC__({cmd:t,callback:r,error:o,...e})})}async function n(t){return _("tauri",t)}async function W(t,e){return n({__tauriModule:"Event",message:{cmd:"unlisten",event:t,eventId:e}})}async function g(t,e,a){await n({__tauriModule:"Event",message:{cmd:"emit",event:t,windowLabel:e,payload:a}})}async function c(t,e,a){return n({__tauriModule:"Event",message:{cmd:"listen",event:t,windowLabel:e,handler:u(a)}}).then(i=>async()=>W(t,i))}async function w(t,e,a){return c(t,e,i=>{a(i),W(t,i.id).catch(()=>{})})}var s;(function(t){t.WINDOW_RESIZED="tauri://resize",t.WINDOW_MOVED="tauri://move",t.WINDOW_CLOSE_REQUESTED="tauri://close-requested",t.WINDOW_CREATED="tauri://window-created",t.WINDOW_DESTROYED="tauri://destroyed",t.WINDOW_FOCUS="tauri://focus",t.WINDOW_BLUR="tauri://blur",t.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",t.WINDOW_THEME_CHANGED="tauri://theme-changed",t.WINDOW_FILE_DROP="tauri://file-drop",t.WINDOW_FILE_DROP_HOVER="tauri://file-drop-hover",t.WINDOW_FILE_DROP_CANCELLED="tauri://file-drop-cancelled",t.MENU="tauri://menu",t.CHECK_UPDATE="tauri://update",t.UPDATE_AVAILABLE="tauri://update-available",t.INSTALL_UPDATE="tauri://update-install",t.STATUS_UPDATE="tauri://update-status",t.DOWNLOAD_PROGRESS="tauri://update-download-progress"})(s||(s={}));async function f(t,e){return c(t,null,e)}var y;(function(t){t[t.Audio=1]="Audio",t[t.Cache=2]="Cache",t[t.Config=3]="Config",t[t.Data=4]="Data",t[t.LocalData=5]="LocalData",t[t.Desktop=6]="Desktop",t[t.Document=7]="Document",t[t.Download=8]="Download",t[t.Executable=9]="Executable",t[t.Font=10]="Font",t[t.Home=11]="Home",t[t.Picture=12]="Picture",t[t.Public=13]="Public",t[t.Runtime=14]="Runtime",t[t.Template=15]="Template",t[t.Video=16]="Video",t[t.Resource=17]="Resource",t[t.App=18]="App",t[t.Log=19]="Log",t[t.Temp=20]="Temp",t[t.AppConfig=21]="AppConfig",t[t.AppData=22]="AppData",t[t.AppLocalData=23]="AppLocalData",t[t.AppCache=24]="AppCache",t[t.AppLog=25]="AppLog"})(y||(y={}));var D;(function(t){t[t.JSON=1]="JSON",t[t.Text=2]="Text",t[t.Binary=3]="Binary"})(D||(D={}));function d(){return navigator.appVersion.includes("Win")}async function L(t){return n({__tauriModule:"Path",message:{cmd:"resolvePath",path:t,directory:y.Resource}})}var Pe=d()?"\\":"/",Ae=d()?";":":";var b=class{constructor(e,a){this.type="Logical",this.width=e,this.height=a}},m=class{constructor(e,a){this.type="Physical",this.width=e,this.height=a}toLogical(e){return new b(this.width/e,this.height/e)}},M=class{constructor(e,a){this.type="Logical",this.x=e,this.y=a}},p=class{constructor(e,a){this.type="Physical",this.x=e,this.y=a}toLogical(e){return new M(this.x/e,this.y/e)}},v;(function(t){t[t.Critical=1]="Critical",t[t.Informational=2]="Informational"})(v||(v={}));function T(){return window.__TAURI_METADATA__.__windows.map(t=>new h(t.label,{skip:!0}))}var O=["tauri://created","tauri://error"],P=class{constructor(e){this.label=e,this.listeners=Object.create(null)}async listen(e,a){return this._handleTauriEvent(e,a)?Promise.resolve(()=>{let i=this.listeners[e];i.splice(i.indexOf(a),1)}):c(e,this.label,a)}async once(e,a){return this._handleTauriEvent(e,a)?Promise.resolve(()=>{let i=this.listeners[e];i.splice(i.indexOf(a),1)}):w(e,this.label,a)}async emit(e,a){if(O.includes(e)){for(let i of this.listeners[e]||[])i({event:e,id:-1,windowLabel:this.label,payload:a});return Promise.resolve()}return g(e,this.label,a)}_handleTauriEvent(e,a){return O.includes(e)?(e in this.listeners?this.listeners[e].push(a):this.listeners[e]=[a],!0):!1}},A=class extends P{async scaleFactor(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"scaleFactor"}}}})}async innerPosition(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"innerPosition"}}}}).then(({x:e,y:a})=>new p(e,a))}async outerPosition(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"outerPosition"}}}}).then(({x:e,y:a})=>new p(e,a))}async innerSize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"innerSize"}}}}).then(({width:e,height:a})=>new m(e,a))}async outerSize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"outerSize"}}}}).then(({width:e,height:a})=>new m(e,a))}async isFullscreen(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isFullscreen"}}}})}async isMinimized(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isMinimized"}}}})}async isMaximized(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isMaximized"}}}})}async isFocused(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isFocused"}}}})}async isDecorated(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isDecorated"}}}})}async isResizable(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isResizable"}}}})}async isMaximizable(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isMaximizable"}}}})}async isMinimizable(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isMinimizable"}}}})}async isClosable(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isClosable"}}}})}async isVisible(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"isVisible"}}}})}async title(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"title"}}}})}async theme(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"theme"}}}})}async center(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"center"}}}})}async requestUserAttention(e){let a=null;return e&&(e===v.Critical?a={type:"Critical"}:a={type:"Informational"}),n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"requestUserAttention",payload:a}}}})}async setResizable(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setResizable",payload:e}}}})}async setMaximizable(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setMaximizable",payload:e}}}})}async setMinimizable(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setMinimizable",payload:e}}}})}async setClosable(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setClosable",payload:e}}}})}async setTitle(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setTitle",payload:e}}}})}async maximize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"maximize"}}}})}async unmaximize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"unmaximize"}}}})}async toggleMaximize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"toggleMaximize"}}}})}async minimize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"minimize"}}}})}async unminimize(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"unminimize"}}}})}async show(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"show"}}}})}async hide(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"hide"}}}})}async close(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"close"}}}})}async setDecorations(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setDecorations",payload:e}}}})}async setAlwaysOnTop(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setAlwaysOnTop",payload:e}}}})}async setContentProtected(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setContentProtected",payload:e}}}})}async setSize(e){if(!e||e.type!=="Logical"&&e.type!=="Physical")throw new Error("the `size` argument must be either a LogicalSize or a PhysicalSize instance");return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setSize",payload:{type:e.type,data:{width:e.width,height:e.height}}}}}})}async setMinSize(e){if(e&&e.type!=="Logical"&&e.type!=="Physical")throw new Error("the `size` argument must be either a LogicalSize or a PhysicalSize instance");return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setMinSize",payload:e?{type:e.type,data:{width:e.width,height:e.height}}:null}}}})}async setMaxSize(e){if(e&&e.type!=="Logical"&&e.type!=="Physical")throw new Error("the `size` argument must be either a LogicalSize or a PhysicalSize instance");return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setMaxSize",payload:e?{type:e.type,data:{width:e.width,height:e.height}}:null}}}})}async setPosition(e){if(!e||e.type!=="Logical"&&e.type!=="Physical")throw new Error("the `position` argument must be either a LogicalPosition or a PhysicalPosition instance");return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setPosition",payload:{type:e.type,data:{x:e.x,y:e.y}}}}}})}async setFullscreen(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setFullscreen",payload:e}}}})}async setFocus(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setFocus"}}}})}async setIcon(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setIcon",payload:{icon:typeof e=="string"?e:Array.from(e)}}}}})}async setSkipTaskbar(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setSkipTaskbar",payload:e}}}})}async setCursorGrab(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setCursorGrab",payload:e}}}})}async setCursorVisible(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setCursorVisible",payload:e}}}})}async setCursorIcon(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setCursorIcon",payload:e}}}})}async setCursorPosition(e){if(!e||e.type!=="Logical"&&e.type!=="Physical")throw new Error("the `position` argument must be either a LogicalPosition or a PhysicalPosition instance");return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setCursorPosition",payload:{type:e.type,data:{x:e.x,y:e.y}}}}}})}async setIgnoreCursorEvents(e){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"setIgnoreCursorEvents",payload:e}}}})}async startDragging(){return n({__tauriModule:"Window",message:{cmd:"manage",data:{label:this.label,cmd:{type:"startDragging"}}}})}async onResized(e){return this.listen(s.WINDOW_RESIZED,a=>{a.payload=X(a.payload),e(a)})}async onMoved(e){return this.listen(s.WINDOW_MOVED,a=>{a.payload=Z(a.payload),e(a)})}async onCloseRequested(e){return this.listen(s.WINDOW_CLOSE_REQUESTED,a=>{let i=new x(a);Promise.resolve(e(i)).then(()=>{if(!i.isPreventDefault())return this.close()})})}async onFocusChanged(e){let a=await this.listen(s.WINDOW_FOCUS,r=>{e({...r,payload:!0})}),i=await this.listen(s.WINDOW_BLUR,r=>{e({...r,payload:!1})});return()=>{a(),i()}}async onScaleChanged(e){return this.listen(s.WINDOW_SCALE_FACTOR_CHANGED,e)}async onMenuClicked(e){return this.listen(s.MENU,e)}async onFileDropEvent(e){let a=await this.listen(s.WINDOW_FILE_DROP,o=>{e({...o,payload:{type:"drop",paths:o.payload}})}),i=await this.listen(s.WINDOW_FILE_DROP_HOVER,o=>{e({...o,payload:{type:"hover",paths:o.payload}})}),r=await this.listen(s.WINDOW_FILE_DROP_CANCELLED,o=>{e({...o,payload:{type:"cancel"}})});return()=>{a(),i(),r()}}async onThemeChanged(e){return this.listen(s.WINDOW_THEME_CHANGED,e)}},x=class{constructor(e){this._preventDefault=!1,this.event=e.event,this.windowLabel=e.windowLabel,this.id=e.id}preventDefault(){this._preventDefault=!0}isPreventDefault(){return this._preventDefault}},h=class t extends A{constructor(e,a={}){super(e),a?.skip||n({__tauriModule:"Window",message:{cmd:"createWebview",data:{options:{label:e,...a}}}}).then(async()=>this.emit("tauri://created")).catch(async i=>this.emit("tauri://error",i))}static getByLabel(e){return T().some(a=>a.label===e)?new t(e,{skip:!0}):null}static async getFocusedWindow(){for(let e of T())if(await e.isFocused())return e;return null}},S;"__TAURI_METADATA__"in window?S=new h(window.__TAURI_METADATA__.__currentWindow.label,{skip:!0}):(console.warn(`Could not find "window.__TAURI_METADATA__". The "appWindow" value will reference the "main" window label. 2 | Note that this is not an issue if running this frontend on a browser instead of a Tauri window.`),S=new h("main",{skip:!0}));function Z(t){return new p(t.x,t.y)}function X(t){return new m(t.width,t.height)}var Ie=d()?`\r 3 | `:` 4 | `;var F=_;var ae="plugin:context_menu|show_context_menu";async function Re(t){return await L(t)}async function k(t,e){let a=[],i=[...t.map(r=>({...r}))];for(let r=0;r{let N={...I,payload:t[r].payload};o(N)})),i[r].event=l,i[r].payload=void 0}if(t[r].subitems){let l=await k(t[r].subitems,`${e}_${r}`);a.push(...l.unlisteners),i[r].subitems=l.processed}}return{unlisteners:a,processed:i}}async function ne(t){let{unlisteners:e,processed:a}=await k(t.items,"root"),i=await f("menu-did-close",()=>{e.forEach(r=>r()),e.length=0,i()});F(ae,{...t,items:a})}function ze(t,e){window.addEventListener(t,async a=>{a.preventDefault(),typeof e=="function"&&(e=await e(a)),await ne(e)})}export{Re as assetToPath,ze as onEventShowMenu,ne as showMenu}; 5 | //# sourceMappingURL=index.js.map 6 | -------------------------------------------------------------------------------- /plugin/dist/index.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /plugin/dist/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Event, UnlistenFn } from "@tauri-apps/api/event"; 2 | export interface Position { 3 | x: number; 4 | y: number; 5 | is_absolute?: boolean; 6 | } 7 | export interface Icon { 8 | path: string; 9 | width?: number; 10 | height?: number; 11 | } 12 | export interface CallbackEvent extends Event { 13 | payload: any; 14 | } 15 | export interface Item { 16 | label?: string; 17 | disabled?: boolean; 18 | is_separator?: boolean; 19 | event?: string | ((e?: CallbackEvent) => any); 20 | payload?: any; 21 | checked?: boolean; 22 | shortcut?: string; 23 | icon?: Icon; 24 | subitems?: Item[]; 25 | } 26 | export type Theme = 'light' | 'dark'; 27 | export interface Options { 28 | pos?: Position; 29 | theme?: Theme; 30 | items: Item[]; 31 | } 32 | export interface ProcessResult { 33 | unlisteners: UnlistenFn[]; 34 | processed: Item[]; 35 | } 36 | export type EventOptionsFunction = (e?: MouseEvent) => Options | Promise; 37 | export type EventOptions = Options | EventOptionsFunction; 38 | -------------------------------------------------------------------------------- /plugin/esbuild.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | esbuild.build({ 4 | entryPoints: ['./index.ts'], 5 | outdir: './dist', 6 | bundle: true, 7 | format: 'esm', 8 | splitting: true, 9 | chunkNames: '[name]', 10 | minify: true, 11 | loader: { 12 | '.ts': 'ts' 13 | }, 14 | tsconfig: './tsconfig.json', 15 | platform: 'node', 16 | sourcemap: true 17 | }).catch(() => process.exit(1)); 18 | -------------------------------------------------------------------------------- /plugin/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as tauriApi from '@tauri-apps/api'; 2 | import * as tauriEvent from '@tauri-apps/api/event'; 3 | import * as tauriApiPath from '@tauri-apps/api/path'; 4 | import { assetToPath, showMenu, onEventShowMenu, ContextMenu } from './index'; 5 | 6 | jest.mock('@tauri-apps/api', () => ({ 7 | invoke: jest.fn() 8 | })); 9 | 10 | jest.mock('@tauri-apps/api/event', () => ({ 11 | listen: jest.fn() 12 | })); 13 | 14 | jest.mock('@tauri-apps/api/path', () => ({ 15 | resolveResource: jest.fn() 16 | })); 17 | 18 | describe('assetToPath', () => { 19 | it('calls tauriApiPath.resolveResource', async () => { 20 | const asset = 'testAsset'; 21 | await assetToPath(asset); 22 | expect(tauriApiPath.resolveResource).toHaveBeenCalledWith(asset); 23 | }); 24 | }); 25 | 26 | describe('showMenu', () => { 27 | it('sets up event listeners for item events', async () => { 28 | const items = [ 29 | { event: jest.fn() }, 30 | { 31 | event: jest.fn(), 32 | subitems: [ 33 | { event: jest.fn() } 34 | ] 35 | } 36 | ]; 37 | await showMenu({ items }); 38 | expect(tauriEvent.listen).toHaveBeenCalledTimes(4); // events + menu-did-close 39 | }); 40 | 41 | it('invokes tauriApi with the SHOW_COMMAND', () => { 42 | showMenu({ items: [] }); 43 | expect(tauriApi.invoke).toHaveBeenCalledWith(expect.stringMatching('plugin:context_menu|show_context_menu'), expect.any(Object)); 44 | }); 45 | }); 46 | 47 | describe('onEventShowMenu', () => { 48 | it('sets up a window event listener', () => { 49 | const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); 50 | onEventShowMenu('testEvent', {} as ContextMenu.Options); 51 | expect(addEventListenerSpy).toHaveBeenCalledWith('testEvent', expect.any(Function)); 52 | addEventListenerSpy.mockRestore(); 53 | }); 54 | }); -------------------------------------------------------------------------------- /plugin/index.ts: -------------------------------------------------------------------------------- 1 | import * as tauriApi from '@tauri-apps/api'; 2 | import * as tauriEvent from '@tauri-apps/api/event'; 3 | import * as tauriApiPath from '@tauri-apps/api/path'; 4 | 5 | const SHOW_COMMAND = 'plugin:context_menu|show_context_menu'; 6 | 7 | import * as ContextMenu from './types'; 8 | export type { ContextMenu }; 9 | 10 | export async function assetToPath(asset: string): Promise { 11 | return await tauriApiPath.resolveResource(asset); 12 | } 13 | 14 | // for each item, if it is a function, replace it with an event listener 15 | async function processItems(items: ContextMenu.Item[], prefix: string): Promise { 16 | const unlisteners: tauriEvent.UnlistenFn[] = []; 17 | 18 | // Copy the items array so we don't mutate the original 19 | // (needed if called multiple times) 20 | const processed:ContextMenu.Item[] = [ ...items.map((item) => ({ ...item })) ]; 21 | 22 | for (let i = 0; i < processed.length; i++) { 23 | const itemEvent = processed[i].event; 24 | 25 | if (typeof itemEvent === 'function') { 26 | const eventName = `${prefix}_context_menu_item_${i}`; 27 | 28 | // Listen to the event and call the function directly 29 | unlisteners.push(await tauriEvent.listen(eventName, (e) => { 30 | const data:ContextMenu.CallbackEvent = { ...e, payload: items[i].payload }; 31 | itemEvent(data); 32 | })); 33 | 34 | // Set the event name on the item instead of the function 35 | processed[i].event = eventName; 36 | 37 | // Remove the payload from the item so it doesn't get sent to the plugin 38 | // (it's already been sent to the event listener) 39 | processed[i].payload = undefined; 40 | } 41 | 42 | // Recurse into subitems if they exist 43 | if (items[i].subitems) { 44 | const result = await processItems(items[i].subitems as ContextMenu.Item[], `${prefix}_${i}`); 45 | unlisteners.push(...result.unlisteners); 46 | processed[i].subitems = result.processed; 47 | } 48 | } 49 | 50 | return { unlisteners, processed }; 51 | } 52 | 53 | export async function showMenu(options: ContextMenu.Options) { 54 | const { unlisteners, processed } = await processItems(options.items, 'root'); 55 | 56 | // unlisten all events when the menu closes 57 | const unlistenMenuClose = await tauriEvent.listen("menu-did-close", () => { 58 | unlisteners.forEach((unlistener) => unlistener()); 59 | unlisteners.length = 0; 60 | unlistenMenuClose(); 61 | }); 62 | 63 | // send the options to the plugin 64 | tauriApi.invoke(SHOW_COMMAND, { ...options, items: processed } as any); 65 | } 66 | 67 | export function onEventShowMenu(eventName: string, options: ContextMenu.EventOptions): void { 68 | window.addEventListener(eventName, async (e) => { 69 | e.preventDefault(); 70 | 71 | // if options is a function, call it to get the options 72 | if (typeof options === 'function') { 73 | options = await options(e as MouseEvent); 74 | } 75 | 76 | await showMenu(options); 77 | }); 78 | } -------------------------------------------------------------------------------- /plugin/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest', 6 | }, 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | }; 9 | -------------------------------------------------------------------------------- /plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tauri-plugin-context-menu", 3 | "version": "0.8.2", 4 | "author": "c2r0b", 5 | "type": "module", 6 | "description": "", 7 | "browser": "dist/index.js", 8 | "main": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "homepage": "https://github.com/c2r0b/tauri-plugin-context-menu", 11 | "scripts": { 12 | "prebuild": "jest", 13 | "build": "node esbuild.js", 14 | "postbuild": "tsc --emitDeclarationOnly", 15 | "pretest": "npm run build" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/c2r0b/tauri-plugin-context-menu.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/c2r0b/tauri-plugin-context-menu/issues" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "dependencies": { 28 | "@tauri-apps/api": "^1.5.0" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.5.5", 32 | "esbuild": "^0.19.4", 33 | "jest": "^29.7.0", 34 | "jest-environment-jsdom": "^29.7.0", 35 | "lerna": "^7.3.0", 36 | "ts-jest": "^29.1.1", 37 | "tslib": "^2.6.2", 38 | "typescript": "^5.2.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "strict": true, 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "types": ["@types"] 10 | }, 11 | "declaration": true, 12 | "declarationDir": "dist", 13 | "lib": ["ES5", "DOM", "ES2015"] 14 | }, 15 | "include": [ 16 | "./**/*.ts", 17 | "./**/*.d.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /plugin/types.ts: -------------------------------------------------------------------------------- 1 | import type { Event, UnlistenFn } from "@tauri-apps/api/event" 2 | 3 | export interface Position { 4 | x: number 5 | y: number 6 | is_absolute?: boolean 7 | } 8 | 9 | export interface Icon { 10 | path: string 11 | width?: number 12 | height?: number 13 | } 14 | 15 | export interface CallbackEvent extends Event { 16 | payload: any 17 | } 18 | 19 | export interface Item { 20 | label?: string 21 | disabled?: boolean 22 | is_separator?: boolean 23 | event?: string|((e?:CallbackEvent) => any) 24 | payload?: any 25 | checked?: boolean 26 | shortcut?: string 27 | icon?: Icon 28 | subitems?: Item[] 29 | } 30 | 31 | export type Theme = 'light' | 'dark' 32 | 33 | export interface Options { 34 | pos?: Position 35 | theme?: Theme 36 | items: Item[] 37 | } 38 | 39 | export interface ProcessResult { 40 | unlisteners: UnlistenFn[] 41 | processed: Item[] 42 | } 43 | 44 | export type EventOptionsFunction = (e?: MouseEvent) => Options | Promise; 45 | 46 | export type EventOptions = Options | EventOptionsFunction; -------------------------------------------------------------------------------- /src/keymap.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[cfg(target_os = "linux")] 4 | use gdk::keys::constants; 5 | 6 | #[cfg(target_os = "windows")] 7 | pub fn get_key_map() -> HashMap<&'static str, &'static str> { 8 | let mut key_map = HashMap::new(); 9 | key_map.insert("cmd", "Ctrl"); // Alias for "ctrl" 10 | key_map.insert("cmd_or_ctrl", "Ctrl"); // Alias for "ctrl" 11 | key_map.insert("shift", "Shift"); 12 | key_map.insert("alt", "Alt"); 13 | key_map.insert("ctrl", "Ctrl"); 14 | key_map.insert("opt", "Alt"); // Alias for "alt" 15 | key_map.insert("altgr", "AltGr"); 16 | key_map.insert("super", "Super"); 17 | key_map.insert("win", "Win"); 18 | key_map.insert("meta", "Meta"); 19 | key_map.insert("plus", "Plus"); 20 | key_map.insert("space", "Space"); 21 | key_map.insert("tab", "Tab"); 22 | key_map.insert("capslock", "CapsLock"); 23 | key_map.insert("numlock", "NumLock"); 24 | key_map.insert("scrolllock", "ScrollLock"); 25 | key_map.insert("backspace", "Backspace"); 26 | key_map.insert("delete", "Delete"); 27 | key_map.insert("insert", "Insert"); 28 | key_map.insert("return", "Return"); 29 | key_map.insert("enter", "Return"); 30 | key_map.insert("up", "UpArrow"); 31 | key_map.insert("down", "DownArrow"); 32 | key_map.insert("left", "LeftArrow"); 33 | key_map.insert("right", "RightArrow"); 34 | key_map.insert("home", "Home"); 35 | key_map.insert("end", "End"); 36 | key_map.insert("pageup", "PageUp"); 37 | key_map.insert("pagedown", "PageDown"); 38 | key_map.insert("escape", "Escape"); 39 | key_map.insert("esc", "Escape"); 40 | key_map.insert("num0", "Numpad0"); 41 | key_map.insert("num1", "Numpad1"); 42 | key_map.insert("num2", "Numpad2"); 43 | key_map.insert("num3", "Numpad3"); 44 | key_map.insert("num4", "Numpad4"); 45 | key_map.insert("num5", "Numpad5"); 46 | key_map.insert("num6", "Numpad6"); 47 | key_map.insert("num7", "Numpad7"); 48 | key_map.insert("num8", "Numpad8"); 49 | key_map.insert("num9", "Numpad9"); 50 | key_map.insert("numdec", "NumpadDecimal"); 51 | key_map.insert("numadd", "NumpadAdd"); 52 | key_map.insert("numsub", "NumpadSubtract"); 53 | key_map.insert("nummult", "NumpadMultiply"); 54 | key_map.insert("numdiv", "NumpadDivide"); 55 | key_map.insert("f1", "F1"); 56 | key_map.insert("f2", "F2"); 57 | key_map.insert("f3", "F3"); 58 | key_map.insert("f4", "F4"); 59 | key_map.insert("f5", "F5"); 60 | key_map.insert("f6", "F6"); 61 | key_map.insert("f7", "F7"); 62 | key_map.insert("f8", "F8"); 63 | key_map.insert("f9", "F9"); 64 | key_map.insert("f10", "F10"); 65 | key_map.insert("f11", "F11"); 66 | key_map.insert("f12", "F12"); 67 | key_map.insert("f13", "F13"); 68 | key_map.insert("f14", "F14"); 69 | key_map.insert("f15", "F15"); 70 | key_map.insert("f16", "F16"); 71 | key_map.insert("f17", "F17"); 72 | key_map.insert("f18", "F18"); 73 | key_map.insert("f19", "F19"); 74 | key_map.insert("f20", "F20"); 75 | key_map.insert("f21", "F21"); 76 | key_map.insert("f22", "F22"); 77 | key_map.insert("f23", "F23"); 78 | key_map.insert("f24", "F24"); 79 | 80 | key_map 81 | } 82 | 83 | #[cfg(target_os = "macos")] 84 | pub fn get_key_map() -> HashMap<&'static str, &'static str> { 85 | let mut key_map = HashMap::new(); 86 | key_map.insert("plus", "+"); 87 | key_map.insert("space", " "); 88 | key_map.insert("tab", "\u{21e5}"); 89 | key_map.insert("capslock", "\u{1000}"); 90 | key_map.insert("numlock", "\u{1001}"); 91 | key_map.insert("scrolllock", "\u{1002}"); 92 | key_map.insert("backspace", "\u{232b}"); 93 | key_map.insert("delete", "\u{2326}"); 94 | key_map.insert("insert", "\u{2380}"); 95 | key_map.insert("return", "\u{23ce}"); 96 | key_map.insert("enter", "\u{23ce}"); 97 | key_map.insert("up", "\u{2191}"); 98 | key_map.insert("down", "\u{2193}"); 99 | key_map.insert("left", "\u{2190}"); 100 | key_map.insert("right", "\u{2192}"); 101 | key_map.insert("home", "\u{2196}"); 102 | key_map.insert("end", "\u{2198}"); 103 | key_map.insert("pageup", "\u{21DE}"); 104 | key_map.insert("pagedown", "\u{21DF}"); 105 | key_map.insert("escape", "\u{238b}"); 106 | key_map.insert("esc", "\u{238b}"); 107 | key_map.insert("num0", "\u{30}"); 108 | key_map.insert("num1", "\u{31}"); 109 | key_map.insert("num2", "\u{32}"); 110 | key_map.insert("num3", "\u{33}"); 111 | key_map.insert("num4", "\u{34}"); 112 | key_map.insert("num5", "\u{35}"); 113 | key_map.insert("num6", "\u{36}"); 114 | key_map.insert("num7", "\u{37}"); 115 | key_map.insert("num8", "\u{38}"); 116 | key_map.insert("num9", "\u{39}"); 117 | key_map.insert("numdec", "\u{2e}"); 118 | key_map.insert("numadd", "\u{2b}"); 119 | key_map.insert("numsub", "\u{2d}"); 120 | key_map.insert("nummult", "\u{2a}"); 121 | key_map.insert("numdiv", "\u{2f}"); 122 | key_map.insert("f1", "\u{F704}"); 123 | key_map.insert("f2", "\u{F705}"); 124 | key_map.insert("f3", "\u{F706}"); 125 | key_map.insert("f4", "\u{F707}"); 126 | key_map.insert("f5", "\u{F708}"); 127 | key_map.insert("f6", "\u{F709}"); 128 | key_map.insert("f7", "\u{F70A}"); 129 | key_map.insert("f8", "\u{F70B}"); 130 | key_map.insert("f9", "\u{F70C}"); 131 | key_map.insert("f10", "\u{F70D}"); 132 | key_map.insert("f11", "\u{F70E}"); 133 | key_map.insert("f12", "\u{F70F}"); 134 | key_map.insert("f13", "\u{F710}"); 135 | key_map.insert("f14", "\u{F711}"); 136 | key_map.insert("f15", "\u{F712}"); 137 | key_map.insert("f16", "\u{F713}"); 138 | key_map.insert("f17", "\u{F714}"); 139 | key_map.insert("f18", "\u{F715}"); 140 | key_map.insert("f19", "\u{F716}"); 141 | key_map.insert("f20", "\u{F717}"); 142 | key_map.insert("f21", "\u{F718}"); 143 | key_map.insert("f22", "\u{F719}"); 144 | key_map.insert("f23", "\u{F71A}"); 145 | key_map.insert("f24", "\u{F71B}"); 146 | 147 | key_map 148 | } 149 | 150 | #[cfg(target_os = "macos")] 151 | pub fn get_modifier_map() -> HashMap<&'static str, cocoa::appkit::NSEventModifierFlags> { 152 | let mut mod_map = HashMap::new(); 153 | mod_map.insert("cmd", cocoa::appkit::NSEventModifierFlags::NSCommandKeyMask); 154 | mod_map.insert( 155 | "cmd_or_ctrl", 156 | cocoa::appkit::NSEventModifierFlags::NSCommandKeyMask, 157 | ); // Alias for "cmd" 158 | mod_map.insert("shift", cocoa::appkit::NSEventModifierFlags::NSShiftKeyMask); 159 | mod_map.insert( 160 | "alt", 161 | cocoa::appkit::NSEventModifierFlags::NSAlternateKeyMask, 162 | ); 163 | mod_map.insert( 164 | "ctrl", 165 | cocoa::appkit::NSEventModifierFlags::NSControlKeyMask, 166 | ); 167 | mod_map.insert( 168 | "opt", 169 | cocoa::appkit::NSEventModifierFlags::NSAlternateKeyMask, 170 | ); // Alias for "alt" 171 | mod_map.insert( 172 | "altgr", 173 | cocoa::appkit::NSEventModifierFlags::NSAlternateKeyMask, 174 | ); // Alias for "alt" 175 | mod_map.insert( 176 | "super", 177 | cocoa::appkit::NSEventModifierFlags::NSCommandKeyMask, 178 | ); // Alias for "cmd" 179 | mod_map.insert("win", cocoa::appkit::NSEventModifierFlags::NSCommandKeyMask); // Alias for "cmd" 180 | mod_map.insert( 181 | "meta", 182 | cocoa::appkit::NSEventModifierFlags::NSCommandKeyMask, 183 | ); 184 | mod_map 185 | } 186 | 187 | #[cfg(target_os = "linux")] 188 | pub fn get_key_map() -> HashMap<&'static str, gdk::keys::Key> { 189 | let mut key_map = HashMap::new(); 190 | key_map.insert("cmd", constants::Control_L); // Alias for "ctrl" 191 | key_map.insert("cmd_or_ctrl", constants::Control_L); // Alias for "ctrl" 192 | key_map.insert("shift", constants::Shift_L); 193 | key_map.insert("alt", constants::Alt_L); 194 | key_map.insert("ctrl", constants::Control_L); 195 | key_map.insert("opt", constants::Alt_L); // Alias for "alt" 196 | key_map.insert("altgr", constants::Alt_R); 197 | key_map.insert("super", constants::Super_L); 198 | key_map.insert("win", constants::Super_L); 199 | key_map.insert("meta", constants::Super_L); 200 | key_map.insert("plus", constants::plus); 201 | key_map.insert("space", constants::space); 202 | key_map.insert("tab", constants::Tab); 203 | key_map.insert("capslock", constants::Caps_Lock); 204 | key_map.insert("numlock", constants::Num_Lock); 205 | key_map.insert("scrolllock", constants::Scroll_Lock); 206 | key_map.insert("backspace", constants::BackSpace); 207 | key_map.insert("delete", constants::Delete); 208 | key_map.insert("insert", constants::Insert); 209 | key_map.insert("return", constants::Return); 210 | key_map.insert("enter", constants::Return); 211 | key_map.insert("up", constants::Up); 212 | key_map.insert("down", constants::Down); 213 | key_map.insert("left", constants::Left); 214 | key_map.insert("right", constants::Right); 215 | key_map.insert("home", constants::Home); 216 | key_map.insert("end", constants::End); 217 | key_map.insert("pageup", constants::Page_Up); 218 | key_map.insert("pagedown", constants::Page_Down); 219 | key_map.insert("escape", constants::Escape); 220 | key_map.insert("esc", constants::Escape); 221 | key_map.insert("num0", constants::KP_0); 222 | key_map.insert("num1", constants::KP_1); 223 | key_map.insert("num2", constants::KP_2); 224 | key_map.insert("num3", constants::KP_3); 225 | key_map.insert("num4", constants::KP_4); 226 | key_map.insert("num5", constants::KP_5); 227 | key_map.insert("num6", constants::KP_6); 228 | key_map.insert("num7", constants::KP_7); 229 | key_map.insert("num8", constants::KP_8); 230 | key_map.insert("num9", constants::KP_9); 231 | key_map.insert("numdec", constants::KP_Decimal); 232 | key_map.insert("numadd", constants::KP_Add); 233 | key_map.insert("numsub", constants::KP_Subtract); 234 | key_map.insert("nummult", constants::KP_Multiply); 235 | key_map.insert("numdiv", constants::KP_Divide); 236 | key_map.insert("f1", constants::F1); 237 | key_map.insert("f2", constants::F2); 238 | key_map.insert("f3", constants::F3); 239 | key_map.insert("f4", constants::F4); 240 | key_map.insert("f5", constants::F5); 241 | key_map.insert("f6", constants::F6); 242 | key_map.insert("f7", constants::F7); 243 | key_map.insert("f8", constants::F8); 244 | key_map.insert("f9", constants::F9); 245 | key_map.insert("f10", constants::F10); 246 | key_map.insert("f11", constants::F11); 247 | key_map.insert("f12", constants::F12); 248 | key_map.insert("f13", constants::F13); 249 | key_map.insert("f14", constants::F14); 250 | key_map.insert("f15", constants::F15); 251 | key_map.insert("f16", constants::F16); 252 | key_map.insert("f17", constants::F17); 253 | key_map.insert("f18", constants::F18); 254 | key_map.insert("f19", constants::F19); 255 | key_map.insert("f20", constants::F20); 256 | key_map.insert("f21", constants::F21); 257 | key_map.insert("f22", constants::F22); 258 | key_map.insert("f23", constants::F23); 259 | key_map.insert("f24", constants::F24); 260 | 261 | key_map 262 | } 263 | 264 | #[cfg(target_os = "linux")] 265 | pub fn get_mod_map() -> HashMap<&'static str, gdk::ModifierType> { 266 | use gdk::ModifierType; 267 | 268 | let mut mod_map = HashMap::new(); 269 | mod_map.insert("cmd", ModifierType::CONTROL_MASK); // Alias for "ctrl" 270 | mod_map.insert("cmd_or_ctrl", ModifierType::CONTROL_MASK); // Alias for "ctrl" 271 | mod_map.insert("shift", ModifierType::SHIFT_MASK); 272 | mod_map.insert("alt", ModifierType::MOD1_MASK); 273 | mod_map.insert("ctrl", ModifierType::CONTROL_MASK); 274 | mod_map.insert("opt", ModifierType::MOD1_MASK); // Alias for "alt" 275 | mod_map.insert("altgr", ModifierType::MOD5_MASK); 276 | mod_map.insert("super", ModifierType::SUPER_MASK); 277 | mod_map.insert("win", ModifierType::SUPER_MASK); 278 | mod_map.insert("meta", ModifierType::META_MASK); 279 | 280 | mod_map 281 | } 282 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use tauri::{plugin::Builder, plugin::TauriPlugin, Runtime, Window}; 3 | 4 | mod keymap; 5 | mod menu_item; 6 | mod theme; 7 | 8 | use menu_item::MenuItem; 9 | use theme::Theme; 10 | 11 | #[cfg(target_os = "windows")] 12 | mod win_image_handler; 13 | 14 | #[cfg(target_os = "windows")] 15 | #[path = "win.rs"] 16 | mod os; 17 | 18 | #[cfg(target_os = "macos")] 19 | mod macos_window_holder; 20 | 21 | #[cfg(target_os = "macos")] 22 | #[path = "macos.rs"] 23 | mod os; 24 | 25 | #[cfg(target_os = "linux")] 26 | #[path = "linux.rs"] 27 | mod os; 28 | 29 | #[derive(Clone, Deserialize)] 30 | pub struct Position { 31 | x: f64, 32 | y: f64, 33 | is_absolute: Option, 34 | } 35 | 36 | #[tauri::command] 37 | fn show_context_menu( 38 | window: Window, 39 | pos: Option, 40 | items: Option>, 41 | theme: Option, 42 | ) { 43 | let theme = theme.and_then(|s| Theme::from_str(&s)); 44 | os::show_context_menu(window, pos, items, theme); 45 | } 46 | pub fn init() -> TauriPlugin { 47 | Builder::new("context_menu") 48 | .invoke_handler(tauri::generate_handler![show_context_menu]) 49 | .build() 50 | } 51 | -------------------------------------------------------------------------------- /src/linux.rs: -------------------------------------------------------------------------------- 1 | use gdk::{keys::Key, Display, ModifierType}; 2 | use gtk::{prelude::*, traits::WidgetExt, AccelFlags, AccelGroup, Menu}; 3 | use std::{env, mem, thread::sleep, time}; 4 | use tauri::{Runtime, Window}; 5 | 6 | use crate::keymap::{get_key_map, get_mod_map}; 7 | use crate::theme::Theme; 8 | use crate::{MenuItem, Position}; 9 | 10 | pub fn on_context_menu( 11 | pos: Option, 12 | items: Option>, 13 | window: Window, 14 | ) { 15 | // Create and show the context menu 16 | let gtk_window = window.gtk_window().unwrap(); 17 | 18 | // Check if the window is realized 19 | if !gtk_window.is_realized() { 20 | gtk_window.realize(); 21 | } 22 | 23 | // Create a new menu. 24 | let menu = Menu::new(); 25 | if let Some(menu_items) = items { 26 | for item in menu_items.iter() { 27 | append_menu_item(&window, >k_window, &menu, item); 28 | } 29 | } 30 | 31 | let (mut x, mut y) = match pos { 32 | Some(ref position) => (position.x as i32, position.y as i32), 33 | None => { 34 | if let Some(display) = Display::default() { 35 | if let Some(seat) = display.default_seat() { 36 | let pointer = seat.pointer(); 37 | let (_screen, x, y) = match pointer { 38 | Some(p) => p.position(), 39 | None => { 40 | eprintln!("Failed to get pointer position"); 41 | (display.default_screen(), 0, 0) 42 | } 43 | }; 44 | (x, y) 45 | } else { 46 | eprintln!("Failed to get default seat"); 47 | (0, 0) 48 | } 49 | } else { 50 | eprintln!("Failed to get default display"); 51 | (0, 0) 52 | } 53 | } 54 | }; 55 | 56 | // Adjust for X11 backend 57 | let is_x11 = env::var("GDK_BACKEND").map_or(false, |v| v == "x11"); 58 | 59 | if is_x11 { 60 | // Get the display and the default seat 61 | let display = gdk::Display::default().expect("Failed to get default display"); 62 | let gdk_window = gtk_window.window().unwrap(); 63 | 64 | // Identify the monitor where the window is displayed 65 | let monitor = display 66 | .monitor_at_window(&gdk_window) 67 | .expect("Failed to get monitor at window"); 68 | 69 | // Get the scale factor for the monitor 70 | let scale_factor = monitor.scale_factor(); 71 | 72 | // Get the geometry of the monitor 73 | let monitor_geometry = monitor.geometry(); 74 | 75 | // Adjust `x` and `y` based on the monitor's geometry and scale factor 76 | x = (x + monitor_geometry.x()) * scale_factor; 77 | y = (y + monitor_geometry.y()) * scale_factor; 78 | } 79 | 80 | let is_absolute = if let Some(position) = pos.clone() { 81 | position.is_absolute 82 | } else { 83 | Some(false) 84 | }; 85 | if is_absolute.unwrap_or(false) || pos.is_none() { 86 | // Adjust x and y if the coordinates are not relative to the window 87 | let window_position = window.outer_position().unwrap(); 88 | x -= window_position.x; 89 | y -= window_position.y; 90 | } 91 | 92 | // Required otherwise the menu doesn't show properly 93 | sleep(time::Duration::from_millis(100)); 94 | 95 | // Delay the display of the context menu to ensure the window is ready 96 | glib::idle_add_local(move || { 97 | // Show the context menu at the specified position. 98 | let gdk_window = gtk_window.window().unwrap(); 99 | let rect = &gdk::Rectangle::new(x, y, 0, 0); 100 | let mut event = gdk::Event::new(gdk::EventType::ButtonPress); 101 | event.set_device( 102 | gdk_window 103 | .display() 104 | .default_seat() 105 | .and_then(|d| d.pointer()) 106 | .as_ref(), 107 | ); 108 | menu.show_all(); 109 | menu.popup_at_rect( 110 | &gdk_window, 111 | rect, 112 | gdk::Gravity::NorthWest, 113 | gdk::Gravity::NorthWest, 114 | Some(&event), 115 | ); 116 | Continue(false) 117 | }); 118 | } 119 | 120 | pub fn show_context_menu( 121 | window: Window, 122 | pos: Option, 123 | items: Option>, 124 | _theme: Option, 125 | ) { 126 | on_context_menu(pos, items, window); 127 | } 128 | 129 | fn append_menu_item( 130 | window: &Window, 131 | gtk_window: >k::ApplicationWindow, 132 | menu: &Menu, 133 | item: &MenuItem, 134 | ) { 135 | if item.is_separator.unwrap_or(false) { 136 | menu.append(>k::SeparatorMenuItem::builder().visible(true).build()); 137 | } else { 138 | let menu_item = match item.checked { 139 | Some(state) => { 140 | // Create a CheckMenuItem for checkable items 141 | let check_menu_item = gtk::CheckMenuItem::new(); 142 | check_menu_item.set_active(state); 143 | check_menu_item.upcast() 144 | } 145 | None => { 146 | // Create a regular MenuItem for non-checkable items 147 | gtk::MenuItem::new() 148 | } 149 | }; 150 | 151 | // Create a Box to hold the image and label 152 | let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 0); 153 | hbox.set_homogeneous(false); 154 | 155 | // Handle icon 156 | if let Some(icon) = &item.icon { 157 | let image = gtk::Image::from_file(&icon.path); 158 | if let Some(width) = icon.width { 159 | if let Some(height) = icon.height { 160 | image.set_pixel_size(width as i32); 161 | image.set_pixel_size(height as i32); 162 | } 163 | } 164 | hbox.pack_start(&image, false, false, 0); 165 | } 166 | 167 | // Add label to the Box 168 | let label = item.label.as_deref().unwrap_or(""); 169 | let accel_label = gtk::AccelLabel::new(label); 170 | accel_label.set_xalign(0.0); // Align the label to the left 171 | hbox.pack_start(&accel_label, true, true, 0); 172 | 173 | // Add the Box to the MenuItem 174 | menu_item.add(&hbox); 175 | 176 | // Handle enabled/disabled state 177 | if item.disabled.unwrap_or(false) { 178 | menu_item.set_sensitive(false); 179 | } 180 | 181 | // If an event is provided, you can connect to the "activate" signal (from item.event and item.payload) 182 | if let Some(event) = &item.event { 183 | let window_clone = window.clone(); 184 | 185 | // payload may exist 186 | let payload_clone = item.payload.clone(); 187 | 188 | // get event from String to str 189 | let event_clone = event.clone(); 190 | menu_item.connect_activate(move |_| { 191 | window_clone 192 | .emit(event_clone.as_str(), &payload_clone) 193 | .unwrap(); // Emit the event to JavaScript 194 | }); 195 | } 196 | 197 | // Handle shortcut 198 | if let Some(shortcut) = &item.shortcut { 199 | let accel_group = AccelGroup::new(); 200 | gtk_window.add_accel_group(&accel_group); 201 | 202 | // Parse and assign the shortcut 203 | let (key, mods) = parse_shortcut(shortcut); 204 | accel_label.set_accel_widget(Some(&menu_item)); 205 | menu_item.add_accelerator("activate", &accel_group, key, mods, AccelFlags::VISIBLE); 206 | } 207 | 208 | if let Some(subitems) = &item.subitems { 209 | let submenu = Menu::new(); 210 | for subitem in subitems.iter() { 211 | append_menu_item(window, gtk_window, &submenu, subitem); 212 | } 213 | menu_item.set_submenu(Some(&submenu)); 214 | } 215 | 216 | menu.append(&menu_item); 217 | } 218 | } 219 | 220 | fn key_to_u32(key: gdk::keys::Key) -> u32 { 221 | unsafe { mem::transmute(key) } 222 | } 223 | 224 | fn parse_shortcut(shortcut: &str) -> (u32, ModifierType) { 225 | let key_map = get_key_map(); 226 | let mod_map = get_mod_map(); // This should map strings like "ctrl" to ModifierType 227 | let parts: Vec<&str> = shortcut.split('+').collect(); 228 | 229 | // Assuming last part is always the key 230 | let key_str = parts.last().unwrap_or(&""); 231 | 232 | // Get the key from the key map 233 | let key = if let Some(key) = key_map.get(key_str) { 234 | // Clone the key value to get ownership of it 235 | key.clone() 236 | } else { 237 | // If the key is not in the map, assume it's a character 238 | Key::from_name(key_str) 239 | }; 240 | 241 | let key_u32 = key_to_u32(key); 242 | 243 | let mut mods = ModifierType::empty(); 244 | 245 | // Process all parts except the last one as modifiers 246 | for &mod_str in &parts[..parts.len() - 1] { 247 | if let Some(&mod_type) = mod_map.get(mod_str) { 248 | mods.insert(mod_type); 249 | } 250 | } 251 | 252 | (key_u32, mods) 253 | } 254 | -------------------------------------------------------------------------------- /src/macos.rs: -------------------------------------------------------------------------------- 1 | use cocoa::appkit::{NSControl, NSMenuItem}; 2 | use cocoa::base::{id, nil, selector}; 3 | use cocoa::foundation::{NSPoint, NSRect, NSSize, NSString}; 4 | use objc::declare::ClassDecl; 5 | use objc::runtime::{Object, Sel, NO, YES}; 6 | use objc::{class, msg_send, sel, sel_impl}; 7 | use std::sync::Arc; 8 | use tauri::{Runtime, Window}; 9 | 10 | use crate::keymap::{get_key_map, get_modifier_map}; 11 | use crate::macos_window_holder::CURRENT_WINDOW; 12 | use crate::theme::Theme; 13 | use crate::{MenuItem, Position}; 14 | 15 | extern "C" { 16 | fn NSPointInRect(aPoint: NSPoint, aRect: NSRect) -> bool; 17 | } 18 | 19 | extern "C" fn menu_item_action(_self: &Object, _cmd: Sel, _item: id) { 20 | // Get the window from the CURRENT_WINDOW static 21 | let window_arc: Arc> = match CURRENT_WINDOW.get_window() { 22 | Some(window_arc) => window_arc, 23 | None => return println!("No window found"), 24 | }; 25 | 26 | // Get the event name and payload from the representedObject of the NSMenuItem 27 | let nsstring_obj: id = unsafe { msg_send![_item, representedObject] }; 28 | let combined_str: String = unsafe { 29 | let cstr: *const std::os::raw::c_char = msg_send![nsstring_obj, UTF8String]; 30 | std::ffi::CStr::from_ptr(cstr) 31 | .to_string_lossy() 32 | .into_owned() 33 | }; 34 | let parts: Vec<&str> = combined_str.split(":::").collect(); 35 | let event_name = parts.get(0).unwrap_or(&"").to_string(); 36 | let payload = parts.get(1).cloned(); 37 | 38 | // Dereferencing the Arc to get a reference to the Window 39 | let window = &*window_arc; 40 | 41 | // Emit the event on the window 42 | window.emit(&event_name, payload).unwrap(); 43 | } 44 | 45 | extern "C" fn menu_did_close(_self: &Object, _cmd: Sel, _menu: id) { 46 | if let Some(window) = CURRENT_WINDOW.get_window::() { 47 | window.emit("menu-did-close", ()).unwrap(); 48 | } else { 49 | println!("Menu did close, but no window was found."); 50 | } 51 | } 52 | 53 | fn register_menu_item_action() -> Sel { 54 | let selector_name = "menuAction:"; 55 | 56 | let exists: bool; 57 | let class = objc::runtime::Class::get("MenuItemDelegate"); 58 | exists = class.is_some(); 59 | 60 | if !exists { 61 | let superclass = objc::runtime::Class::get("NSObject").unwrap(); 62 | let mut decl = ClassDecl::new("MenuItemDelegate", superclass).unwrap(); 63 | 64 | unsafe { 65 | decl.add_method( 66 | selector(selector_name), 67 | menu_item_action:: as extern "C" fn(&Object, Sel, id), 68 | ); 69 | decl.add_method( 70 | selector("menuDidClose:"), 71 | menu_did_close:: as extern "C" fn(&Object, Sel, id), 72 | ); 73 | decl.register(); 74 | } 75 | } 76 | 77 | selector(selector_name) 78 | } 79 | 80 | fn create_custom_menu_item(option: &MenuItem) -> id { 81 | // If the item is a separator, return a separator item 82 | if option.is_separator.unwrap_or(false) { 83 | let separator: id = unsafe { msg_send![class!(NSMenuItem), separatorItem] }; 84 | return separator; 85 | } 86 | 87 | let sel = register_menu_item_action::(); 88 | let menu_item: id = unsafe { 89 | let title = match &option.label { 90 | Some(label) => NSString::alloc(nil).init_str(label), 91 | None => NSString::alloc(nil).init_str(""), 92 | }; 93 | 94 | // Parse the shortcut 95 | let (key, mask) = match &option.shortcut { 96 | Some(shortcut) => { 97 | let parts: Vec<&str> = shortcut.split('+').collect(); 98 | 99 | let key_map = get_key_map(); 100 | let modifier_map = get_modifier_map(); 101 | 102 | // Default values 103 | let mut key_str = ""; 104 | let mut mask = cocoa::appkit::NSEventModifierFlags::empty(); 105 | 106 | for part in parts.iter() { 107 | if let Some(k) = key_map.get(*part) { 108 | key_str = k; 109 | } else if let Some(m) = modifier_map.get(*part) { 110 | mask.insert(*m); 111 | } else { 112 | key_str = *part; // Assuming the last item or the only item without a '+' is the main key. 113 | } 114 | } 115 | 116 | (NSString::alloc(nil).init_str(key_str), mask) 117 | } 118 | None => ( 119 | NSString::alloc(nil).init_str(""), 120 | cocoa::appkit::NSEventModifierFlags::empty(), 121 | ), 122 | }; 123 | 124 | let item = cocoa::appkit::NSMenuItem::alloc(nil) 125 | .initWithTitle_action_keyEquivalent_(title, sel, key); 126 | item.setKeyEquivalentModifierMask_(mask); 127 | 128 | // Set the enabled state (disabled flag is optional) 129 | item.setEnabled_(match option.disabled { 130 | Some(true) => NO, 131 | _ => YES, 132 | }); 133 | 134 | // Set the represented object as the event name and payload 135 | let string_payload = match &option.payload { 136 | Some(payload) => format!( 137 | "{}:::{}", 138 | &option.event.as_ref().unwrap_or(&"".to_string()), 139 | payload 140 | ), 141 | None => option.event.as_ref().unwrap_or(&"".to_string()).clone(), 142 | }; 143 | let ns_string_payload = NSString::alloc(nil).init_str(&string_payload); 144 | let _: () = msg_send![item, setRepresentedObject:ns_string_payload]; 145 | 146 | // Set the icon if it exists 147 | if let Some(icon) = &option.icon { 148 | let ns_string_path: id = NSString::alloc(nil).init_str(&icon.path); 149 | let image: *mut Object = msg_send![class!(NSImage), alloc]; 150 | let image: *mut Object = msg_send![image, initWithContentsOfFile:ns_string_path]; 151 | if image.is_null() { 152 | println!("Failed to load image from path: {}", icon.path); 153 | } else { 154 | let width = icon.width.unwrap_or(16); 155 | let height = icon.height.unwrap_or(16); 156 | let size = NSSize::new(width as f64, height as f64); 157 | let _: () = msg_send![image, setSize:size]; 158 | 159 | let _: () = msg_send![item, setImage:image]; 160 | } 161 | } 162 | 163 | // Set the delegate 164 | let delegate_class_name = "MenuItemDelegate"; 165 | let delegate_class: &'static objc::runtime::Class = 166 | objc::runtime::Class::get(delegate_class_name).expect("Class should exist"); 167 | let delegate_instance: id = msg_send![delegate_class, new]; 168 | item.setTarget_(delegate_instance); 169 | 170 | // Set the submenu if it exists 171 | if let Some(subitems) = &option.subitems { 172 | let submenu: id = msg_send![class!(NSMenu), new]; 173 | let _: () = msg_send![submenu, setAutoenablesItems:NO]; 174 | for subitem in subitems.iter() { 175 | let sub_menu_item: id = create_custom_menu_item::(subitem); 176 | let _: () = msg_send![submenu, addItem:sub_menu_item]; 177 | } 178 | let _: () = msg_send![item, setSubmenu:submenu]; 179 | } 180 | 181 | // Handle checkable menu items 182 | let state = match option.checked { 183 | Some(true) => 1, 184 | _ => 0, 185 | }; 186 | let _: () = msg_send![item, setState:state]; 187 | 188 | item 189 | }; 190 | 191 | menu_item 192 | } 193 | 194 | fn create_context_menu( 195 | options: &[MenuItem], 196 | window: &Window, 197 | theme: Option, 198 | ) -> id { 199 | let _: () = CURRENT_WINDOW.set_window(window.clone()); 200 | unsafe { 201 | let title = NSString::alloc(nil).init_str("Menu"); 202 | let menu: id = msg_send![class!(NSMenu), alloc]; 203 | let menu: id = msg_send![menu, initWithTitle: title]; 204 | 205 | // Set the theme menu 206 | if let Some(theme) = theme { 207 | let appearance_name: id = match theme { 208 | Theme::Dark => NSString::alloc(nil).init_str("NSAppearanceNameDarkAqua"), 209 | Theme::Light => NSString::alloc(nil).init_str("NSAppearanceNameAqua"), 210 | }; 211 | 212 | let appearance: id = msg_send![class!(NSAppearance), appearanceNamed:appearance_name]; 213 | let _: () = msg_send![menu, setAppearance: appearance]; 214 | } 215 | 216 | let _: () = msg_send![menu, setAutoenablesItems:NO]; 217 | 218 | for option in options.iter().cloned() { 219 | let item: id = create_custom_menu_item::(&option); 220 | let _: () = msg_send![menu, addItem:item]; 221 | } 222 | 223 | let delegate_class_name = "MenuItemDelegate"; 224 | let delegate_class: &'static objc::runtime::Class = 225 | objc::runtime::Class::get(delegate_class_name).expect("Class should exist"); 226 | let delegate_instance: id = msg_send![delegate_class, new]; 227 | let _: () = msg_send![menu, setDelegate:delegate_instance]; 228 | 229 | menu 230 | } 231 | } 232 | 233 | pub fn show_context_menu( 234 | window: Window, 235 | pos: Option, 236 | items: Option>, 237 | theme: Option, 238 | ) { 239 | let main_queue = dispatch::Queue::main(); 240 | main_queue.exec_async(move || { 241 | let items_slice = items.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); 242 | let menu = create_context_menu(items_slice, &window, theme); 243 | let location = match pos { 244 | // Convert web page coordinates to screen coordinates 245 | Some(pos) if pos.x != 0.0 || pos.y != 0.0 => unsafe { 246 | let window_position = window.outer_position().unwrap(); 247 | 248 | // Get all screens and the mouse location 249 | let screens: id = msg_send![class!(NSScreen), screens]; 250 | let screen_count: usize = msg_send![screens, count]; 251 | let mouse_location: NSPoint = msg_send![class!(NSEvent), mouseLocation]; 252 | 253 | // Find the screen under the mouse cursor 254 | let mut target_screen: id = nil; 255 | let mut target_screen_frame: NSRect = 256 | NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0)); 257 | 258 | for i in 0..screen_count { 259 | let screen: id = msg_send![screens, objectAtIndex:i]; 260 | let frame: NSRect = msg_send![screen, frame]; 261 | if NSPointInRect(mouse_location, frame) { 262 | target_screen = screen; 263 | target_screen_frame = frame; 264 | break; 265 | } 266 | } 267 | 268 | // Fallback to the main screen if no specific screen found 269 | if target_screen == nil { 270 | target_screen = msg_send![class!(NSScreen), mainScreen]; 271 | target_screen_frame = msg_send![target_screen, frame]; 272 | } 273 | 274 | let screen_height = target_screen_frame.size.height; 275 | let screen_origin_y = target_screen_frame.origin.y; 276 | let scale_factor = match window.scale_factor() { 277 | Ok(factor) => factor, 278 | Err(_) => 1.0, // Default to 1.0 if scale factor can't be retrieved 279 | }; 280 | 281 | if pos.is_absolute.unwrap_or(false) { 282 | let x = pos.x; 283 | let y = screen_origin_y + screen_height - pos.y; 284 | NSPoint::new(x, y) 285 | } else { 286 | let x = pos.x + (window_position.x as f64 / scale_factor); 287 | let y = screen_origin_y + screen_height 288 | - (window_position.y as f64 / scale_factor) 289 | - pos.y; 290 | NSPoint::new(x, y) 291 | } 292 | }, 293 | // Get the current mouse location if the web page didn't specify a position 294 | _ => unsafe { 295 | let event: NSPoint = msg_send![class!(NSEvent), mouseLocation]; 296 | NSPoint::new(event.x, event.y) 297 | }, 298 | }; 299 | unsafe { 300 | let _: () = 301 | msg_send![menu, popUpMenuPositioningItem:nil atLocation:location inView:nil]; 302 | } 303 | }); 304 | } 305 | -------------------------------------------------------------------------------- /src/macos_window_holder.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | use std::sync::{Arc, Mutex}; 3 | use tauri::{Runtime, Window}; 4 | 5 | pub struct WindowHolder { 6 | window: Arc>>>, 7 | } 8 | 9 | impl WindowHolder { 10 | pub fn new() -> Self { 11 | Self { 12 | window: Arc::new(Mutex::new(None)), 13 | } 14 | } 15 | 16 | pub fn set_window(&self, window: Window) { 17 | let mut lock = self.window.lock().unwrap(); 18 | *lock = Some(Arc::new(window)); 19 | } 20 | 21 | pub fn get_window(&self) -> Option>> { 22 | let lock = self.window.lock().unwrap(); 23 | match &*lock { 24 | Some(window) => Some(window.clone().downcast::>().unwrap()), 25 | None => None, 26 | } 27 | } 28 | } 29 | 30 | lazy_static::lazy_static! { 31 | pub static ref CURRENT_WINDOW: WindowHolder = WindowHolder::new(); 32 | } 33 | -------------------------------------------------------------------------------- /src/menu_item.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Deserialize)] 4 | pub struct MenuItem { 5 | pub label: Option, 6 | pub disabled: Option, 7 | pub shortcut: Option, 8 | pub event: Option, 9 | pub payload: Option, 10 | pub subitems: Option>, 11 | pub icon: Option, 12 | pub checked: Option, 13 | pub is_separator: Option, 14 | } 15 | 16 | #[derive(Clone, Deserialize)] 17 | pub struct MenuItemIcon { 18 | pub path: String, 19 | pub width: Option, 20 | pub height: Option, 21 | } 22 | 23 | impl Default for MenuItem { 24 | fn default() -> Self { 25 | Self { 26 | label: None, 27 | disabled: Some(false), 28 | shortcut: None, 29 | event: None, 30 | payload: None, 31 | subitems: None, 32 | icon: None, 33 | checked: Some(false), 34 | is_separator: Some(false), 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Clone, Copy, Deserialize)] 4 | pub enum Theme { 5 | Light, 6 | Dark, 7 | } 8 | 9 | impl Theme { 10 | pub fn from_str(s: &str) -> Option { 11 | match s { 12 | "light" => Some(Theme::Light), 13 | "dark" => Some(Theme::Dark), 14 | _ => None, 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/win.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::convert::TryInto; 3 | use std::ptr::null_mut; 4 | use std::sync::{Arc, Mutex}; 5 | use tauri::{Runtime, Window}; 6 | use winapi::{ 7 | shared::minwindef::LOWORD, 8 | shared::windef::{HMENU, HWND, HWND__, POINT}, 9 | um::winuser::{ 10 | AppendMenuW, ClientToScreen, CreatePopupMenu, DestroyMenu, DispatchMessageW, GetCursorPos, 11 | GetMessageW, PostQuitMessage, SetMenuItemBitmaps, TrackPopupMenu, TranslateMessage, 12 | MF_BYCOMMAND, MF_CHECKED, MF_DISABLED, MF_ENABLED, MF_POPUP, MF_SEPARATOR, MF_STRING, MSG, 13 | TPM_LEFTALIGN, TPM_RIGHTBUTTON, TPM_TOPALIGN, WM_ACTIVATE, WM_COMMAND, 14 | }, 15 | }; 16 | 17 | use crate::keymap::get_key_map; 18 | use crate::theme::Theme; 19 | use crate::win_image_handler::{convert_to_hbitmap, load_bitmap_from_file}; 20 | use crate::{MenuItem, Position}; 21 | 22 | const ID_MENU_ITEM_BASE: u32 = 1000; 23 | const WA_INACTIVE: u16 = 0; 24 | 25 | // We use a lazy_static Mutex to ensure thread safety. 26 | // This will store a map from menu item IDs to events. 27 | lazy_static::lazy_static! { 28 | static ref CALLBACK_MAP: Mutex)>> = Mutex::new(HashMap::new()); 29 | } 30 | 31 | pub fn get_label_with_shortcut(label: &str, shortcut: Option<&str>) -> String { 32 | let key_map = get_key_map(); 33 | 34 | label.to_string() 35 | + &shortcut.map_or_else(String::new, |s| { 36 | format!( 37 | "\t{}", 38 | s.split('+') 39 | .map(|part| { 40 | let mut c = part.chars(); 41 | // If the part exists in the key_map, use the key_map value. 42 | // Otherwise, use the original logic. 43 | key_map.get(part).map_or_else( 44 | || c.next().unwrap_or_default().to_uppercase().to_string() + c.as_str(), 45 | |value| value.to_string(), 46 | ) 47 | }) 48 | .collect::>() 49 | .join("+") 50 | ) 51 | }) 52 | } 53 | 54 | fn append_menu_item(menu: HMENU, item: &MenuItem, counter: &mut u32) -> Result { 55 | let id = *counter; 56 | *counter += 1; 57 | 58 | if item.is_separator.unwrap_or(false) { 59 | unsafe { 60 | AppendMenuW(menu, MF_SEPARATOR, 0, null_mut()); 61 | } 62 | } else { 63 | let label = item.label.as_deref().unwrap_or(""); 64 | let shortcut = item.shortcut.as_deref(); 65 | let menu_label = get_label_with_shortcut(label, shortcut); 66 | let label_wide: Vec = menu_label 67 | .encode_utf16() 68 | .chain(std::iter::once(0)) 69 | .collect(); // Add a null terminator 70 | let mut flags: u32 = MF_STRING; 71 | 72 | // Check if the item should be disabled 73 | if item.disabled.unwrap_or(false) { 74 | flags |= MF_DISABLED; 75 | } else { 76 | flags |= MF_ENABLED; 77 | } 78 | 79 | // Check if the item is checkable and set the initial state 80 | if item.checked.unwrap_or(false) { 81 | flags |= MF_CHECKED; 82 | } 83 | 84 | if let Some(subitems) = &item.subitems { 85 | let submenu = unsafe { CreatePopupMenu() }; 86 | for subitem in subitems.iter() { 87 | let _ = append_menu_item(submenu, subitem, counter); 88 | } 89 | unsafe { 90 | AppendMenuW( 91 | menu, 92 | MF_POPUP | flags, 93 | (submenu as u32).try_into().unwrap(), 94 | label_wide.as_ptr(), 95 | ); 96 | } 97 | } else { 98 | unsafe { 99 | AppendMenuW(menu, flags, id.try_into().unwrap(), label_wide.as_ptr()); 100 | }; 101 | } 102 | 103 | // If an event is provided, store it in the callback map 104 | if let Some(event) = &item.event { 105 | CALLBACK_MAP 106 | .lock() 107 | .unwrap() 108 | .insert(id, (event.clone(), item.payload.clone())); 109 | } 110 | 111 | // If the icon path is provided, load the bitmap and set it for the menu item. 112 | if let Some(icon) = &item.icon { 113 | match load_bitmap_from_file(&icon.path, icon.width, icon.height) { 114 | Ok(bitmap) => match convert_to_hbitmap(bitmap) { 115 | Ok(hbitmap) => { 116 | if !hbitmap.is_null() { 117 | unsafe { 118 | SetMenuItemBitmaps(menu, id as u32, MF_BYCOMMAND, hbitmap, hbitmap); 119 | } 120 | } else { 121 | return Err(format!("Failed to load bitmap from path: {}", icon.path)); 122 | } 123 | } 124 | Err(err_msg) => return Err(err_msg), 125 | }, 126 | Err(err) => { 127 | return Err(format!( 128 | "Failed to load image from path: {}. Error: {:?}", 129 | icon.path, err 130 | )) 131 | } 132 | } 133 | } 134 | } 135 | 136 | Ok(id) 137 | } 138 | 139 | // This function would be called when a WM_COMMAND message is received, with the ID of the menu item that was clicked 140 | pub fn handle_menu_item_click(id: u32, window: Window) { 141 | if let Some((event, payload)) = CALLBACK_MAP.lock().unwrap().get(&id) { 142 | window.emit(event, &payload).unwrap(); // Emit the event to JavaScript 143 | } 144 | } 145 | 146 | pub fn show_context_menu( 147 | window: Window, 148 | pos: Option, 149 | items: Option>, 150 | _theme: Option, 151 | ) { 152 | // Clear the callback map at the start of each context menu display 153 | CALLBACK_MAP.lock().unwrap().clear(); 154 | 155 | let menu = unsafe { CreatePopupMenu() }; 156 | let hwnd = window.hwnd().unwrap().0 as *mut HWND__; 157 | 158 | let mut counter = ID_MENU_ITEM_BASE; 159 | if let Some(menu_items) = items { 160 | for item in menu_items.iter() { 161 | let _ = append_menu_item(menu, item, &mut counter); 162 | } 163 | } 164 | 165 | let position = match pos { 166 | Some(p) => { 167 | let scale_factor = window.scale_factor().unwrap_or(1.0); // Use 1.0 as a default if getting the scale factor fails 168 | let mut point = POINT { 169 | x: (p.x * scale_factor) as i32, 170 | y: (p.y * scale_factor) as i32, 171 | }; 172 | 173 | if p.is_absolute.unwrap_or(false) { 174 | point 175 | } else { 176 | unsafe { 177 | ClientToScreen(hwnd as HWND, &mut point); 178 | } 179 | point 180 | } 181 | } 182 | None => { 183 | // Get the current cursor position using GetCursorPos 184 | let mut current_pos = POINT { x: 0, y: 0 }; 185 | unsafe { 186 | GetCursorPos(&mut current_pos); 187 | } 188 | current_pos 189 | } 190 | }; 191 | 192 | unsafe { 193 | TrackPopupMenu( 194 | menu, 195 | TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON, 196 | position.x, 197 | position.y, 198 | 0, // reserved param 199 | hwnd as HWND, 200 | std::ptr::null_mut(), 201 | ); 202 | 203 | DestroyMenu(menu); 204 | 205 | // Post a quit message to exit the message loop 206 | PostQuitMessage(0); 207 | } 208 | 209 | // Emit the menu-did-close event to JavaScript 210 | window.emit("menu-did-close", ()).unwrap(); 211 | 212 | let mut msg: MSG = unsafe { std::mem::zeroed() }; 213 | while unsafe { GetMessageW(&mut msg, null_mut(), 0, 0) } > 0 { 214 | match msg.message { 215 | WM_COMMAND => { 216 | // Extract the menu item ID from wParam 217 | let menu_item_id = LOWORD(msg.wParam as u32); 218 | handle_menu_item_click(menu_item_id.into(), window.clone()); 219 | } 220 | WM_ACTIVATE => { 221 | if LOWORD(msg.wParam as u32) == WA_INACTIVE { 222 | unsafe { DestroyMenu(menu) }; 223 | } 224 | } 225 | _ => unsafe { 226 | TranslateMessage(&msg); 227 | DispatchMessageW(&msg); 228 | }, 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/win_image_handler.rs: -------------------------------------------------------------------------------- 1 | use image::io::Reader as ImageReader; 2 | use std::ptr::null_mut; 3 | use winapi::{ 4 | shared::minwindef::BYTE, 5 | shared::ntdef::VOID, 6 | shared::windef::HBITMAP, 7 | um::wingdi::{CreateCompatibleDC, CreateDIBSection, BITMAPINFO}, 8 | um::winuser::GetDC, 9 | }; 10 | 11 | pub fn load_bitmap_from_file( 12 | path: &str, 13 | width: Option, 14 | height: Option, 15 | ) -> Result { 16 | let mut image_reader = ImageReader::open(path)?.decode()?; 17 | let (resized_width, resized_height) = (width.unwrap_or(16), height.unwrap_or(16)); 18 | image_reader = image_reader.resize( 19 | resized_width, 20 | resized_height, 21 | image::imageops::FilterType::Nearest, 22 | ); 23 | Ok(image_reader) 24 | } 25 | 26 | pub fn convert_to_hbitmap(img: image::DynamicImage) -> Result { 27 | // Convert the image to a monochrome 1-bit per pixel format 28 | let img = img.to_rgba8(); 29 | let (width, height) = img.dimensions(); 30 | 31 | // Get the device context for the screen 32 | let hdc_screen = unsafe { GetDC(null_mut()) }; 33 | if hdc_screen.is_null() { 34 | return Err("Failed to get device context.".to_string()); 35 | } 36 | 37 | // Create a compatible memory device context 38 | let hdc_memory = unsafe { CreateCompatibleDC(hdc_screen) }; 39 | if hdc_memory.is_null() { 40 | return Err("Failed to create memory device context.".to_string()); 41 | } 42 | 43 | // Create a compatible bitmap 44 | let mut bmi: BITMAPINFO = unsafe { std::mem::zeroed() }; 45 | bmi.bmiHeader.biSize = std::mem::size_of::() as u32; 46 | bmi.bmiHeader.biWidth = width as i32; 47 | bmi.bmiHeader.biHeight = -(height as i32); // Top-down 48 | bmi.bmiHeader.biPlanes = 1; 49 | bmi.bmiHeader.biBitCount = 32; // 32 bits per pixel for RGBA 50 | bmi.bmiHeader.biCompression = winapi::um::wingdi::BI_RGB; 51 | 52 | let mut bits: *mut VOID = std::ptr::null_mut(); 53 | let hbitmap = unsafe { 54 | CreateDIBSection( 55 | hdc_screen, 56 | &bmi, 57 | winapi::um::wingdi::DIB_RGB_COLORS, 58 | &mut bits, 59 | std::ptr::null_mut(), 60 | 0, 61 | ) 62 | }; 63 | if hbitmap.is_null() { 64 | return Err("Failed to create DIB section.".to_string()); 65 | } 66 | 67 | // Copy image pixels to the bitmap 68 | for y in 0..height { 69 | for x in 0..width { 70 | let pixel = img.get_pixel(x, y); 71 | let offset = (y * width + x) * 4; 72 | let dst = unsafe { bits.offset(offset as isize) as *mut BYTE }; 73 | let alpha = pixel[3] as f32 / 255.0; 74 | unsafe { 75 | *dst.offset(0) = (pixel[2] as f32 * alpha) as BYTE; // Blue channel 76 | *dst.offset(1) = (pixel[1] as f32 * alpha) as BYTE; // Green channel 77 | *dst.offset(2) = (pixel[0] as f32 * alpha) as BYTE; // Red channel 78 | *dst.offset(3) = pixel[3]; // Alpha channel 79 | } 80 | } 81 | } 82 | 83 | Ok(hbitmap) 84 | } 85 | --------------------------------------------------------------------------------