first commit
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/dist/
|
||||||
|
/target/
|
||||||
|
/Cargo.lock
|
||||||
3
.taurignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/src
|
||||||
|
/public
|
||||||
|
/Cargo.toml
|
||||||
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"rust": "html"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "razer-linux-desktop-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
[dependencies]
|
||||||
|
leptos = { version = "0.8", features = ["csr"] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
js-sys = "0.3"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
console_error_panic_hook = "0.1.7"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["src-tauri"]
|
||||||
94
MIGRATION_STATUS.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Migration Status
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Replaced the default Tauri + Leptos starter UI with a desktop-oriented Razer app shell.
|
||||||
|
- Added native Linux hidraw discovery for supported devices:
|
||||||
|
- Razer Basilisk V3 `1532:0099`
|
||||||
|
- Razer Basilisk V3 Pro wired `1532:00aa`
|
||||||
|
- Razer Basilisk V3 Pro wireless `1532:00ab`
|
||||||
|
- Ported the Python report framing used by `qdrazer.protocol.Report` into Rust:
|
||||||
|
- 90-byte report payload plus report id
|
||||||
|
- Basilisk transaction id `0x1f`
|
||||||
|
- command class/id layout
|
||||||
|
- XOR CRC over bytes 2 through 87
|
||||||
|
- feature report send/receive through `HIDIOCSFEATURE` and `HIDIOCGFEATURE`
|
||||||
|
- Implemented a native connect and snapshot flow:
|
||||||
|
- serial number
|
||||||
|
- firmware version
|
||||||
|
- available onboard profiles
|
||||||
|
- basic profile settings
|
||||||
|
- Implemented native writes for the first vertical slice:
|
||||||
|
- scroll wheel mode
|
||||||
|
- scroll acceleration
|
||||||
|
- smart reel
|
||||||
|
- polling delay
|
||||||
|
- current DPI X/Y
|
||||||
|
- DPI stages get/set, stage count, and active stage selection
|
||||||
|
- Basic tab parity improvements from the Vue app:
|
||||||
|
- editable DPI stage list
|
||||||
|
- stage count control
|
||||||
|
- active stage control
|
||||||
|
- `Y = X` helper for DPI stages
|
||||||
|
- Profile tab parity improvements from the Vue app:
|
||||||
|
- create profile
|
||||||
|
- delete profile
|
||||||
|
- direct + white/red/green/blue/cyan slot management UI
|
||||||
|
- YAML export/import for the selected profile's basic, button, and LED bundle
|
||||||
|
- backend profile info chunked read/write support (`0x0588` / `0x0508`) carried in exported/imported bundles
|
||||||
|
- Connect flow parity improvements from the Vue app:
|
||||||
|
- scan + select device
|
||||||
|
- auto-connect first supported device after scan
|
||||||
|
- connect status and error handling
|
||||||
|
- LED tab parity improvements from the Vue app:
|
||||||
|
- region selection for wheel/logo/strip
|
||||||
|
- off/static/spectrum/wave effects
|
||||||
|
- per-region speed and brightness controls
|
||||||
|
- apply-to-all regions helper
|
||||||
|
- Button tab parity improvements from the Vue app:
|
||||||
|
- per-button assignment editing for normal and hypershift layers
|
||||||
|
- categories for mouse, keyboard, macro, DPI switch, profile switch, system, consumer, hypershift toggle, scroll mode toggle, and custom payloads
|
||||||
|
- native get/set button mapping commands through the Rust backend
|
||||||
|
- Macro tab parity improvements from the Vue app:
|
||||||
|
- macro list loading
|
||||||
|
- load/edit/save/delete by macro ID
|
||||||
|
- YAML export/import for all macro operation lists
|
||||||
|
- flash reset action
|
||||||
|
- native macro op encode/decode for keyboard, system, consumer, mouse button, mouse wheel, and delay operations
|
||||||
|
- preserve loaded `macro_info_hex` when round-tripping an existing macro through the editor
|
||||||
|
- Sensor tab parity improvements from the Vue app:
|
||||||
|
- lift mode selection for smart, smart asym, config, and self calibration modes
|
||||||
|
- calibration start/stop controls
|
||||||
|
- retrieved calibration data editor
|
||||||
|
- parameter calculator for symmetric and asymmetric lift settings
|
||||||
|
- set-params write path for config A / config B blobs
|
||||||
|
- Debug tooling improvements:
|
||||||
|
- raw sent/received report logging from the Tauri hidraw backend
|
||||||
|
- log viewer and clear action in the Info tab
|
||||||
|
- warning/error entries for exclusive-access and command failures
|
||||||
|
|
||||||
|
## Follow-Up Fixes Applied During Parity Review
|
||||||
|
|
||||||
|
- Removed the user-facing `Use Without Mouse` entry point.
|
||||||
|
- Added auto-connect after device scan.
|
||||||
|
- Replaced the polling delay number input with the source-style fixed Hz choices.
|
||||||
|
- Made short or partially decoded button mappings fall back to `custom/raw` instead of failing the tab.
|
||||||
|
- Fixed the Sensor tab reactive loop that could freeze the app.
|
||||||
|
- Restored the stylesheet after the broken import split and re-split it into trunk-linked CSS files under the file-size cap.
|
||||||
|
- Fixed the button-mapping save payload shape for the Tauri `set_button_mapping` command.
|
||||||
|
- Fixed LED option/layout regressions caused by over-aggressive wrapping rules.
|
||||||
|
- Serialized "Apply to all regions" through a single backend command instead of racing multiple hidraw writes from the frontend.
|
||||||
|
|
||||||
|
## Source Features Still To Port
|
||||||
|
|
||||||
|
- Completion audit and parity fixes against the Vue reference still remain.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This target no longer embeds Pyodide or uses WebHID. The backend is Tauri-native Rust and currently targets Linux hidraw devices. Running the real device workflow requires read/write permission for the matching `/dev/hidraw*` node, typically handled with a udev rule.
|
||||||
|
|
||||||
|
The active bundle target is AppImage. On modern rolling Linux distributions, linuxdeploy's bundled `strip` binary can fail on `.relr.dyn` ELF sections, so build AppImage artifacts with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NO_STRIP=true cargo tauri build
|
||||||
|
```
|
||||||
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Tauri + Leptos
|
||||||
|
|
||||||
|
This template should help get you started developing with Tauri and Leptos.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||||
9
Trunk.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[build]
|
||||||
|
target = "./index.html"
|
||||||
|
|
||||||
|
[watch]
|
||||||
|
ignore = ["./src-tauri"]
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
port = 1420
|
||||||
|
open = false
|
||||||
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Razer Basilisk V3 Onboard Memory Tools</title>
|
||||||
|
<link data-trunk rel="css" href="styles/structure.css" />
|
||||||
|
<link data-trunk rel="css" href="styles/panels.css" />
|
||||||
|
<link data-trunk rel="css" href="styles/details.css" />
|
||||||
|
<link data-trunk rel="copy-dir" href="public" />
|
||||||
|
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
64
public/leptos.svg
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 437.4294 209.6185" style="enable-background:new 0 0 437.4294 209.6185;" xml:space="preserve">
|
||||||
|
<path style="fill:none;" d="M130.0327,79.3931c-11.4854-0.23-22.52,9.3486-24.5034,21.0117l49.1157,0.0293
|
||||||
|
c-2.1729-10.418-11.1821-21.0449-24.1987-21.0449C130.3081,79.3892,130.1714,79.3907,130.0327,79.3931z"/>
|
||||||
|
<path style="fill:#181139;" d="M95.1109,128.1089H58.6797V65.6861c0-1.5234-0.8169-2.4331-2.1855-2.4331h-3.1187
|
||||||
|
c-1.3159,0-2.2349,1.0005-2.2349,2.4331v67.4297c0,1.4521,0.8145,2.2852,2.2349,2.2852h41.7353c1.4844,0,2.4819-0.9375,2.4819-2.333
|
||||||
|
v-2.7744C97.5928,128.9253,96.6651,128.1089,95.1109,128.1089z"/>
|
||||||
|
<path style="fill:#181139;" d="M146.4561,77.1739c-4.8252-3.001-10.3037-4.5249-16.2837-4.5288c-0.0068,0-0.0137,0-0.0205,0
|
||||||
|
c-5.7349,0-11.1377,1.4639-16.0566,4.3511c-4.916,2.8853-8.8721,6.8364-11.7593,11.7456
|
||||||
|
c-2.8975,4.9248-4.3687,10.332-4.3721,16.0713c-0.0034,5.7188,1.4966,11.0654,4.4565,15.8887
|
||||||
|
c2.9893,4.9209,6.8789,8.7334,11.8887,11.6514c4.8657,2.8633,10.2397,4.3174,15.9717,4.3203c0.0073,0,0.0146,0,0.022,0
|
||||||
|
c8.123,0,14.7441-2.5869,21.4683-8.3906c0.5493-0.4805,0.8516-1.1201,0.8516-1.8008c0.001-0.6074-0.1743-1.1035-0.5205-1.4756
|
||||||
|
l-1.3569-1.8428l-0.0732-0.0859c-0.2637-0.2637-0.6929-0.6152-1.3716-0.6152c-0.6421,0-1.2549,0.2217-1.7124,0.6143
|
||||||
|
c-1.9346,1.585-3.5459,2.8008-4.7969,3.6182c-1.7979,1.208-5.8218,3.2314-12.5986,3.2314c-0.0073,0-0.0142,0-0.021,0
|
||||||
|
c-0.1357,0.0029-0.269,0.0039-0.4043,0.0039c-12.2642,0-23.4736-10.3262-24.5088-22.4814l53.0127,0.0322c0.0015,0,0.0024,0,0.0034,0
|
||||||
|
c2.2373,0,3.4697-1.1621,3.4712-3.2715c0.0034-5.2588-1.3574-10.3945-4.0464-15.2705
|
||||||
|
C155.0015,84.0953,151.2188,80.1363,146.4561,77.1739z M154.6451,100.4341l-49.1157-0.0293
|
||||||
|
c1.9834-11.6631,13.0181-21.2417,24.5034-21.0117c0.1387-0.0024,0.2754-0.0039,0.4136-0.0039
|
||||||
|
C143.4629,79.3892,152.4722,90.0162,154.6451,100.4341z"/>
|
||||||
|
<path style="fill:#181139;" d="M204.0386,136.6382c5.7319,0,11.1069-1.4502,15.9746-4.3115
|
||||||
|
c4.938-2.9014,8.75-6.7129,11.6533-11.6533c2.8608-4.8672,4.311-10.2578,4.311-16.0244c0-5.7324-1.4502-11.1064-4.311-15.9746
|
||||||
|
c-2.9019-4.9385-6.7134-8.75-11.6533-11.6533c-4.8687-2.8618-10.2437-4.3125-15.9746-4.3125
|
||||||
|
c-9.938,0-19.2021,4.7583-24.3516,12.3174v-9.438c0-0.5946-0.1465-1.0788-0.411-1.4511c-0.3815-0.5369-1.0157-0.834-1.8727-0.834
|
||||||
|
h-2.6738c-1.4521,0-2.2852,0.833-2.2852,2.2852v5.6964v46.4791v23.9676c0,1.2568,0.7808,2.0371,2.0371,2.0371h3.3667
|
||||||
|
c0.9209,0,1.6421-0.6992,1.6421-1.5908v-17.098v-10.984C185.0884,131.8892,194.2749,136.6382,204.0386,136.6382z M186.6358,122.5591
|
||||||
|
c-4.9346-4.9346-7.6831-11.4932-7.542-18.0254c-0.1367-6.3506,2.5439-12.751,7.3545-17.5605
|
||||||
|
c4.8521-4.8521,11.3037-7.5547,17.7383-7.417c4.3691,0,8.4863,1.1465,12.2314,3.4043c3.7344,2.2979,6.7456,5.4053,8.9492,9.2354
|
||||||
|
c2.1699,3.9072,3.2695,8.0967,3.2695,12.4697c0.1396,6.4619-2.5967,12.9844-7.5083,17.8955
|
||||||
|
c-4.7617,4.7617-11.0469,7.3857-17.2544,7.2803C197.6856,129.9712,191.396,127.3208,186.6358,122.5591z"/>
|
||||||
|
<path style="fill:#181139;" d="M241.8955,80.3975h7.5669v42.0259c0,6.8174,4.5674,12.1309,11.0825,12.9189
|
||||||
|
c0.6836,0.1055,1.8379,0.1572,3.5303,0.1572c2.0078,0,3.0273-0.3535,3.0273-2.2842v-2.377c0-1.7891-1.334-2.0371-2.7568-2.0371
|
||||||
|
c0,0-0.001,0-0.002,0l-1.7871-0.0488c-2.0117-0.0439-3.4883-0.7627-4.3896-2.1367c-0.9697-1.4805-1.4619-3.1738-1.4619-5.0352
|
||||||
|
V80.3975h10.0928c1.3076,0,2.2852-1.3628,2.2852-2.5815v-1.9312c0-1.3999-0.8359-2.2354-2.2354-2.2354h-10.1426V60.6861
|
||||||
|
c0-1.4619-0.7969-2.4829-1.9375-2.4829c-0.1865,0-0.4121,0-0.6392,0.0884l-2.6489,0.6865
|
||||||
|
c-1.2109,0.3682-2.0171,0.9263-2.0171,2.4507v12.2207h-7.5669c-1.4185,0-2.335,0.897-2.335,2.2852v1.8813
|
||||||
|
C239.5606,79.2393,240.6079,80.3975,241.8955,80.3975z"/>
|
||||||
|
<path style="fill:#181139;" d="M379.1182,106.2691c-4.0488-2.9219-8.8545-5.0293-14.291-6.2646
|
||||||
|
c-6.5049-1.3975-13.4473-5.2129-13.3203-10.3066c0-7.5225,6.6367-10.1914,12.3203-10.1914c5.3574,0,10.2207,3.002,13.001,8.0146
|
||||||
|
c0.6729,1.2861,1.4785,1.9375,2.3955,1.9375c0.3311,0,0.7061-0.1113,0.9922-0.2832l2.2021-1.1523
|
||||||
|
c0.5947-0.3408,0.9229-0.9414,0.9229-1.6924c0-0.5205-0.0908-0.9541-0.2617-1.292c-3.6367-8.2466-10.0967-12.4282-19.2021-12.4282
|
||||||
|
c-11.7305,0-19.6123,6.9263-19.6123,17.2349c0,4.3125,1.8438,7.9746,5.4756,10.8809c3.4482,2.7979,7.9121,4.8623,13.2705,6.1377
|
||||||
|
c4.5859,1.085,8.3193,2.5654,11.0977,4.4023c1.4159,0.9354,2.4412,2.0535,3.106,3.3672c0.6053,1.1962,0.9135,2.5535,0.9135,4.1005
|
||||||
|
c0.0742,2.3857-0.79,4.5176-2.5684,6.3389c-3.1445,3.2178-8.4053,4.6689-12.0205,4.6689c-0.0361,0-0.0723,0-0.1074,0
|
||||||
|
c-3.4268,0-6.4893-0.8438-9.1035-2.5068c-2.5918-1.6484-4.2363-3.8076-5.0293-6.6064c-0.3203-1.0996-0.751-2.1738-2.1553-2.1738
|
||||||
|
c-0.0742,0-0.2109,0.0146-0.4062,0.0449c-0.1133,0.0166-0.2559,0.0381-0.5088,0.0742l-1.8818,0.4463l-0.1045,0.0332
|
||||||
|
c-1.0244,0.4082-1.6113,1.1846-1.6113,2.1309c0,0.2285,0.0625,0.6592,0.2178,1.1094c1.9707,8.5801,10.2432,14.3447,20.5732,14.3447
|
||||||
|
c0.125,0.002,0.249,0.002,0.374,0.002c6.5947,0,12.6748-2.3193,16.7275-6.3945c3.1895-3.208,4.8311-7.2363,4.748-11.6357
|
||||||
|
c0-2.8187-0.6185-5.3109-1.8062-7.481C382.4437,109.2624,381.0062,107.631,379.1182,106.2691z"/>
|
||||||
|
<path style="fill:#EF3939;" d="M348.9043,45.7325c0-6.3157-3.2826-11.8699-8.2238-15.0756
|
||||||
|
c-2.811-1.8237-6.1537-2.8947-9.7469-2.8947c-9.9092,0-17.9707,8.0615-17.9707,17.9702c0,4.7659,1.8775,9.0925,4.9157,12.3123
|
||||||
|
c-3.6619,4.3709-6.6334,9.3336-8.7663,14.7186c-1.5873-0.2422-3.2123-0.3683-4.8662-0.3683
|
||||||
|
c-17.7158,0-32.1289,14.4131-32.1289,32.1289c0,14.6854,9.9077,27.0922,23.3869,30.9101
|
||||||
|
c-6.7762,17.3461-23.6572,29.6719-43.3742,29.6719c-16.8195,0-31.583-8.9662-39.7656-22.369
|
||||||
|
c-2.4778,0.5446-5.0429,0.8519-7.6721,0.9023c9.0226,16.99,26.8969,28.5917,47.4377,28.5917
|
||||||
|
c23.2646,0,43.1121-14.8788,50.5461-35.6179c0.5204,0.0251,1.0435,0.0398,1.5701,0.0398c17.7158,0,32.1289-14.4131,32.1289-32.1289
|
||||||
|
c0-13.557-8.4446-25.1712-20.3465-29.8811c1.9001-4.5678,4.5115-8.7646,7.6888-12.4641c0.9996,0.4404,2.0479,0.785,3.1324,1.0384
|
||||||
|
c1.3144,0.3071,2.6773,0.486,4.0839,0.486C340.8428,63.7032,348.9043,55.6416,348.9043,45.7325z M304.2461,129.5279
|
||||||
|
c-13.7871,0-25.0039-11.2168-25.0039-25.0039s11.2168-25.0039,25.0039-25.0039S329.25,90.7369,329.25,104.524
|
||||||
|
S318.0332,129.5279,304.2461,129.5279z M330.9336,34.8872c0.645,0,1.2737,0.0671,1.8881,0.1755
|
||||||
|
c5.0818,0.8974,8.9576,5.3347,8.9576,10.6697c0,5.9805-4.8652,10.8457-10.8457,10.8457s-10.8457-4.8652-10.8457-10.8457
|
||||||
|
c0-1.3967,0.2746-2.7282,0.7576-3.9555C322.4306,37.7496,326.35,34.8872,330.9336,34.8872z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
97
public/snakemouse.svg
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--logos"
|
||||||
|
width="31.88"
|
||||||
|
height="32"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 256 257"
|
||||||
|
version="1.1"
|
||||||
|
id="svg6"
|
||||||
|
sodipodi:docname="vite.svg"
|
||||||
|
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview6"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="19.78125"
|
||||||
|
inkscape:cx="20.979463"
|
||||||
|
inkscape:cy="12.638231"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1525"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="35"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg6" />
|
||||||
|
<defs
|
||||||
|
id="defs5">
|
||||||
|
<linearGradient
|
||||||
|
id="IconifyId1813088fe1fbc01fb466"
|
||||||
|
x1="-.828%"
|
||||||
|
x2="57.636%"
|
||||||
|
y1="7.652%"
|
||||||
|
y2="78.411%">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#41D1FF"
|
||||||
|
id="stop1" />
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#BD34FE"
|
||||||
|
id="stop2" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="IconifyId1813088fe1fbc01fb467"
|
||||||
|
x1="43.376%"
|
||||||
|
x2="50.316%"
|
||||||
|
y1="2.242%"
|
||||||
|
y2="89.03%">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#FFEA83"
|
||||||
|
id="stop3" />
|
||||||
|
<stop
|
||||||
|
offset="8.333%"
|
||||||
|
stop-color="#FFDD35"
|
||||||
|
id="stop4" />
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#FFA800"
|
||||||
|
id="stop5" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
style="fill:#000000;stroke:#808080;stroke-width:3.03543"
|
||||||
|
d="m 76.716476,88.102684 c -15.949016,6.71076 -23.212565,27.231106 -29.638231,42.224326 -5.533186,12.91078 -8.884299,25.86473 -10.150079,39.78832 -1.433795,15.77173 -5.399311,40.20965 6.49605,53.1864 2.255829,2.46091 4.524334,3.32554 7.308057,4.87204 5.333748,2.9632 11.173654,5.39575 17.458136,4.87205 17.397704,-1.44981 42.177531,-22.36433 50.344391,-35.72829 8.66645,-14.18145 11.01373,-34.55233 9.33808,-50.75039 -0.93957,-9.08243 -4.58663,-17.99756 -6.90206,-26.79621 -1.72562,-6.55735 -3.47692,-14.30617 -6.90206,-20.300156 -1.60918,-2.81607 -25.636798,-11.716188 -30.856235,-12.586101 -3.333575,-0.555602 -6.463469,1.213939 -9.744075,1.624015"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#d3d3d3;stroke-width:3.03543"
|
||||||
|
d="M 95.392622,92.162714 C 87.968647,113.87631 85.056612,136.75478 79.558497,158.74724"
|
||||||
|
id="path7" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#d3d3d3;stroke-width:3.03543"
|
||||||
|
d="m 42.206207,149.00316 c 2.52918,0 1.653556,-0.1901 4.872038,1.21801 10.435697,4.56562 22.658434,8.71935 34.104269,10.15007 13.776268,1.72204 31.442876,4.15035 43.442336,10.15008"
|
||||||
|
id="path8" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:3.03543"
|
||||||
|
d="m 93.768607,94.598736 c 0.846631,-6.535678 6.902053,-12.07655 6.902053,-18.676145 0,-4.997065 -2.0054,-6.153307 -4.060026,-10.556083 -9.45919,-20.269682 -30.703928,-0.580078 -39.38231,9.338074 -5.524911,6.314186 -8.808542,15.304592 -14.616114,21.112166 -0.206039,0.206034 -8.613433,1.288485 -10.962085,1.624007 -2.41322,0.344749 -6.499213,2.43444 -8.932069,1.218011 C 9.2334969,91.916492 6.9659315,76.488599 11.75597,62.118484"
|
||||||
|
id="path9" />
|
||||||
|
<path
|
||||||
|
style="fill:#00ff00;stroke:#000000;stroke-width:3.03543"
|
||||||
|
d="m 174.15724,21.92417 c -14.25178,-10.578418 -30.06864,-8.789617 -46.28436,-4.060031 -1.58986,0.463708 -8.30108,2.211024 -9.33808,3.248025 -2.56392,2.563925 -8.34176,2.251708 -10.96209,4.872038 -0.38092,0.380914 5.2193,2.408908 5.27804,2.436019 5.89893,2.722581 11.89935,5.22873 17.86415,7.71406 0.11083,0.04618 6.57248,1.76969 5.68404,2.436019 -1.52267,1.141999 -2.64159,0.825819 -4.46603,1.624013 -7.7971,3.411229 -11.3179,6.30445 -19.48815,8.120063 -2.72761,0.606135 -5.76808,0.911566 -8.52607,1.21801 -1.35178,0.1502 -5.420126,0.406004 -4.060034,0.406004 4.473464,0 11.308844,2.238069 15.022114,4.466034 5.62434,3.374602 12.125,3.675784 17.45814,8.120064 8.86751,7.389591 10.06228,18.447884 14.21011,29.232222 0.90161,2.344201 2.25859,4.373851 2.84202,6.902056 6.55693,28.413364 -5.35327,85.657594 12.5861,107.184834 0.83585,1.00302 3.57596,2.54533 4.46603,2.84202 3.43259,1.1442 4.91874,6.37628 8.93208,7.71407 12.68368,4.22789 25.28251,6.45908 38.57029,8.12006 3.60737,0.45095 7.18347,2.25378 10.96209,1.62401 2.65812,-0.443 7.31468,-3.82041 9.74407,-5.27804 0.4924,-0.29539 1.75112,-1.00477 1.21801,-1.21801 -2.35257,-0.94103 -18.20334,-9.44087 -19.48815,-11.36809 -1.46524,-2.19786 -2.12781,-4.56383 -4.06003,-6.49605 -1.08523,-1.08523 -2.96453,-1.05623 -4.06003,-2.03001 -2.4415,-2.17022 -4.69971,-4.5398 -7.30806,-6.49606 -2.10939,-1.58205 -5.08105,-1.97888 -6.90205,-4.06003 -1.48777,-1.7003 -1.37359,-3.56486 -2.84202,-5.27804 -0.92437,-1.07843 -3.90245,-2.52685 -4.46604,-3.65402 -2.60805,-5.2161 -3.24867,-12.58913 -4.46603,-18.27015 -7.70018,-35.93416 -4.42482,-71.992398 -1.62401,-108.402842 0.58226,-7.569903 4.60669,-30.08214 -6.49605,-31.668248 z"
|
||||||
|
id="path10" />
|
||||||
|
<path
|
||||||
|
style="fill:#000000;stroke:#000000;stroke-width:3.03543"
|
||||||
|
d="m 152.63907,30.85624 c -0.37964,1.695848 -3.25427,-2.975275 -1.21801,-3.654028 8.41815,-2.806052 10.21109,8.901564 6.90205,10.556082 -3.79602,1.898012 -9.84544,-1.07609 -5.68404,-6.902054 z"
|
||||||
|
id="path11" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
6
public/tauri.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
25
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "razer-linux-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "razer_linux_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
397
src-tauri/src/backend/button_mapping.rs
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
fn decode_button_mapping(
|
||||||
|
profile: String,
|
||||||
|
button: String,
|
||||||
|
hypershift: bool,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<ButtonMappingState, String> {
|
||||||
|
let fn_class = data.first().copied().unwrap_or(0);
|
||||||
|
let fn_len = data
|
||||||
|
.get(1)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(data.len().saturating_sub(2) as u8) as usize;
|
||||||
|
let payload_start = data.len().min(2);
|
||||||
|
let payload_end = (2 + fn_len.min(5)).min(data.len());
|
||||||
|
let fn_value = &data[payload_start..payload_end];
|
||||||
|
let decoded = match fn_class {
|
||||||
|
0x00 => Ok(("disabled".to_string(), json!({}))),
|
||||||
|
0x01 => Ok((
|
||||||
|
"mouse".to_string(),
|
||||||
|
json!({"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Mouse mapping payload too short".to_string())?)?, "double_click": false}),
|
||||||
|
)),
|
||||||
|
0x0b => Ok((
|
||||||
|
"mouse".to_string(),
|
||||||
|
json!({"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Mouse mapping payload too short".to_string())?)?, "double_click": true}),
|
||||||
|
)),
|
||||||
|
0x0e => Ok((
|
||||||
|
"mouse".to_string(),
|
||||||
|
json!({
|
||||||
|
"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?)?,
|
||||||
|
"turbo": u16::from_be_bytes([
|
||||||
|
*fn_value.get(1).ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?,
|
||||||
|
*fn_value.get(2).ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
0x02 => Ok((
|
||||||
|
"keyboard".to_string(),
|
||||||
|
json!({
|
||||||
|
"modifier": decode_modifiers(*fn_value.first().ok_or_else(|| "Keyboard mapping payload too short".to_string())?),
|
||||||
|
"key": *fn_value.get(1).ok_or_else(|| "Keyboard mapping payload too short".to_string())?
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
0x0d => Ok((
|
||||||
|
"keyboard".to_string(),
|
||||||
|
json!({
|
||||||
|
"modifier": decode_modifiers(*fn_value.first().ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?),
|
||||||
|
"key": *fn_value.get(1).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
|
||||||
|
"turbo": u16::from_be_bytes([
|
||||||
|
*fn_value.get(2).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
|
||||||
|
*fn_value.get(3).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
0x03 | 0x04 | 0x05 | 0x0f => Ok((
|
||||||
|
"macro".to_string(),
|
||||||
|
json!({
|
||||||
|
"mode": decode_macro_mode(fn_class)?,
|
||||||
|
"macro_id": u16::from_be_bytes([
|
||||||
|
*fn_value.first().ok_or_else(|| "Macro mapping payload too short".to_string())?,
|
||||||
|
*fn_value.get(1).ok_or_else(|| "Macro mapping payload too short".to_string())?,
|
||||||
|
]),
|
||||||
|
"times": if fn_class == 0x03 { fn_value.get(2).copied().unwrap_or(1) } else { 1 }
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
0x06 => decode_dpi_switch(fn_value).map(|payload| ("dpi_switch".to_string(), payload)),
|
||||||
|
0x07 => decode_profile_switch(fn_value).map(|payload| ("profile_switch".to_string(), payload)),
|
||||||
|
0x09 => Ok((
|
||||||
|
"system".to_string(),
|
||||||
|
json!({"fn": decode_system_flags(*fn_value.first().ok_or_else(|| "System mapping payload too short".to_string())?)}),
|
||||||
|
)),
|
||||||
|
0x0a => Ok((
|
||||||
|
"consumer".to_string(),
|
||||||
|
json!({"fn": u16::from_be_bytes([
|
||||||
|
*fn_value.first().ok_or_else(|| "Consumer mapping payload too short".to_string())?,
|
||||||
|
*fn_value.get(1).ok_or_else(|| "Consumer mapping payload too short".to_string())?,
|
||||||
|
])}),
|
||||||
|
)),
|
||||||
|
0x0c => Ok((
|
||||||
|
"hypershift_toggle".to_string(),
|
||||||
|
json!({"fn": *fn_value.first().ok_or_else(|| "Hypershift toggle payload too short".to_string())?}),
|
||||||
|
)),
|
||||||
|
0x12 => Ok((
|
||||||
|
"scroll_mode_toggle".to_string(),
|
||||||
|
json!({"fn": *fn_value.first().ok_or_else(|| "Scroll mode toggle payload too short".to_string())?}),
|
||||||
|
)),
|
||||||
|
_ => Ok(("custom".to_string(), json!({"fn_class": fn_class, "fn_value": fn_value}))),
|
||||||
|
};
|
||||||
|
let (category, payload) = decoded.unwrap_or_else(|_| {
|
||||||
|
(
|
||||||
|
"custom".to_string(),
|
||||||
|
json!({"fn_class": fn_class, "fn_value": fn_value}),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ButtonMappingState {
|
||||||
|
profile,
|
||||||
|
button,
|
||||||
|
hypershift,
|
||||||
|
category,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_button_mapping(mapping: &ButtonMappingState) -> Result<[u8; 7], String> {
|
||||||
|
let mut out = [0u8; 7];
|
||||||
|
let payload = mapping
|
||||||
|
.payload
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "Button mapping payload must be an object".to_string())?;
|
||||||
|
|
||||||
|
let (fn_class, fn_value) = match mapping.category.as_str() {
|
||||||
|
"disabled" => (0x00, vec![]),
|
||||||
|
"mouse" => {
|
||||||
|
let fn_button = fn_mouse_value(get_string(payload, "fn")?)?;
|
||||||
|
if payload.get("turbo").is_some() && !payload["turbo"].is_null() {
|
||||||
|
let turbo = get_u16(payload, "turbo")?;
|
||||||
|
(0x0e, vec![fn_button, (turbo >> 8) as u8, turbo as u8])
|
||||||
|
} else if payload
|
||||||
|
.get("double_click")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
(0x0b, vec![fn_button])
|
||||||
|
} else {
|
||||||
|
(0x01, vec![fn_button])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"keyboard" => {
|
||||||
|
let modifier = encode_modifiers(payload.get("modifier"))?;
|
||||||
|
let key = get_u8(payload, "key")?;
|
||||||
|
if payload.get("turbo").is_some() && !payload["turbo"].is_null() {
|
||||||
|
let turbo = get_u16(payload, "turbo")?;
|
||||||
|
(0x0d, vec![modifier, key, (turbo >> 8) as u8, turbo as u8])
|
||||||
|
} else {
|
||||||
|
(0x02, vec![modifier, key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"macro" => {
|
||||||
|
let fn_class = encode_macro_mode(get_string(payload, "mode")?)?;
|
||||||
|
let macro_id = get_u16(payload, "macro_id")?;
|
||||||
|
let mut value = vec![(macro_id >> 8) as u8, macro_id as u8];
|
||||||
|
if fn_class == 0x03 {
|
||||||
|
value.push(get_u8(payload, "times").unwrap_or(1));
|
||||||
|
}
|
||||||
|
(fn_class, value)
|
||||||
|
}
|
||||||
|
"dpi_switch" => encode_dpi_switch(payload)?,
|
||||||
|
"profile_switch" => encode_profile_switch(payload)?,
|
||||||
|
"system" => (0x09, vec![encode_system_flags(payload.get("fn"))?]),
|
||||||
|
"consumer" => {
|
||||||
|
let code = get_u16(payload, "fn")?;
|
||||||
|
(0x0a, vec![(code >> 8) as u8, code as u8])
|
||||||
|
}
|
||||||
|
"hypershift_toggle" => (0x0c, vec![get_u8(payload, "fn").unwrap_or(1)]),
|
||||||
|
"scroll_mode_toggle" => (0x12, vec![get_u8(payload, "fn").unwrap_or(1)]),
|
||||||
|
"custom" => {
|
||||||
|
let fn_class = get_u8(payload, "fn_class")?;
|
||||||
|
let fn_value = payload
|
||||||
|
.get("fn_value")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| "custom.fn_value must be an array".to_string())?
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.filter(|value| *value <= 255)
|
||||||
|
.map(|value| value as u8)
|
||||||
|
.ok_or_else(|| "custom.fn_value must contain bytes".to_string())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
(fn_class, fn_value)
|
||||||
|
}
|
||||||
|
other => return Err(format!("Unsupported button mapping category '{other}'")),
|
||||||
|
};
|
||||||
|
|
||||||
|
if fn_value.len() > 5 {
|
||||||
|
return Err("Button mapping payload is too large".to_string());
|
||||||
|
}
|
||||||
|
out[0] = fn_class;
|
||||||
|
out[1] = fn_value.len() as u8;
|
||||||
|
out[2..2 + fn_value.len()].copy_from_slice(&fn_value);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_fn_mouse(value: u8) -> Result<&'static str, String> {
|
||||||
|
match value {
|
||||||
|
0x01 => Ok("left"),
|
||||||
|
0x02 => Ok("right"),
|
||||||
|
0x03 => Ok("middle"),
|
||||||
|
0x04 => Ok("backward"),
|
||||||
|
0x05 => Ok("forward"),
|
||||||
|
0x09 => Ok("wheel_up"),
|
||||||
|
0x0a => Ok("wheel_down"),
|
||||||
|
0x68 => Ok("wheel_left"),
|
||||||
|
0x69 => Ok("wheel_right"),
|
||||||
|
_ => Err(format!("Unknown mouse function value {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_modifiers(value: u8) -> Vec<&'static str> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (flag, name) in [
|
||||||
|
(0x01, "left_control"),
|
||||||
|
(0x02, "left_shift"),
|
||||||
|
(0x04, "left_alt"),
|
||||||
|
(0x08, "left_gui"),
|
||||||
|
(0x10, "right_control"),
|
||||||
|
(0x20, "right_shift"),
|
||||||
|
(0x40, "right_alt"),
|
||||||
|
(0x80, "right_gui"),
|
||||||
|
] {
|
||||||
|
if value & flag != 0 {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_modifiers(value: Option<&Value>) -> Result<u8, String> {
|
||||||
|
let mut out = 0u8;
|
||||||
|
let Some(values) = value else { return Ok(out) };
|
||||||
|
let Some(values) = values.as_array() else { return Err("modifier must be an array".to_string()) };
|
||||||
|
for item in values {
|
||||||
|
out |= match item.as_str().ok_or_else(|| "modifier entries must be strings".to_string())? {
|
||||||
|
"left_control" => 0x01,
|
||||||
|
"left_shift" => 0x02,
|
||||||
|
"left_alt" => 0x04,
|
||||||
|
"left_gui" => 0x08,
|
||||||
|
"right_control" => 0x10,
|
||||||
|
"right_shift" => 0x20,
|
||||||
|
"right_alt" => 0x40,
|
||||||
|
"right_gui" => 0x80,
|
||||||
|
other => return Err(format!("Unknown modifier '{other}'")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_macro_mode(value: u8) -> Result<&'static str, String> {
|
||||||
|
match value {
|
||||||
|
0x03 => Ok("macro_fixed"),
|
||||||
|
0x04 => Ok("macro_hold"),
|
||||||
|
0x05 => Ok("macro_toggle"),
|
||||||
|
0x0f => Ok("macro_sequence"),
|
||||||
|
_ => Err(format!("Unknown macro mode {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_macro_mode(value: &str) -> Result<u8, String> {
|
||||||
|
match value {
|
||||||
|
"macro_fixed" => Ok(0x03),
|
||||||
|
"macro_hold" => Ok(0x04),
|
||||||
|
"macro_toggle" => Ok(0x05),
|
||||||
|
"macro_sequence" => Ok(0x0f),
|
||||||
|
_ => Err(format!("Unknown macro mode '{value}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_dpi_switch(value: &[u8]) -> Result<Value, String> {
|
||||||
|
Ok(match value.len() {
|
||||||
|
1 => json!({ "fn": decode_dpi_switch_op(value[0])? }),
|
||||||
|
2 => json!({ "fn": decode_dpi_switch_op(value[0])?, "stage": value[1] }),
|
||||||
|
5 => json!({ "fn": decode_dpi_switch_op(value[0])?, "dpi": [u16::from_be_bytes([value[1], value[2]]), u16::from_be_bytes([value[3], value[4]])] }),
|
||||||
|
_ => return Err("Unsupported DPI switch payload".to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_dpi_switch(payload: &serde_json::Map<String, Value>) -> Result<(u8, Vec<u8>), String> {
|
||||||
|
let op = get_string(payload, "fn")?;
|
||||||
|
let op_value = encode_dpi_switch_op(op)?;
|
||||||
|
Ok(match op {
|
||||||
|
"fixed" => (0x06, vec![op_value, get_u8(payload, "stage")?]),
|
||||||
|
"aim" => {
|
||||||
|
let dpi = payload
|
||||||
|
.get("dpi")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| "dpi_switch.dpi must be an array".to_string())?;
|
||||||
|
let x = dpi.first().and_then(Value::as_u64).ok_or_else(|| "dpi_switch.dpi[0] missing".to_string())? as u16;
|
||||||
|
let y = dpi.get(1).and_then(Value::as_u64).ok_or_else(|| "dpi_switch.dpi[1] missing".to_string())? as u16;
|
||||||
|
(0x06, vec![op_value, (x >> 8) as u8, x as u8, (y >> 8) as u8, y as u8])
|
||||||
|
}
|
||||||
|
_ => (0x06, vec![op_value]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_dpi_switch_op(value: u8) -> Result<&'static str, String> {
|
||||||
|
match value {
|
||||||
|
0x01 => Ok("next"),
|
||||||
|
0x02 => Ok("prev"),
|
||||||
|
0x03 => Ok("fixed"),
|
||||||
|
0x05 => Ok("aim"),
|
||||||
|
0x06 => Ok("next_loop"),
|
||||||
|
0x07 => Ok("prev_loop"),
|
||||||
|
_ => Err(format!("Unknown DPI switch op {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_dpi_switch_op(value: &str) -> Result<u8, String> {
|
||||||
|
match value {
|
||||||
|
"next" => Ok(0x01),
|
||||||
|
"prev" => Ok(0x02),
|
||||||
|
"fixed" => Ok(0x03),
|
||||||
|
"aim" => Ok(0x05),
|
||||||
|
"next_loop" => Ok(0x06),
|
||||||
|
"prev_loop" => Ok(0x07),
|
||||||
|
_ => Err(format!("Unknown DPI switch op '{value}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_profile_switch(value: &[u8]) -> Result<Value, String> {
|
||||||
|
Ok(match value.len() {
|
||||||
|
1 => json!({ "fn": decode_profile_switch_op(value[0])? }),
|
||||||
|
2 => json!({ "fn": decode_profile_switch_op(value[0])?, "profile": profile_name(value[1])? }),
|
||||||
|
_ => return Err("Unsupported profile switch payload".to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_profile_switch(payload: &serde_json::Map<String, Value>) -> Result<(u8, Vec<u8>), String> {
|
||||||
|
let op = get_string(payload, "fn")?;
|
||||||
|
let op_value = encode_profile_switch_op(op)?;
|
||||||
|
Ok(if op == "fixed" {
|
||||||
|
(0x07, vec![op_value, profile_value(get_string(payload, "profile")?)?])
|
||||||
|
} else {
|
||||||
|
(0x07, vec![op_value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_profile_switch_op(value: u8) -> Result<&'static str, String> {
|
||||||
|
match value {
|
||||||
|
0x01 => Ok("next"),
|
||||||
|
0x02 => Ok("prev"),
|
||||||
|
0x03 => Ok("fixed"),
|
||||||
|
0x04 => Ok("next_loop"),
|
||||||
|
0x05 => Ok("prev_loop"),
|
||||||
|
_ => Err(format!("Unknown profile switch op {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_profile_switch_op(value: &str) -> Result<u8, String> {
|
||||||
|
match value {
|
||||||
|
"next" => Ok(0x01),
|
||||||
|
"prev" => Ok(0x02),
|
||||||
|
"fixed" => Ok(0x03),
|
||||||
|
"next_loop" => Ok(0x04),
|
||||||
|
"prev_loop" => Ok(0x05),
|
||||||
|
_ => Err(format!("Unknown profile switch op '{value}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_system_flags(value: u8) -> Vec<&'static str> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (flag, name) in [(0x01, "power_down"), (0x02, "sleep"), (0x04, "wake_up")] {
|
||||||
|
if value & flag != 0 {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_system_flags(value: Option<&Value>) -> Result<u8, String> {
|
||||||
|
let Some(values) = value else { return Ok(0) };
|
||||||
|
let Some(values) = values.as_array() else { return Err("system.fn must be an array".to_string()) };
|
||||||
|
let mut out = 0u8;
|
||||||
|
for item in values {
|
||||||
|
out |= match item.as_str().ok_or_else(|| "system.fn entries must be strings".to_string())? {
|
||||||
|
"power_down" => 0x01,
|
||||||
|
"sleep" => 0x02,
|
||||||
|
"wake_up" => 0x04,
|
||||||
|
other => return Err(format!("Unknown system function '{other}'")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_string<'a>(payload: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a str, String> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| format!("Missing string field '{key}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_u8(payload: &serde_json::Map<String, Value>, key: &str) -> Result<u8, String> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.filter(|value| *value <= 255)
|
||||||
|
.map(|value| value as u8)
|
||||||
|
.ok_or_else(|| format!("Missing byte field '{key}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_u16(payload: &serde_json::Map<String, Value>, key: &str) -> Result<u16, String> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.filter(|value| *value <= 65535)
|
||||||
|
.map(|value| value as u16)
|
||||||
|
.ok_or_else(|| format!("Missing u16 field '{key}'"))
|
||||||
|
}
|
||||||
440
src-tauri/src/backend/commands.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_supported_devices() -> Result<Vec<DeviceSummary>, String> {
|
||||||
|
discover_devices().map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn connect_device(path: String, state: tauri::State<'_, AppState>) -> Result<DeviceState, String> {
|
||||||
|
let discovered = discover_devices().map_err(|err| err.to_string())?;
|
||||||
|
let selected = discovered
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.path == path)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("No supported Razer device found at {path}"))?;
|
||||||
|
let mut candidates = connection_candidates(&discovered, &selected);
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
for summary in candidates.drain(..) {
|
||||||
|
match try_connect_summary(summary.clone(), state.logs.clone()) {
|
||||||
|
Ok((device, snapshot)) => {
|
||||||
|
*state
|
||||||
|
.connected
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Device state lock was poisoned".to_string())? = Some(device);
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
Err(error) => failures.push(format!("{}: {error}", summary.path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Could not talk to the selected Razer device on any matching hidraw node. Tried: {}",
|
||||||
|
failures.join("; ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn refresh_device_state(
|
||||||
|
profile: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
with_device(&state, |device| device.snapshot(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_scroll_mode(
|
||||||
|
profile: String,
|
||||||
|
mode: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_scroll_mode(profile_value(&profile)?, scroll_mode_value(&mode)?)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_scroll_acceleration(
|
||||||
|
profile: String,
|
||||||
|
enabled: bool,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_bool_command(0x0216, profile_value(&profile)?, enabled)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_scroll_smart_reel(
|
||||||
|
profile: String,
|
||||||
|
enabled: bool,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_bool_command(0x0217, profile_value(&profile)?, enabled)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_polling_rate(
|
||||||
|
profile: String,
|
||||||
|
polling_rate_ms: u8,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
if polling_rate_ms == 0 {
|
||||||
|
return Err("Polling rate delay must be at least 1 ms".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.sr_with(0x000e, &[profile_value(&profile)?, polling_rate_ms], 0)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_dpi_xy(
|
||||||
|
profile: String,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
if !(100..=25600).contains(&x) || !(100..=25600).contains(&y) {
|
||||||
|
return Err("DPI must be between 100 and 25600".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
with_device(&state, |device| {
|
||||||
|
let mut args = Vec::with_capacity(7);
|
||||||
|
args.push(profile_value(&profile)?);
|
||||||
|
args.extend_from_slice(&x.to_be_bytes());
|
||||||
|
args.extend_from_slice(&y.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0, 0]);
|
||||||
|
device.sr_with(0x0405, &args, 0)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_dpi_stages(
|
||||||
|
profile: String,
|
||||||
|
dpi_stages: Vec<[u16; 2]>,
|
||||||
|
active_stage: u8,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
if dpi_stages.is_empty() || dpi_stages.len() > 5 {
|
||||||
|
return Err("DPI stage count must be between 1 and 5".to_string());
|
||||||
|
}
|
||||||
|
if active_stage == 0 || usize::from(active_stage) > dpi_stages.len() {
|
||||||
|
return Err("Active DPI stage must point to an existing stage".to_string());
|
||||||
|
}
|
||||||
|
if dpi_stages
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|dpi| !(100..=25600).contains(dpi))
|
||||||
|
{
|
||||||
|
return Err("All DPI stages must be between 100 and 25600".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
with_device(&state, |device| {
|
||||||
|
let mut args = Vec::with_capacity(38);
|
||||||
|
args.push(profile_value(&profile)?);
|
||||||
|
args.push(active_stage);
|
||||||
|
args.push(dpi_stages.len() as u8);
|
||||||
|
for (index, [x, y]) in dpi_stages.iter().copied().enumerate() {
|
||||||
|
args.push(index as u8);
|
||||||
|
args.extend_from_slice(&x.to_be_bytes());
|
||||||
|
args.extend_from_slice(&y.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0, 0]);
|
||||||
|
}
|
||||||
|
while args.len() < 38 {
|
||||||
|
args.extend_from_slice(&[0; 7]);
|
||||||
|
}
|
||||||
|
device.sr_with(0x0406, &args, 0)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_profile(
|
||||||
|
profile: String,
|
||||||
|
current_profile: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
let profile = profile_value(&profile)?;
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.sr_with(0x0502, &[profile], 0)?;
|
||||||
|
device.snapshot(¤t_profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_profile(
|
||||||
|
profile: String,
|
||||||
|
next_profile: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
let profile = profile_value(&profile)?;
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.sr_with(0x0503, &[profile], 0)?;
|
||||||
|
device.snapshot(&next_profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_led_state(profile: String, state: tauri::State<'_, AppState>) -> Result<LedState, String> {
|
||||||
|
with_device(&state, |device| device.led_snapshot(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_led_effect(
|
||||||
|
profile: String,
|
||||||
|
region: String,
|
||||||
|
effect: String,
|
||||||
|
mode: u8,
|
||||||
|
speed: u8,
|
||||||
|
colors: Vec<[u8; 3]>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<LedState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
let profile = profile_value(&profile)?;
|
||||||
|
let region = led_region_value(®ion)?;
|
||||||
|
let effect = led_effect_value(&effect)?;
|
||||||
|
let color_count = colors.len();
|
||||||
|
if color_count > 2 {
|
||||||
|
return Err("At most 2 LED colors are supported in this port".to_string());
|
||||||
|
}
|
||||||
|
let mut args = Vec::with_capacity(6 + color_count * 3);
|
||||||
|
args.push(profile);
|
||||||
|
args.push(region);
|
||||||
|
args.push(effect);
|
||||||
|
args.push(mode);
|
||||||
|
args.push(speed);
|
||||||
|
args.push(color_count as u8);
|
||||||
|
for color in colors {
|
||||||
|
args.extend_from_slice(&color);
|
||||||
|
}
|
||||||
|
device.sr_with(0x0f02, &args, 0)?;
|
||||||
|
device.led_snapshot(&profile_name(profile)?.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_led_brightness(
|
||||||
|
profile: String,
|
||||||
|
region: String,
|
||||||
|
brightness: u8,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<LedState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
let profile = profile_value(&profile)?;
|
||||||
|
let region = led_region_value(®ion)?;
|
||||||
|
device.sr_with(0x0f04, &[profile, region, brightness], 0)?;
|
||||||
|
device.led_snapshot(&profile_name(profile)?.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn apply_led_to_all_regions(
|
||||||
|
profile: String,
|
||||||
|
region_state: LedRegionState,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<LedState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
let profile_value = profile_value(&profile)?;
|
||||||
|
let effect_value = led_effect_value(®ion_state.effect)?;
|
||||||
|
for region in ["wheel", "logo", "strip"] {
|
||||||
|
let region_value = led_region_value(region)?;
|
||||||
|
let mut effect_args = Vec::with_capacity(6 + region_state.colors.len() * 3);
|
||||||
|
effect_args.push(profile_value);
|
||||||
|
effect_args.push(region_value);
|
||||||
|
effect_args.push(effect_value);
|
||||||
|
effect_args.push(region_state.mode);
|
||||||
|
effect_args.push(region_state.speed);
|
||||||
|
effect_args.push(region_state.colors.len() as u8);
|
||||||
|
for color in ®ion_state.colors {
|
||||||
|
effect_args.extend_from_slice(color);
|
||||||
|
}
|
||||||
|
device.sr_with(0x0f02, &effect_args, 0)?;
|
||||||
|
device.sr_with(0x0f04, &[profile_value, region_value, region_state.brightness], 0)?;
|
||||||
|
}
|
||||||
|
device.led_snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_button_mapping(
|
||||||
|
profile: String,
|
||||||
|
button: String,
|
||||||
|
hypershift: bool,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ButtonMappingState, String> {
|
||||||
|
with_device(&state, |device| device.get_button_mapping_state(&profile, &button, hypershift))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_button_mapping(
|
||||||
|
mapping: ButtonMappingState,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ButtonMappingState, String> {
|
||||||
|
with_device(&state, |device| device.set_button_mapping_state(mapping))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn export_profile_config(
|
||||||
|
profile: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProfileConfigBundle, String> {
|
||||||
|
with_device(&state, |device| device.export_profile_config(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn import_profile_config(
|
||||||
|
profile: String,
|
||||||
|
bundle: ProfileConfigBundle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<DeviceState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.import_profile_config(&profile, bundle)?;
|
||||||
|
device.snapshot(&profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_macros(state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
|
||||||
|
with_device(&state, |device| device.get_macro_list())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_macro_definition(
|
||||||
|
macro_id: u16,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<MacroDefinition, String> {
|
||||||
|
with_device(&state, |device| device.get_macro_definition(macro_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_macro_definition(
|
||||||
|
macro_id: u16,
|
||||||
|
definition: MacroDefinition,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<u16>, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_macro_definition(macro_id, definition)?;
|
||||||
|
device.get_macro_list()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_macro(macro_id: u16, state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.delete_macro(macro_id)?;
|
||||||
|
device.get_macro_list()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reset_macro_flash(state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.reset_macro_flash()?;
|
||||||
|
device.get_macro_list()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_debug_logs(state: tauri::State<'_, AppState>) -> Result<Vec<DebugLogEntry>, String> {
|
||||||
|
state
|
||||||
|
.logs
|
||||||
|
.lock()
|
||||||
|
.map(|logs| logs.clone())
|
||||||
|
.map_err(|_| "Debug log lock was poisoned".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_debug_logs(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.logs
|
||||||
|
.lock()
|
||||||
|
.map(|mut logs| logs.clear())
|
||||||
|
.map_err(|_| "Debug log lock was poisoned".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_sensor_state(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
|
||||||
|
with_device(&state, |device| device.sensor_snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_sensor_lift_mode(
|
||||||
|
lift_mode: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<SensorState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_sensor_lift_mode(&lift_mode)?;
|
||||||
|
device.sensor_snapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_sensor_calibration(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.start_sensor_calibration()?;
|
||||||
|
device.sensor_snapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn stop_sensor_calibration(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.stop_sensor_calibration()?;
|
||||||
|
device.sensor_snapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_sensor_params(
|
||||||
|
param_a: Vec<u8>,
|
||||||
|
param_b: Vec<u8>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<SensorState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.set_sensor_params(¶m_a, ¶m_b)?;
|
||||||
|
device.sensor_snapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_info_state(state: tauri::State<'_, AppState>) -> Result<InfoState, String> {
|
||||||
|
with_device(&state, |device| device.info_snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn send_raw_command(
|
||||||
|
command: u16,
|
||||||
|
args: Vec<u8>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
with_device(&state, |device| Ok(device.sr_with(command, &args, 0)?.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reset_flash(state: tauri::State<'_, AppState>) -> Result<InfoState, String> {
|
||||||
|
with_device(&state, |device| {
|
||||||
|
device.reset_macro_flash()?;
|
||||||
|
device.info_snapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_device<T>(
|
||||||
|
state: &tauri::State<'_, AppState>,
|
||||||
|
f: impl FnOnce(&mut ConnectedDevice) -> Result<T, String>,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
let mut guard = state
|
||||||
|
.connected
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Device state lock was poisoned".to_string())?;
|
||||||
|
let device = guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| "No device is connected".to_string())?;
|
||||||
|
f(device)
|
||||||
|
}
|
||||||
4
src-tauri/src/backend/device.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
include!("device_parts/core.rs");
|
||||||
|
include!("device_parts/profile_led_button.rs");
|
||||||
|
include!("device_parts/macro.rs");
|
||||||
|
include!("device_parts/sensor_io.rs");
|
||||||
196
src-tauri/src/backend/device_parts/core.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
impl ConnectedDevice {
|
||||||
|
fn snapshot(&mut self, profile: &str) -> Result<DeviceState, String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let serial = self.get_serial()?;
|
||||||
|
let firmware = self.get_firmware_version()?;
|
||||||
|
let profiles = self
|
||||||
|
.get_profile_list()
|
||||||
|
.unwrap_or_else(|_| vec!["direct".to_string()]);
|
||||||
|
let basic = BasicSettings {
|
||||||
|
profile: profile.to_string(),
|
||||||
|
scroll_mode: self.get_scroll_mode(profile_value)?,
|
||||||
|
scroll_acceleration: self.get_bool_command(0x0296, profile_value)?,
|
||||||
|
scroll_smart_reel: self.get_bool_command(0x0297, profile_value)?,
|
||||||
|
polling_rate_ms: self.get_polling_rate(profile_value)?,
|
||||||
|
dpi_xy: self.get_dpi_xy(profile_value)?,
|
||||||
|
dpi_stages: self.get_dpi_stages(profile_value)?.0,
|
||||||
|
active_dpi_stage: self.get_dpi_stages(profile_value)?.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DeviceState {
|
||||||
|
device: self.summary.clone(),
|
||||||
|
serial,
|
||||||
|
firmware,
|
||||||
|
profiles,
|
||||||
|
basic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_serial(&mut self) -> Result<String, String> {
|
||||||
|
let response = self.sr_with(0x0082, &[0; 16], 0)?;
|
||||||
|
let serial = response[..16]
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(String::from_utf8_lossy(serial).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_firmware_version(&mut self) -> Result<String, String> {
|
||||||
|
let response = self.sr_with(0x0081, &[0; 4], 0)?;
|
||||||
|
Ok(format!(
|
||||||
|
"{}.{}.{}.{}",
|
||||||
|
response[0], response[1], response[2], response[3]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile_list(&mut self) -> Result<Vec<String>, String> {
|
||||||
|
let count = self.sr_with(0x0580, &[0], 0)?[0] as usize;
|
||||||
|
let mut args = vec![0; count + 1];
|
||||||
|
let response = self.sr_with(0x0581, &args, 0)?;
|
||||||
|
args.clear();
|
||||||
|
|
||||||
|
let mut profiles = vec!["direct".to_string()];
|
||||||
|
for profile in response.iter().skip(1).take(count) {
|
||||||
|
if let Ok(name) = profile_name(*profile) {
|
||||||
|
if !profiles.iter().any(|existing| existing == name) {
|
||||||
|
profiles.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_scroll_mode(&mut self, profile: u8) -> Result<String, String> {
|
||||||
|
let response = self.sr_with(0x0294, &[profile, 0], 0)?;
|
||||||
|
match response[1] {
|
||||||
|
0 => Ok("tactile".to_string()),
|
||||||
|
1 => Ok("freespin".to_string()),
|
||||||
|
value => Err(format!("Unknown scroll mode value {value}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_scroll_mode(&mut self, profile: u8, mode: u8) -> Result<(), String> {
|
||||||
|
self.sr_with(0x0214, &[profile, mode], 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bool_command(&mut self, command: u16, profile: u8) -> Result<bool, String> {
|
||||||
|
Ok(self.sr_with(command, &[profile, 0], 0)?[1] != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bool_command(&mut self, command: u16, profile: u8, enabled: bool) -> Result<(), String> {
|
||||||
|
self.sr_with(command, &[profile, u8::from(enabled)], 0)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_polling_rate(&mut self, profile: u8) -> Result<u8, String> {
|
||||||
|
Ok(self.sr_with(0x008e, &[profile, 0], 0)?[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dpi_xy(&mut self, profile: u8) -> Result<[u16; 2], String> {
|
||||||
|
let response = self.sr_with(0x0485, &[profile, 0, 0, 0, 0, 0, 0], 0)?;
|
||||||
|
Ok([
|
||||||
|
u16::from_be_bytes([response[1], response[2]]),
|
||||||
|
u16::from_be_bytes([response[3], response[4]]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dpi_stages(&mut self, profile: u8) -> Result<(Vec<[u16; 2]>, u8), String> {
|
||||||
|
let mut args = vec![0; 38];
|
||||||
|
args[0] = profile;
|
||||||
|
let response = self.sr_with(0x0486, &args, 0)?;
|
||||||
|
let active_stage = response[1];
|
||||||
|
let stage_count = response[2].min(5) as usize;
|
||||||
|
let stages = (0..stage_count)
|
||||||
|
.map(|index| {
|
||||||
|
let offset = 3 + (index * 7);
|
||||||
|
[
|
||||||
|
u16::from_be_bytes([response[offset + 1], response[offset + 2]]),
|
||||||
|
u16::from_be_bytes([response[offset + 3], response[offset + 4]]),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((stages, active_stage))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sr_with(&mut self, command: u16, args: &[u8], wait_power: u8) -> Result<[u8; 80], String> {
|
||||||
|
if args.len() > 80 {
|
||||||
|
return Err("Razer reports support at most 80 argument bytes".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut report = [0u8; 90];
|
||||||
|
report[0] = 0;
|
||||||
|
report[1] = 0x1f;
|
||||||
|
report[4] = 0;
|
||||||
|
report[5] = args.len() as u8;
|
||||||
|
report[6] = (command >> 8) as u8;
|
||||||
|
report[7] = command as u8;
|
||||||
|
report[8..8 + args.len()].copy_from_slice(args);
|
||||||
|
report[88] = report[2..88].iter().fold(0, |crc, byte| crc ^ byte);
|
||||||
|
self.append_log(
|
||||||
|
"tx",
|
||||||
|
format!("cmd=0x{command:04x} wait_power={wait_power} report={}", bytes_to_hex_compact(&report)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(error) = self.send_feature_report(&report) {
|
||||||
|
self.append_log("error", format!("send cmd=0x{command:04x} failed: {error}"));
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
for attempt in 0..(15 * (usize::from(wait_power) + 1)) {
|
||||||
|
sleep(Duration::from_millis(10 * (attempt as u64 + 1)));
|
||||||
|
let response = match self.get_feature_report() {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
self.append_log("error", format!("recv cmd=0x{command:04x} failed: {error}"));
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.append_log(
|
||||||
|
"rx",
|
||||||
|
format!("cmd=0x{command:04x} attempt={} report={}", attempt + 1, bytes_to_hex_compact(&response)),
|
||||||
|
);
|
||||||
|
if response[6] != report[6] || response[7] != report[7] {
|
||||||
|
self.append_log(
|
||||||
|
"warn",
|
||||||
|
format!(
|
||||||
|
"cmd=0x{command:04x} got different reply command=0x{:02x}{:02x}",
|
||||||
|
response[6], response[7]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Err(
|
||||||
|
"Device replied to a different command; close other software using it"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match response[0] {
|
||||||
|
2 => {
|
||||||
|
let mut arguments = [0u8; 80];
|
||||||
|
arguments.copy_from_slice(&response[8..88]);
|
||||||
|
self.append_log("info", format!("cmd=0x{command:04x} completed"));
|
||||||
|
return Ok(arguments);
|
||||||
|
}
|
||||||
|
1 => continue,
|
||||||
|
3 => {
|
||||||
|
self.append_log("error", format!("cmd=0x{command:04x} failure status"));
|
||||||
|
return Err("Device reported command failure".to_string());
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
self.append_log("error", format!("cmd=0x{command:04x} timeout status"));
|
||||||
|
return Err("Device reported command timeout".to_string());
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
self.append_log("warn", format!("cmd=0x{command:04x} unsupported"));
|
||||||
|
return Err("Device does not support this command".to_string());
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
self.append_log("error", format!("cmd=0x{command:04x} unexpected status {status}"));
|
||||||
|
return Err(format!("Device returned unexpected status {status}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.append_log("error", format!("cmd=0x{command:04x} timed out waiting for response"));
|
||||||
|
Err("Timed out waiting for device response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
157
src-tauri/src/backend/device_parts/macro.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
impl ConnectedDevice {
|
||||||
|
fn get_macro_count(&mut self) -> Result<u16, String> {
|
||||||
|
Ok(u16::from_be_bytes(self.sr_with(0x0680, &[0, 0], 0)?[..2].try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_macro_list(&mut self) -> Result<Vec<u16>, String> {
|
||||||
|
let mut macros = Vec::new();
|
||||||
|
let total = usize::from(self.get_macro_count()?);
|
||||||
|
while macros.len() < total {
|
||||||
|
let offset = u16::try_from(macros.len()).map_err(|_| "Macro list offset overflow".to_string())?;
|
||||||
|
let mut args = Vec::with_capacity(68);
|
||||||
|
args.extend_from_slice(&offset.to_be_bytes());
|
||||||
|
args.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0; 64]);
|
||||||
|
let response = self.sr_with(0x068b, &args, 0)?;
|
||||||
|
let size = u16::from_be_bytes([response[0], response[1]]) as usize;
|
||||||
|
let take = size.saturating_sub(macros.len()).min(32);
|
||||||
|
for index in 0..take {
|
||||||
|
let offset = 2 + index * 2;
|
||||||
|
macros.push(u16::from_be_bytes([response[offset], response[offset + 1]]));
|
||||||
|
}
|
||||||
|
if take == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(macros)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_macro_info(&mut self, macro_id: u16) -> Result<Vec<u8>, String> {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut args = Vec::with_capacity(70);
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||||
|
args.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0; 64]);
|
||||||
|
let response = self.sr_with(0x068c, &args, 1)?;
|
||||||
|
let total_size = u16::from_be_bytes([response[2], response[3]]) as usize;
|
||||||
|
let chunk = &response[4..68];
|
||||||
|
let remaining = total_size.saturating_sub(data.len());
|
||||||
|
let take = remaining.min(chunk.len());
|
||||||
|
data.extend_from_slice(&chunk[..take]);
|
||||||
|
if data.len() >= total_size {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_macro_info(&mut self, macro_id: u16, data: &[u8]) -> Result<(), String> {
|
||||||
|
let total_size = u16::try_from(data.len()).map_err(|_| "Macro info is too large to write".to_string())?;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < data.len() {
|
||||||
|
let chunk = &data[offset..(offset + 64).min(data.len())];
|
||||||
|
let mut args = Vec::with_capacity(6 + chunk.len());
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&(offset as u16).to_be_bytes());
|
||||||
|
args.extend_from_slice(&total_size.to_be_bytes());
|
||||||
|
args.extend_from_slice(chunk);
|
||||||
|
self.sr_with(0x060c, &args, 1)?;
|
||||||
|
offset += chunk.len();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_macro_size(&mut self, macro_id: u16) -> Result<u32, String> {
|
||||||
|
let mut args = Vec::with_capacity(6);
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0; 4]);
|
||||||
|
let response = self.sr_with(0x0688, &args, 0)?;
|
||||||
|
Ok(u32::from_be_bytes([response[2], response[3], response[4], response[5]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_macro_size(&mut self, macro_id: u16, size: u32) -> Result<(), String> {
|
||||||
|
let mut args = Vec::with_capacity(6);
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&size.to_be_bytes());
|
||||||
|
self.sr_with(0x0608, &args, 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_macro_function(&mut self, macro_id: u16) -> Result<Vec<u8>, String> {
|
||||||
|
let size = self.get_macro_size(macro_id)? as usize;
|
||||||
|
let mut data = Vec::with_capacity(size);
|
||||||
|
while data.len() < size {
|
||||||
|
let offset = u32::try_from(data.len()).map_err(|_| "Macro function offset overflow".to_string())?;
|
||||||
|
let mut args = Vec::with_capacity(71);
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&offset.to_be_bytes());
|
||||||
|
args.push(64);
|
||||||
|
args.extend_from_slice(&[0; 64]);
|
||||||
|
let response = self.sr_with(0x0689, &args, 1)?;
|
||||||
|
let chunk = &response[..64.min(size - data.len())];
|
||||||
|
data.extend_from_slice(chunk);
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_macro_function_bytes(&mut self, macro_id: u16, data: &[u8]) -> Result<(), String> {
|
||||||
|
let total_size = u32::try_from(data.len()).map_err(|_| "Macro function is too large to write".to_string())?;
|
||||||
|
self.set_macro_size(macro_id, total_size)?;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < data.len() {
|
||||||
|
let chunk = &data[offset..(offset + 64).min(data.len())];
|
||||||
|
let mut args = Vec::with_capacity(7 + chunk.len());
|
||||||
|
args.extend_from_slice(¯o_id.to_be_bytes());
|
||||||
|
args.extend_from_slice(&(offset as u32).to_be_bytes());
|
||||||
|
args.push(chunk.len() as u8);
|
||||||
|
args.extend_from_slice(chunk);
|
||||||
|
self.sr_with(0x0609, &args, 1)?;
|
||||||
|
offset += chunk.len();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_macro_definition(&mut self, macro_id: u16) -> Result<MacroDefinition, String> {
|
||||||
|
let macro_info = self.get_macro_info(macro_id).ok();
|
||||||
|
let macro_function = self.get_macro_function(macro_id)?;
|
||||||
|
Ok(MacroDefinition {
|
||||||
|
macro_id,
|
||||||
|
macro_info_hex: macro_info.map(|bytes| bytes_to_hex_compact(&bytes)),
|
||||||
|
operations: decode_macro_operations(¯o_function)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_macro_definition(
|
||||||
|
&mut self,
|
||||||
|
macro_id: u16,
|
||||||
|
definition: MacroDefinition,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let operation_bytes = encode_macro_operations(&definition.operations)?;
|
||||||
|
self.delete_macro(macro_id).ok();
|
||||||
|
self.set_macro_function_bytes(macro_id, &operation_bytes)?;
|
||||||
|
if let Some(info_hex) = definition.macro_info_hex {
|
||||||
|
self.set_macro_info(macro_id, &parse_hex_bytes(&info_hex)?)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_macro(&mut self, macro_id: u16) -> Result<(), String> {
|
||||||
|
self.sr_with(0x0603, ¯o_id.to_be_bytes(), 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_macro_flash(&mut self) -> Result<(), String> {
|
||||||
|
let request = [0x00, 0x00, 0x00, 0x02, 0x00, 0x00];
|
||||||
|
self.sr_with(0x060a, &request, 0)?;
|
||||||
|
for _ in 0..20 {
|
||||||
|
let response = self.sr_with(0x068a, &request, 0)?;
|
||||||
|
if response[..6] == [0x00, 0x00, 0x02, 0x02, 0x00, 0x00] {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
Err("Resetting macro flash took too long".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
226
src-tauri/src/backend/device_parts/profile_led_button.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
impl ConnectedDevice {
|
||||||
|
fn led_snapshot(&mut self, profile: &str) -> Result<LedState, String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let mut regions = Vec::new();
|
||||||
|
for (region_name, region_value) in [("wheel", 0x01), ("logo", 0x04), ("strip", 0x0a)] {
|
||||||
|
let (effect, mode, speed, colors) = self.get_led_effect(profile_value, region_value)?;
|
||||||
|
let brightness = self.get_led_brightness(profile_value, region_value)?;
|
||||||
|
regions.push(LedRegionState {
|
||||||
|
region: region_name.to_string(),
|
||||||
|
effect,
|
||||||
|
mode,
|
||||||
|
speed,
|
||||||
|
colors,
|
||||||
|
brightness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(LedState {
|
||||||
|
profile: profile.to_string(),
|
||||||
|
regions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_led_effect(
|
||||||
|
&mut self,
|
||||||
|
profile: u8,
|
||||||
|
region: u8,
|
||||||
|
) -> Result<(String, u8, u8, Vec<[u8; 3]>), String> {
|
||||||
|
let response = self.sr_with(0x0f82, &[profile, region, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
|
||||||
|
let effect = led_effect_name(response[2])?.to_string();
|
||||||
|
let mode = response[3];
|
||||||
|
let speed = response[4];
|
||||||
|
let color_count = response[5] as usize;
|
||||||
|
let mut colors = Vec::new();
|
||||||
|
for index in 0..color_count.min(2) {
|
||||||
|
let offset = 6 + index * 3;
|
||||||
|
colors.push([response[offset], response[offset + 1], response[offset + 2]]);
|
||||||
|
}
|
||||||
|
Ok((effect, mode, speed, colors))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_led_brightness(&mut self, profile: u8, region: u8) -> Result<u8, String> {
|
||||||
|
Ok(self.sr_with(0x0f84, &[profile, region, 0], 0)?[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_button_mapping_state(
|
||||||
|
&mut self,
|
||||||
|
profile: &str,
|
||||||
|
button: &str,
|
||||||
|
hypershift: bool,
|
||||||
|
) -> Result<ButtonMappingState, String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let button_value = button_value(button)?;
|
||||||
|
let hypershift_value = u8::from(hypershift);
|
||||||
|
let response = self.sr_with(
|
||||||
|
0x028c,
|
||||||
|
&[profile_value, button_value, hypershift_value, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
0,
|
||||||
|
)?;
|
||||||
|
decode_button_mapping(profile.to_string(), button.to_string(), hypershift, &response[3..10])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_button_mapping_state(
|
||||||
|
&mut self,
|
||||||
|
mapping: ButtonMappingState,
|
||||||
|
) -> Result<ButtonMappingState, String> {
|
||||||
|
let profile_value = profile_value(&mapping.profile)?;
|
||||||
|
let button_value = button_value(&mapping.button)?;
|
||||||
|
let hypershift_value = u8::from(mapping.hypershift);
|
||||||
|
let function_bytes = encode_button_mapping(&mapping)?;
|
||||||
|
let mut args = Vec::with_capacity(10);
|
||||||
|
args.extend_from_slice(&[profile_value, button_value, hypershift_value]);
|
||||||
|
args.extend_from_slice(&function_bytes);
|
||||||
|
self.sr_with(0x020c, &args, 0)?;
|
||||||
|
self.get_button_mapping_state(&mapping.profile, &mapping.button, mapping.hypershift)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile_info(&mut self, profile: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let mut data = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut args = Vec::with_capacity(69);
|
||||||
|
args.push(profile_value);
|
||||||
|
args.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||||
|
args.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
args.extend_from_slice(&[0; 64]);
|
||||||
|
let response = self.sr_with(0x0588, &args, 1)?;
|
||||||
|
let total_size = u16::from_be_bytes([response[1], response[2]]) as usize;
|
||||||
|
let chunk = &response[3..67];
|
||||||
|
let remaining = total_size.saturating_sub(data.len());
|
||||||
|
let take = remaining.min(chunk.len());
|
||||||
|
data.extend_from_slice(&chunk[..take]);
|
||||||
|
if data.len() >= total_size {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile_info(&mut self, profile: &str, data: &[u8]) -> Result<(), String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let total_size = u16::try_from(data.len())
|
||||||
|
.map_err(|_| "Profile info is too large to write".to_string())?;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < data.len() {
|
||||||
|
let chunk = &data[offset..(offset + 64).min(data.len())];
|
||||||
|
let mut args = Vec::with_capacity(5 + chunk.len());
|
||||||
|
args.push(profile_value);
|
||||||
|
args.extend_from_slice(&(offset as u16).to_be_bytes());
|
||||||
|
args.extend_from_slice(&total_size.to_be_bytes());
|
||||||
|
args.extend_from_slice(chunk);
|
||||||
|
self.sr_with(0x0508, &args, 1)?;
|
||||||
|
offset += chunk.len();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_profile_config(&mut self, profile: &str) -> Result<ProfileConfigBundle, String> {
|
||||||
|
let basic = self.snapshot(profile)?.basic;
|
||||||
|
let button_mappings = all_button_names()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|button| [false, true].into_iter().map(move |hypershift| (*button, hypershift)))
|
||||||
|
.map(|(button, hypershift)| {
|
||||||
|
self.get_button_mapping_state(profile, button, hypershift)
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
self.append_log(
|
||||||
|
"warn",
|
||||||
|
format!(
|
||||||
|
"profile export fallback for {button} hypershift={} on profile {profile}: {error}",
|
||||||
|
hypershift
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ButtonMappingState {
|
||||||
|
profile: profile.to_string(),
|
||||||
|
button: button.to_string(),
|
||||||
|
hypershift,
|
||||||
|
category: "custom".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"fn_class": 0,
|
||||||
|
"fn_value": [],
|
||||||
|
"read_error": error
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let led = self.led_snapshot(profile)?;
|
||||||
|
let profile_info_hex = self
|
||||||
|
.get_profile_info(profile)
|
||||||
|
.ok()
|
||||||
|
.map(|bytes| bytes_to_hex_compact(&bytes));
|
||||||
|
|
||||||
|
Ok(ProfileConfigBundle {
|
||||||
|
basic,
|
||||||
|
button_mappings,
|
||||||
|
led,
|
||||||
|
profile_info_hex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_profile_config(
|
||||||
|
&mut self,
|
||||||
|
profile: &str,
|
||||||
|
bundle: ProfileConfigBundle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let profile_value = profile_value(profile)?;
|
||||||
|
let basic = bundle.basic;
|
||||||
|
|
||||||
|
self.set_scroll_mode(profile_value, scroll_mode_value(&basic.scroll_mode)?)?;
|
||||||
|
self.set_bool_command(0x0216, profile_value, basic.scroll_acceleration)?;
|
||||||
|
self.set_bool_command(0x0217, profile_value, basic.scroll_smart_reel)?;
|
||||||
|
self.sr_with(0x000e, &[profile_value, basic.polling_rate_ms], 0)?;
|
||||||
|
|
||||||
|
let mut dpi_args = Vec::with_capacity(7);
|
||||||
|
dpi_args.push(profile_value);
|
||||||
|
dpi_args.extend_from_slice(&basic.dpi_xy[0].to_be_bytes());
|
||||||
|
dpi_args.extend_from_slice(&basic.dpi_xy[1].to_be_bytes());
|
||||||
|
dpi_args.extend_from_slice(&[0, 0]);
|
||||||
|
self.sr_with(0x0405, &dpi_args, 0)?;
|
||||||
|
|
||||||
|
let mut stage_args = Vec::with_capacity(38);
|
||||||
|
stage_args.push(profile_value);
|
||||||
|
stage_args.push(basic.active_dpi_stage);
|
||||||
|
stage_args.push(basic.dpi_stages.len() as u8);
|
||||||
|
for (index, [x, y]) in basic.dpi_stages.iter().copied().enumerate() {
|
||||||
|
stage_args.push(index as u8);
|
||||||
|
stage_args.extend_from_slice(&x.to_be_bytes());
|
||||||
|
stage_args.extend_from_slice(&y.to_be_bytes());
|
||||||
|
stage_args.extend_from_slice(&[0, 0]);
|
||||||
|
}
|
||||||
|
while stage_args.len() < 38 {
|
||||||
|
stage_args.extend_from_slice(&[0; 7]);
|
||||||
|
}
|
||||||
|
self.sr_with(0x0406, &stage_args, 0)?;
|
||||||
|
|
||||||
|
for mut mapping in bundle.button_mappings {
|
||||||
|
mapping.profile = profile.to_string();
|
||||||
|
self.set_button_mapping_state(mapping)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for region in bundle.led.regions {
|
||||||
|
let region_value = led_region_value(®ion.region)?;
|
||||||
|
let effect_value = led_effect_value(®ion.effect)?;
|
||||||
|
let mut effect_args = Vec::with_capacity(6 + region.colors.len() * 3);
|
||||||
|
effect_args.push(profile_value);
|
||||||
|
effect_args.push(region_value);
|
||||||
|
effect_args.push(effect_value);
|
||||||
|
effect_args.push(region.mode);
|
||||||
|
effect_args.push(region.speed);
|
||||||
|
effect_args.push(region.colors.len() as u8);
|
||||||
|
for color in ®ion.colors {
|
||||||
|
effect_args.extend_from_slice(color);
|
||||||
|
}
|
||||||
|
self.sr_with(0x0f02, &effect_args, 0)?;
|
||||||
|
self.sr_with(0x0f04, &[profile_value, region_value, region.brightness], 0)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(profile_info_hex) = bundle.profile_info_hex {
|
||||||
|
let bytes = parse_hex_bytes(&profile_info_hex)?;
|
||||||
|
self.set_profile_info(profile, &bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
187
src-tauri/src/backend/device_parts/sensor_io.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
impl ConnectedDevice {
|
||||||
|
fn get_device_mode(&mut self) -> Result<(u8, u8), String> {
|
||||||
|
let response = self.sr_with(0x0084, &[0, 0], 0)?;
|
||||||
|
Ok((response[0], response[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_mode(&mut self, mode: &str, param: u8) -> Result<(), String> {
|
||||||
|
self.sr_with(0x0004, &[device_mode_value(mode)?, param], 0)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sensor_state_enabled(&mut self) -> Result<bool, String> {
|
||||||
|
let response = self.sr_with(0x0b83, &[0x00, 0x04, 0x00], 0)?;
|
||||||
|
Ok(response[2] != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_state_enabled(&mut self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.sr_with(0x0b03, &[0x00, 0x04, u8::from(enabled)], 0)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_calibration(&mut self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.sr_with(0x0b09, &[0x00, 0x04, 0x00, u8::from(enabled)], 0)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sensor_lift_mode(&mut self) -> Result<String, String> {
|
||||||
|
let response = self.sr_with(0x0b8b, &[0x00, 0x04, 0x00, 0x00], 0)?;
|
||||||
|
let value = u16::from_be_bytes([response[2], response[3]]);
|
||||||
|
Ok(lift_mode_name(value)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_lift_mode(&mut self, lift_mode: &str) -> Result<(), String> {
|
||||||
|
let value = lift_mode_value(lift_mode)?;
|
||||||
|
let smart_mode = lift_mode.starts_with("sym_") || lift_mode.starts_with("asym_");
|
||||||
|
self.set_sensor_state_enabled(!smart_mode)?;
|
||||||
|
self.sr_with(0x0b0b, &[0x00, 0x04, (value >> 8) as u8, value as u8], 0)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sensor_lift_config(&mut self) -> Result<Vec<u8>, String> {
|
||||||
|
let response = self.sr_with(0x0b85, &[0x00, 0x04, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
|
||||||
|
Ok(response[2..10].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_lift_config(&mut self, data: &[u8]) -> Result<(), String> {
|
||||||
|
if data.len() != 8 {
|
||||||
|
return Err("Sensor config A must contain exactly 8 bytes".to_string());
|
||||||
|
}
|
||||||
|
let mut args = vec![0x00, 0x04];
|
||||||
|
args.extend_from_slice(data);
|
||||||
|
self.sr_with(0x0b05, &args, 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sensor_lift_config_b(&mut self) -> Result<Vec<u8>, String> {
|
||||||
|
let response = self.sr_with(0x0b8c, &[0x00, 0x04, 0, 0, 0, 0, 0], 0)?;
|
||||||
|
Ok(response[2..7].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_lift_config_b(&mut self, data: &[u8]) -> Result<(), String> {
|
||||||
|
if data.len() != 5 {
|
||||||
|
return Err("Sensor config B must contain exactly 5 bytes".to_string());
|
||||||
|
}
|
||||||
|
let mut args = vec![0x00, 0x04];
|
||||||
|
args.extend_from_slice(data);
|
||||||
|
self.sr_with(0x0b0c, &args, 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sensor_lift_config_a(&mut self) -> Result<Vec<u8>, String> {
|
||||||
|
let response = self.sr_with(0x0b8d, &[0x00, 0x04, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
|
||||||
|
Ok(response[2..10].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_lift_config_a(&mut self, data: &[u8]) -> Result<(), String> {
|
||||||
|
if data.len() != 8 {
|
||||||
|
return Err("Sensor config A must contain exactly 8 bytes".to_string());
|
||||||
|
}
|
||||||
|
let mut args = vec![0x00, 0x04];
|
||||||
|
args.extend_from_slice(data);
|
||||||
|
self.sr_with(0x0b0d, &args, 0).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensor_snapshot(&mut self) -> Result<SensorState, String> {
|
||||||
|
let lift_mode = self.get_sensor_lift_mode()?;
|
||||||
|
let sensor_enabled = self.get_sensor_state_enabled()?;
|
||||||
|
let device_mode = device_mode_name(self.get_device_mode()?.0)?.to_string();
|
||||||
|
let retrieved_calib = self.get_sensor_lift_config()?;
|
||||||
|
let (param_a, param_b) = if lift_mode.starts_with("config2") || lift_mode.starts_with("calib2") {
|
||||||
|
(self.get_sensor_lift_config_a()?, self.get_sensor_lift_config_b()?)
|
||||||
|
} else {
|
||||||
|
(self.get_sensor_lift_config()?, Vec::new())
|
||||||
|
};
|
||||||
|
Ok(SensorState {
|
||||||
|
lift_mode,
|
||||||
|
sensor_enabled,
|
||||||
|
device_mode,
|
||||||
|
retrieved_calib,
|
||||||
|
param_a,
|
||||||
|
param_b,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sensor_calibration(&mut self) -> Result<(), String> {
|
||||||
|
self.set_device_mode("driver", 0)?;
|
||||||
|
self.set_sensor_state_enabled(true)?;
|
||||||
|
self.set_sensor_lift_mode("calib1")?;
|
||||||
|
self.set_sensor_calibration(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_sensor_calibration(&mut self) -> Result<(), String> {
|
||||||
|
self.set_sensor_calibration(true)?;
|
||||||
|
self.set_sensor_state_enabled(false)?;
|
||||||
|
self.set_device_mode("normal", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sensor_params(&mut self, param_a: &[u8], param_b: &[u8]) -> Result<(), String> {
|
||||||
|
if param_b.is_empty() {
|
||||||
|
self.set_sensor_lift_config(param_a)
|
||||||
|
} else {
|
||||||
|
self.set_sensor_lift_config_a(param_a)?;
|
||||||
|
self.set_sensor_lift_config_b(param_b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_flash_usage(&mut self) -> Result<(u16, u32, u32, u32), String> {
|
||||||
|
let response = self.sr_with(0x068e, &[0; 14], 0)?;
|
||||||
|
Ok((
|
||||||
|
u16::from_be_bytes([response[0], response[1]]),
|
||||||
|
u32::from_be_bytes([response[2], response[3], response[4], response[5]]),
|
||||||
|
u32::from_be_bytes([response[6], response[7], response[8], response[9]]),
|
||||||
|
u32::from_be_bytes([response[10], response[11], response[12], response[13]]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_snapshot(&mut self) -> Result<InfoState, String> {
|
||||||
|
let (_, flash_total, flash_free, flash_recycled) = self.get_flash_usage()?;
|
||||||
|
Ok(InfoState {
|
||||||
|
flash_total,
|
||||||
|
flash_free,
|
||||||
|
flash_recycled,
|
||||||
|
macro_count: self.get_macro_count()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_log(&self, level: &str, message: String) {
|
||||||
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(mut logs) = self.logs.lock() {
|
||||||
|
logs.push(DebugLogEntry {
|
||||||
|
timestamp_ms,
|
||||||
|
level: level.to_string(),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
if logs.len() > 500 {
|
||||||
|
let drop_count = logs.len() - 500;
|
||||||
|
logs.drain(0..drop_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_feature_report(&self, report: &[u8; 90]) -> Result<(), String> {
|
||||||
|
let mut buffer = [0u8; 91];
|
||||||
|
buffer[1..].copy_from_slice(report);
|
||||||
|
hidraw_ioctl(
|
||||||
|
self.file.as_raw_fd(),
|
||||||
|
hid_iocsfeature(buffer.len()),
|
||||||
|
&mut buffer,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|err| format!("Failed to send feature report: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_feature_report(&self) -> Result<[u8; 90], String> {
|
||||||
|
let mut buffer = [0u8; 91];
|
||||||
|
hidraw_ioctl(
|
||||||
|
self.file.as_raw_fd(),
|
||||||
|
hid_iocgfeature(buffer.len()),
|
||||||
|
&mut buffer,
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("Failed to read feature report: {err}"))?;
|
||||||
|
let mut report = [0u8; 90];
|
||||||
|
report.copy_from_slice(&buffer[1..]);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src-tauri/src/backend/discovery.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
fn try_connect_summary(
|
||||||
|
summary: DeviceSummary,
|
||||||
|
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
|
||||||
|
) -> Result<(ConnectedDevice, DeviceState), String> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&summary.path)
|
||||||
|
.map_err(|err| format!("Could not open. Check hidraw permissions or udev rules: {err}"))?;
|
||||||
|
|
||||||
|
let mut device = ConnectedDevice { file, summary, logs };
|
||||||
|
let snapshot = device.snapshot("direct")?;
|
||||||
|
Ok((device, snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_candidates(
|
||||||
|
discovered: &[DeviceSummary],
|
||||||
|
selected: &DeviceSummary,
|
||||||
|
) -> Vec<DeviceSummary> {
|
||||||
|
let mut candidates: Vec<DeviceSummary> = discovered
|
||||||
|
.iter()
|
||||||
|
.filter(|device| {
|
||||||
|
device.vendor_id == selected.vendor_id
|
||||||
|
&& device.product_id == selected.product_id
|
||||||
|
&& device.probe_group == selected.probe_group
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
candidates.sort_by_key(|device| {
|
||||||
|
(
|
||||||
|
device.path != selected.path,
|
||||||
|
interface_probe_priority(device.interface_number),
|
||||||
|
device.path.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interface_probe_priority(interface_number: Option<u8>) -> u8 {
|
||||||
|
match interface_number {
|
||||||
|
Some(3) => 0,
|
||||||
|
Some(2) => 1,
|
||||||
|
Some(1) => 2,
|
||||||
|
Some(0) => 3,
|
||||||
|
Some(other) => 4 + other,
|
||||||
|
None => u8::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_devices() -> io::Result<Vec<DeviceSummary>> {
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
let hidraw_root = Path::new("/sys/class/hidraw");
|
||||||
|
if !hidraw_root.exists() {
|
||||||
|
return Ok(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(hidraw_root)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let Some((vendor_id, product_id, product_name, serial)) =
|
||||||
|
read_hidraw_identity(&entry.path())?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(supported) = SUPPORTED_DEVICES
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.product_id == product_id)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let interface_number = read_interface_number(&entry.path())?;
|
||||||
|
let probe_group = read_probe_group(&entry.path())?;
|
||||||
|
let manufacturer = read_ancestor_file(&entry.path(), "manufacturer")?;
|
||||||
|
let product_usb_string = read_ancestor_file(&entry.path(), "product")?;
|
||||||
|
|
||||||
|
devices.push(DeviceSummary {
|
||||||
|
path: format!("/dev/{name}"),
|
||||||
|
vendor_id,
|
||||||
|
product_id,
|
||||||
|
product_name: product_usb_string.unwrap_or(product_name),
|
||||||
|
manufacturer,
|
||||||
|
serial,
|
||||||
|
interface_number,
|
||||||
|
supported_name: supported.name.to_string(),
|
||||||
|
probe_group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.sort_by_key(|device| {
|
||||||
|
(
|
||||||
|
device.probe_group.clone(),
|
||||||
|
interface_probe_priority(device.interface_number),
|
||||||
|
device.path.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_hidraw_identity(
|
||||||
|
hidraw_path: &Path,
|
||||||
|
) -> io::Result<Option<(u16, u16, String, Option<String>)>> {
|
||||||
|
let uevent = fs::read_to_string(hidraw_path.join("device/uevent"))?;
|
||||||
|
let mut vendor_id = None;
|
||||||
|
let mut product_id = None;
|
||||||
|
let mut product_name = None;
|
||||||
|
let mut serial = None;
|
||||||
|
|
||||||
|
for line in uevent.lines() {
|
||||||
|
if let Some(value) = line.strip_prefix("HID_ID=") {
|
||||||
|
let parts: Vec<&str> = value.split(':').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
vendor_id = u16::from_str_radix(parts[1].trim_start_matches("0000"), 16).ok();
|
||||||
|
product_id = u16::from_str_radix(parts[2].trim_start_matches("0000"), 16).ok();
|
||||||
|
}
|
||||||
|
} else if let Some(value) = line.strip_prefix("HID_NAME=") {
|
||||||
|
product_name = Some(value.to_string());
|
||||||
|
} else if let Some(value) = line.strip_prefix("HID_UNIQ=") {
|
||||||
|
if !value.is_empty() {
|
||||||
|
serial = Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (vendor_id, product_id) {
|
||||||
|
(Some(RAZER_VENDOR_ID), Some(product_id)) => Ok(Some((
|
||||||
|
RAZER_VENDOR_ID,
|
||||||
|
product_id,
|
||||||
|
product_name.unwrap_or_else(|| "Razer HID device".to_string()),
|
||||||
|
serial,
|
||||||
|
))),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_interface_number(hidraw_path: &Path) -> io::Result<Option<u8>> {
|
||||||
|
let Some(value) = read_ancestor_file(hidraw_path, "bInterfaceNumber")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(u8::from_str_radix(value.trim(), 16).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_probe_group(hidraw_path: &Path) -> io::Result<String> {
|
||||||
|
let canonical = fs::canonicalize(hidraw_path)?;
|
||||||
|
let interface_path = canonical
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "hidraw device has no parent"))?;
|
||||||
|
let usb_device = interface_path.parent().ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::NotFound, "HID interface has no USB parent")
|
||||||
|
})?;
|
||||||
|
Ok(usb_device.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ancestor_file(path: &Path, file_name: &str) -> io::Result<Option<String>> {
|
||||||
|
let mut current = fs::canonicalize(path)?;
|
||||||
|
for _ in 0..8 {
|
||||||
|
let candidate = current.join(file_name);
|
||||||
|
if candidate.exists() {
|
||||||
|
return Ok(Some(fs::read_to_string(candidate)?.trim().to_string()));
|
||||||
|
}
|
||||||
|
if !current.pop() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
33
src-tauri/src/backend/ioctl.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
fn hidraw_ioctl(fd: libc::c_int, request: libc::c_ulong, buffer: &mut [u8]) -> io::Result<i32> {
|
||||||
|
let result = unsafe { libc::ioctl(fd, request, buffer.as_mut_ptr()) };
|
||||||
|
if result < 0 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hid_iocsfeature(len: usize) -> libc::c_ulong {
|
||||||
|
ioc(IOC_READ | IOC_WRITE, b'H', 0x06, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hid_iocgfeature(len: usize) -> libc::c_ulong {
|
||||||
|
ioc(IOC_READ | IOC_WRITE, b'H', 0x07, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IOC_NRBITS: libc::c_ulong = 8;
|
||||||
|
const IOC_TYPEBITS: libc::c_ulong = 8;
|
||||||
|
const IOC_SIZEBITS: libc::c_ulong = 14;
|
||||||
|
const IOC_NRSHIFT: libc::c_ulong = 0;
|
||||||
|
const IOC_TYPESHIFT: libc::c_ulong = IOC_NRSHIFT + IOC_NRBITS;
|
||||||
|
const IOC_SIZESHIFT: libc::c_ulong = IOC_TYPESHIFT + IOC_TYPEBITS;
|
||||||
|
const IOC_DIRSHIFT: libc::c_ulong = IOC_SIZESHIFT + IOC_SIZEBITS;
|
||||||
|
const IOC_WRITE: libc::c_ulong = 1;
|
||||||
|
const IOC_READ: libc::c_ulong = 2;
|
||||||
|
|
||||||
|
fn ioc(dir: libc::c_ulong, ty: u8, nr: u8, size: usize) -> libc::c_ulong {
|
||||||
|
(dir << IOC_DIRSHIFT)
|
||||||
|
| ((ty as libc::c_ulong) << IOC_TYPESHIFT)
|
||||||
|
| ((nr as libc::c_ulong) << IOC_NRSHIFT)
|
||||||
|
| ((size as libc::c_ulong) << IOC_SIZESHIFT)
|
||||||
|
}
|
||||||
183
src-tauri/src/backend/macro_ops.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
const MACRO_OP_KEYBOARD_DOWN: u8 = 0x01;
|
||||||
|
const MACRO_OP_KEYBOARD_UP: u8 = 0x02;
|
||||||
|
const MACRO_OP_SYSTEM_A: u8 = 0x03;
|
||||||
|
const MACRO_OP_SYSTEM_B: u8 = 0x04;
|
||||||
|
const MACRO_OP_CONSUMER_A: u8 = 0x05;
|
||||||
|
const MACRO_OP_CONSUMER_B: u8 = 0x06;
|
||||||
|
const MACRO_OP_MOUSE_BUTTON: u8 = 0x08;
|
||||||
|
const MACRO_OP_MOUSE_WHEEL: u8 = 0x0a;
|
||||||
|
const MACRO_OP_DELAY_1: u8 = 0x11;
|
||||||
|
const MACRO_OP_DELAY_2: u8 = 0x12;
|
||||||
|
|
||||||
|
fn decode_macro_operations(data: &[u8]) -> Result<Vec<MacroOperationState>, String> {
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
while offset < data.len() {
|
||||||
|
let op_type = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
let value_len = match op_type {
|
||||||
|
MACRO_OP_KEYBOARD_DOWN
|
||||||
|
| MACRO_OP_KEYBOARD_UP
|
||||||
|
| MACRO_OP_SYSTEM_A
|
||||||
|
| MACRO_OP_SYSTEM_B
|
||||||
|
| MACRO_OP_MOUSE_BUTTON
|
||||||
|
| MACRO_OP_MOUSE_WHEEL
|
||||||
|
| MACRO_OP_DELAY_1 => 1,
|
||||||
|
MACRO_OP_CONSUMER_A | MACRO_OP_CONSUMER_B | MACRO_OP_DELAY_2 => 2,
|
||||||
|
other => return Err(format!("Unsupported macro op type {other:#04x}")),
|
||||||
|
};
|
||||||
|
if offset + value_len > data.len() {
|
||||||
|
return Err("Macro operation payload is truncated".to_string());
|
||||||
|
}
|
||||||
|
let value = &data[offset..offset + value_len];
|
||||||
|
offset += value_len;
|
||||||
|
|
||||||
|
let operation = match op_type {
|
||||||
|
MACRO_OP_KEYBOARD_DOWN => MacroOperationState {
|
||||||
|
category: "keyboard".to_string(),
|
||||||
|
payload: json!({"key": value[0], "is_up": false}),
|
||||||
|
},
|
||||||
|
MACRO_OP_KEYBOARD_UP => MacroOperationState {
|
||||||
|
category: "keyboard".to_string(),
|
||||||
|
payload: json!({"key": value[0], "is_up": true}),
|
||||||
|
},
|
||||||
|
MACRO_OP_SYSTEM_A => MacroOperationState {
|
||||||
|
category: "system".to_string(),
|
||||||
|
payload: json!({"key": value[0], "is_b": false}),
|
||||||
|
},
|
||||||
|
MACRO_OP_SYSTEM_B => MacroOperationState {
|
||||||
|
category: "system".to_string(),
|
||||||
|
payload: json!({"key": value[0], "is_b": true}),
|
||||||
|
},
|
||||||
|
MACRO_OP_CONSUMER_A => MacroOperationState {
|
||||||
|
category: "consumer".to_string(),
|
||||||
|
payload: json!({"key": u16::from_be_bytes([value[0], value[1]]), "is_b": false}),
|
||||||
|
},
|
||||||
|
MACRO_OP_CONSUMER_B => MacroOperationState {
|
||||||
|
category: "consumer".to_string(),
|
||||||
|
payload: json!({"key": u16::from_be_bytes([value[0], value[1]]), "is_b": true}),
|
||||||
|
},
|
||||||
|
MACRO_OP_MOUSE_BUTTON => MacroOperationState {
|
||||||
|
category: "mouse_button".to_string(),
|
||||||
|
payload: json!({"button": decode_macro_mouse_buttons(value[0])}),
|
||||||
|
},
|
||||||
|
MACRO_OP_MOUSE_WHEEL => MacroOperationState {
|
||||||
|
category: "mouse_wheel".to_string(),
|
||||||
|
payload: json!({"value": i8::from_be_bytes([value[0]])}),
|
||||||
|
},
|
||||||
|
MACRO_OP_DELAY_1 => MacroOperationState {
|
||||||
|
category: "delay".to_string(),
|
||||||
|
payload: json!({"value": value[0]}),
|
||||||
|
},
|
||||||
|
MACRO_OP_DELAY_2 => MacroOperationState {
|
||||||
|
category: "delay".to_string(),
|
||||||
|
payload: json!({"value": u16::from_be_bytes([value[0], value[1]])}),
|
||||||
|
},
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
operations.push(operation);
|
||||||
|
}
|
||||||
|
Ok(operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_macro_operations(operations: &[MacroOperationState]) -> Result<Vec<u8>, String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for operation in operations {
|
||||||
|
let payload = operation
|
||||||
|
.payload
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "Macro operation payload must be an object".to_string())?;
|
||||||
|
match operation.category.as_str() {
|
||||||
|
"keyboard" => {
|
||||||
|
out.push(if payload.get("is_up").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
|
MACRO_OP_KEYBOARD_UP
|
||||||
|
} else {
|
||||||
|
MACRO_OP_KEYBOARD_DOWN
|
||||||
|
});
|
||||||
|
out.push(get_u8(payload, "key")?);
|
||||||
|
}
|
||||||
|
"system" => {
|
||||||
|
out.push(if payload.get("is_b").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
|
MACRO_OP_SYSTEM_B
|
||||||
|
} else {
|
||||||
|
MACRO_OP_SYSTEM_A
|
||||||
|
});
|
||||||
|
out.push(get_u8(payload, "key")?);
|
||||||
|
}
|
||||||
|
"consumer" => {
|
||||||
|
let key = get_u16(payload, "key")?;
|
||||||
|
out.push(if payload.get("is_b").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
|
MACRO_OP_CONSUMER_B
|
||||||
|
} else {
|
||||||
|
MACRO_OP_CONSUMER_A
|
||||||
|
});
|
||||||
|
out.extend_from_slice(&key.to_be_bytes());
|
||||||
|
}
|
||||||
|
"mouse_button" => {
|
||||||
|
out.push(MACRO_OP_MOUSE_BUTTON);
|
||||||
|
out.push(encode_macro_mouse_buttons(get_string(payload, "button")?)?);
|
||||||
|
}
|
||||||
|
"mouse_wheel" => {
|
||||||
|
out.push(MACRO_OP_MOUSE_WHEEL);
|
||||||
|
let value = payload
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.filter(|value| (-128..=127).contains(value))
|
||||||
|
.ok_or_else(|| "mouse_wheel.value must be between -128 and 127".to_string())?;
|
||||||
|
out.push((value as i8) as u8);
|
||||||
|
}
|
||||||
|
"delay" => {
|
||||||
|
let value = payload
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.filter(|value| *value <= u16::MAX as u64)
|
||||||
|
.ok_or_else(|| "delay.value must be between 0 and 65535".to_string())?
|
||||||
|
as u16;
|
||||||
|
if value < 0x100 {
|
||||||
|
out.push(MACRO_OP_DELAY_1);
|
||||||
|
out.push(value as u8);
|
||||||
|
} else {
|
||||||
|
out.push(MACRO_OP_DELAY_2);
|
||||||
|
out.extend_from_slice(&value.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => return Err(format!("Unsupported macro operation category '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_macro_mouse_buttons(value: u8) -> String {
|
||||||
|
if value == 0 {
|
||||||
|
return "NONE".to_string();
|
||||||
|
}
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
for (flag, name) in [
|
||||||
|
(0x01, "LEFT"),
|
||||||
|
(0x02, "RIGHT"),
|
||||||
|
(0x04, "MIDDLE"),
|
||||||
|
(0x08, "BACKWARD"),
|
||||||
|
(0x10, "FORWARD"),
|
||||||
|
] {
|
||||||
|
if value & flag != 0 {
|
||||||
|
flags.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_macro_mouse_buttons(value: &str) -> Result<u8, String> {
|
||||||
|
let mut flags = 0u8;
|
||||||
|
for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) {
|
||||||
|
flags |= match part {
|
||||||
|
"NONE" => 0x00,
|
||||||
|
"LEFT" => 0x01,
|
||||||
|
"RIGHT" => 0x02,
|
||||||
|
"MIDDLE" => 0x04,
|
||||||
|
"BACKWARD" => 0x08,
|
||||||
|
"FORWARD" => 0x10,
|
||||||
|
other => return Err(format!("Unknown macro mouse button flag '{other}'")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(flags)
|
||||||
|
}
|
||||||
142
src-tauri/src/backend/models.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct SupportedDevice {
|
||||||
|
product_id: u16,
|
||||||
|
name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeviceSummary {
|
||||||
|
path: String,
|
||||||
|
vendor_id: u16,
|
||||||
|
product_id: u16,
|
||||||
|
product_name: String,
|
||||||
|
manufacturer: Option<String>,
|
||||||
|
serial: Option<String>,
|
||||||
|
interface_number: Option<u8>,
|
||||||
|
supported_name: String,
|
||||||
|
probe_group: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BasicSettings {
|
||||||
|
profile: String,
|
||||||
|
scroll_mode: String,
|
||||||
|
scroll_acceleration: bool,
|
||||||
|
scroll_smart_reel: bool,
|
||||||
|
polling_rate_ms: u8,
|
||||||
|
dpi_xy: [u16; 2],
|
||||||
|
dpi_stages: Vec<[u16; 2]>,
|
||||||
|
active_dpi_stage: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeviceState {
|
||||||
|
device: DeviceSummary,
|
||||||
|
serial: String,
|
||||||
|
firmware: String,
|
||||||
|
profiles: Vec<String>,
|
||||||
|
basic: BasicSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LedRegionState {
|
||||||
|
region: String,
|
||||||
|
effect: String,
|
||||||
|
mode: u8,
|
||||||
|
speed: u8,
|
||||||
|
colors: Vec<[u8; 3]>,
|
||||||
|
brightness: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LedState {
|
||||||
|
profile: String,
|
||||||
|
regions: Vec<LedRegionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ButtonMappingState {
|
||||||
|
profile: String,
|
||||||
|
button: String,
|
||||||
|
hypershift: bool,
|
||||||
|
category: String,
|
||||||
|
payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ProfileConfigBundle {
|
||||||
|
basic: BasicSettings,
|
||||||
|
button_mappings: Vec<ButtonMappingState>,
|
||||||
|
led: LedState,
|
||||||
|
profile_info_hex: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MacroOperationState {
|
||||||
|
category: String,
|
||||||
|
payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MacroDefinition {
|
||||||
|
macro_id: u16,
|
||||||
|
macro_info_hex: Option<String>,
|
||||||
|
operations: Vec<MacroOperationState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SensorState {
|
||||||
|
lift_mode: String,
|
||||||
|
sensor_enabled: bool,
|
||||||
|
device_mode: String,
|
||||||
|
retrieved_calib: Vec<u8>,
|
||||||
|
param_a: Vec<u8>,
|
||||||
|
param_b: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct InfoState {
|
||||||
|
flash_total: u32,
|
||||||
|
flash_free: u32,
|
||||||
|
flash_recycled: u32,
|
||||||
|
macro_count: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DebugLogEntry {
|
||||||
|
timestamp_ms: u64,
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
connected: Mutex<Option<ConnectedDevice>>,
|
||||||
|
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connected: Mutex::new(None),
|
||||||
|
logs: std::sync::Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectedDevice {
|
||||||
|
summary: DeviceSummary,
|
||||||
|
file: File,
|
||||||
|
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
|
||||||
|
}
|
||||||
187
src-tauri/src/backend/value_maps.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
fn profile_value(profile: &str) -> Result<u8, String> {
|
||||||
|
match profile {
|
||||||
|
"direct" => Ok(0x00),
|
||||||
|
"white" | "default" => Ok(0x01),
|
||||||
|
"red" => Ok(0x02),
|
||||||
|
"green" => Ok(0x03),
|
||||||
|
"blue" => Ok(0x04),
|
||||||
|
"cyan" => Ok(0x05),
|
||||||
|
_ => Err(format!("Unsupported profile '{profile}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_name(profile: u8) -> Result<&'static str, String> {
|
||||||
|
match profile {
|
||||||
|
0x00 => Ok("direct"),
|
||||||
|
0x01 => Ok("white"),
|
||||||
|
0x02 => Ok("red"),
|
||||||
|
0x03 => Ok("green"),
|
||||||
|
0x04 => Ok("blue"),
|
||||||
|
0x05 => Ok("cyan"),
|
||||||
|
_ => Err(format!("Unknown profile value {profile}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_mode_value(mode: &str) -> Result<u8, String> {
|
||||||
|
match mode {
|
||||||
|
"tactile" => Ok(0),
|
||||||
|
"freespin" => Ok(1),
|
||||||
|
_ => Err(format!("Unsupported scroll mode '{mode}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn led_region_value(region: &str) -> Result<u8, String> {
|
||||||
|
match region {
|
||||||
|
"all" => Ok(0x00),
|
||||||
|
"wheel" => Ok(0x01),
|
||||||
|
"logo" => Ok(0x04),
|
||||||
|
"strip" => Ok(0x0a),
|
||||||
|
_ => Err(format!("Unsupported LED region '{region}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn led_effect_value(effect: &str) -> Result<u8, String> {
|
||||||
|
match effect {
|
||||||
|
"off" | "disabled" => Ok(0x00),
|
||||||
|
"static" => Ok(0x01),
|
||||||
|
"spectrum" => Ok(0x03),
|
||||||
|
"wave" => Ok(0x04),
|
||||||
|
"custom" => Ok(0x08),
|
||||||
|
_ => Err(format!("Unsupported LED effect '{effect}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn led_effect_name(effect: u8) -> Result<&'static str, String> {
|
||||||
|
match effect {
|
||||||
|
0x00 => Ok("off"),
|
||||||
|
0x01 => Ok("static"),
|
||||||
|
0x03 => Ok("spectrum"),
|
||||||
|
0x04 => Ok("wave"),
|
||||||
|
0x08 => Ok("custom"),
|
||||||
|
_ => Err(format!("Unknown LED effect value {effect}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_value(button: &str) -> Result<u8, String> {
|
||||||
|
match button {
|
||||||
|
"left" => Ok(0x01),
|
||||||
|
"right" => Ok(0x02),
|
||||||
|
"middle" => Ok(0x03),
|
||||||
|
"backward" => Ok(0x04),
|
||||||
|
"forward" => Ok(0x05),
|
||||||
|
"wheel_up" => Ok(0x09),
|
||||||
|
"wheel_down" => Ok(0x0a),
|
||||||
|
"bottom" => Ok(0x0e),
|
||||||
|
"aim" => Ok(0x0f),
|
||||||
|
"wheel_left" => Ok(0x34),
|
||||||
|
"wheel_right" => Ok(0x35),
|
||||||
|
"middle_backward" => Ok(0x60),
|
||||||
|
"middle_forward" => Ok(0x6a),
|
||||||
|
_ => Err(format!("Unsupported button '{button}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_button_names() -> &'static [&'static str] {
|
||||||
|
&[
|
||||||
|
"aim",
|
||||||
|
"left",
|
||||||
|
"middle",
|
||||||
|
"right",
|
||||||
|
"forward",
|
||||||
|
"wheel_up",
|
||||||
|
"middle_forward",
|
||||||
|
"wheel_left",
|
||||||
|
"backward",
|
||||||
|
"wheel_down",
|
||||||
|
"middle_backward",
|
||||||
|
"wheel_right",
|
||||||
|
"bottom",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_hex_compact(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex_bytes(input: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let compact = input.chars().filter(|ch| !ch.is_whitespace()).collect::<String>();
|
||||||
|
if compact.len() % 2 != 0 {
|
||||||
|
return Err("Hex input must contain an even number of characters".to_string());
|
||||||
|
}
|
||||||
|
(0..compact.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|index| {
|
||||||
|
u8::from_str_radix(&compact[index..index + 2], 16)
|
||||||
|
.map_err(|_| format!("Invalid hex byte '{}'", &compact[index..index + 2]))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_mode_name(mode: u8) -> Result<&'static str, String> {
|
||||||
|
match mode {
|
||||||
|
0x00 => Ok("normal"),
|
||||||
|
0x01 => Ok("bootloader"),
|
||||||
|
0x02 => Ok("test"),
|
||||||
|
0x03 => Ok("driver"),
|
||||||
|
_ => Err(format!("Unknown device mode {mode}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_mode_value(mode: &str) -> Result<u8, String> {
|
||||||
|
match mode {
|
||||||
|
"normal" => Ok(0x00),
|
||||||
|
"bootloader" => Ok(0x01),
|
||||||
|
"test" => Ok(0x02),
|
||||||
|
"driver" => Ok(0x03),
|
||||||
|
_ => Err(format!("Unknown device mode '{mode}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lift_mode_name(value: u16) -> Result<&'static str, String> {
|
||||||
|
match value {
|
||||||
|
0x0000 => Ok("none"),
|
||||||
|
0x0100 => Ok("sym_1"),
|
||||||
|
0x0101 => Ok("sym_2"),
|
||||||
|
0x0102 => Ok("sym_3"),
|
||||||
|
0x0200 => Ok("asym_12"),
|
||||||
|
0x0201 => Ok("asym_13"),
|
||||||
|
0x0202 => Ok("asym_23"),
|
||||||
|
0x0300 => Ok("config1"),
|
||||||
|
0x0400 => Ok("config2"),
|
||||||
|
0x0500 => Ok("calib1"),
|
||||||
|
0x0600 => Ok("calib2"),
|
||||||
|
_ => Err(format!("Unknown lift mode 0x{value:04x}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lift_mode_value(value: &str) -> Result<u16, String> {
|
||||||
|
match value {
|
||||||
|
"none" => Ok(0x0000),
|
||||||
|
"sym_1" => Ok(0x0100),
|
||||||
|
"sym_2" => Ok(0x0101),
|
||||||
|
"sym_3" => Ok(0x0102),
|
||||||
|
"asym_12" => Ok(0x0200),
|
||||||
|
"asym_13" => Ok(0x0201),
|
||||||
|
"asym_23" => Ok(0x0202),
|
||||||
|
"config1" => Ok(0x0300),
|
||||||
|
"config2" => Ok(0x0400),
|
||||||
|
"calib1" => Ok(0x0500),
|
||||||
|
"calib2" => Ok(0x0600),
|
||||||
|
_ => Err(format!("Unknown lift mode '{value}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fn_mouse_value(button: &str) -> Result<u8, String> {
|
||||||
|
match button {
|
||||||
|
"left" => Ok(0x01),
|
||||||
|
"right" => Ok(0x02),
|
||||||
|
"middle" => Ok(0x03),
|
||||||
|
"backward" => Ok(0x04),
|
||||||
|
"forward" => Ok(0x05),
|
||||||
|
"wheel_up" => Ok(0x09),
|
||||||
|
"wheel_down" => Ok(0x0a),
|
||||||
|
"wheel_left" => Ok(0x68),
|
||||||
|
"wheel_right" => Ok(0x69),
|
||||||
|
_ => Err(format!("Unsupported mouse function '{button}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io,
|
||||||
|
os::fd::AsRawFd,
|
||||||
|
path::Path,
|
||||||
|
sync::Mutex,
|
||||||
|
thread::sleep,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAZER_VENDOR_ID: u16 = 0x1532;
|
||||||
|
const SUPPORTED_DEVICES: &[SupportedDevice] = &[
|
||||||
|
SupportedDevice {
|
||||||
|
product_id: 0x0099,
|
||||||
|
name: "Razer Basilisk V3",
|
||||||
|
},
|
||||||
|
SupportedDevice {
|
||||||
|
product_id: 0x00aa,
|
||||||
|
name: "Razer Basilisk V3 Pro (wired)",
|
||||||
|
},
|
||||||
|
SupportedDevice {
|
||||||
|
product_id: 0x00ab,
|
||||||
|
name: "Razer Basilisk V3 Pro (wireless)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
include!("backend/models.rs");
|
||||||
|
include!("backend/commands.rs");
|
||||||
|
include!("backend/device.rs");
|
||||||
|
include!("backend/discovery.rs");
|
||||||
|
include!("backend/value_maps.rs");
|
||||||
|
include!("backend/button_mapping.rs");
|
||||||
|
include!("backend/macro_ops.rs");
|
||||||
|
include!("backend/ioctl.rs");
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.manage(AppState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
list_supported_devices,
|
||||||
|
connect_device,
|
||||||
|
refresh_device_state,
|
||||||
|
set_scroll_mode,
|
||||||
|
set_scroll_acceleration,
|
||||||
|
set_scroll_smart_reel,
|
||||||
|
set_polling_rate,
|
||||||
|
set_dpi_xy,
|
||||||
|
set_dpi_stages,
|
||||||
|
create_profile,
|
||||||
|
delete_profile,
|
||||||
|
get_led_state,
|
||||||
|
set_led_effect,
|
||||||
|
set_led_brightness,
|
||||||
|
apply_led_to_all_regions,
|
||||||
|
get_button_mapping,
|
||||||
|
set_button_mapping,
|
||||||
|
export_profile_config,
|
||||||
|
import_profile_config,
|
||||||
|
list_macros,
|
||||||
|
get_macro_definition,
|
||||||
|
set_macro_definition,
|
||||||
|
delete_macro,
|
||||||
|
reset_macro_flash,
|
||||||
|
get_debug_logs,
|
||||||
|
clear_debug_logs,
|
||||||
|
get_sensor_state,
|
||||||
|
set_sensor_lift_mode,
|
||||||
|
start_sensor_calibration,
|
||||||
|
stop_sensor_calibration,
|
||||||
|
set_sensor_params,
|
||||||
|
get_info_state,
|
||||||
|
send_raw_command,
|
||||||
|
reset_flash,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
razer_linux_desktop_lib::run()
|
||||||
|
}
|
||||||
36
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "razer-linux-desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "one.daichendt.razer-linux-desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "NO_COLOR=false trunk serve",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "NO_COLOR=false trunk build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "razer-linux-desktop",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["appimage"],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use leptos::ev::{Event, KeyboardEvent};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], js_name = invoke)]
|
||||||
|
fn tauri_invoke(cmd: &str, args: JsValue) -> js_sys::Promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
include!("app/models.rs");
|
||||||
|
include!("app/root.rs");
|
||||||
|
include!("app/profile_panel.rs");
|
||||||
|
include!("app/led_panel.rs");
|
||||||
|
include!("app/basic_panel.rs");
|
||||||
|
include!("app/button_panel.rs");
|
||||||
|
include!("app/macro_panel.rs");
|
||||||
|
include!("app/sensor_panel.rs");
|
||||||
|
include!("app/button_editor_primary.rs");
|
||||||
|
include!("app/button_editor_secondary.rs");
|
||||||
|
include!("app/button_actions_info.rs");
|
||||||
|
include!("app/helpers.rs");
|
||||||
267
src/app/basic_panel.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
#[component]
|
||||||
|
fn BasicPanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_snapshot: WriteSignal<Option<DeviceState>>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let basic = snapshot.basic.clone();
|
||||||
|
let initial_dpi_stages = basic.dpi_stages.clone();
|
||||||
|
let initial_active_stage = basic.active_dpi_stage;
|
||||||
|
let stage_list_for_count = initial_dpi_stages.clone();
|
||||||
|
let stage_list_for_active = initial_dpi_stages.clone();
|
||||||
|
let stage_list_for_copy = initial_dpi_stages.clone();
|
||||||
|
|
||||||
|
let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| {
|
||||||
|
run_setting(
|
||||||
|
"set_dpi_stages",
|
||||||
|
DpiStagesArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
dpi_stages,
|
||||||
|
active_stage,
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let apply_acceleration = move |ev: Event| {
|
||||||
|
run_setting(
|
||||||
|
"set_scroll_acceleration",
|
||||||
|
BoolSettingArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
enabled: event_target_checked(&ev),
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let apply_smart_reel = move |ev: Event| {
|
||||||
|
run_setting(
|
||||||
|
"set_scroll_smart_reel",
|
||||||
|
BoolSettingArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
enabled: event_target_checked(&ev),
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let apply_polling = move |ev: Event| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
|
||||||
|
run_setting(
|
||||||
|
"set_polling_rate",
|
||||||
|
PollingRateArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
polling_rate_ms: value,
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_dpi_x = move |ev: Event| {
|
||||||
|
let x = event_target_value(&ev)
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or(basic.dpi_xy[0]);
|
||||||
|
run_setting(
|
||||||
|
"set_dpi_xy",
|
||||||
|
DpiArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
x,
|
||||||
|
y: basic.dpi_xy[1],
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_dpi_y = move |ev: Event| {
|
||||||
|
let y = event_target_value(&ev)
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or(basic.dpi_xy[1]);
|
||||||
|
run_setting(
|
||||||
|
"set_dpi_xy",
|
||||||
|
DpiArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
x: basic.dpi_xy[0],
|
||||||
|
y,
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_stage_count = move |ev: Event| {
|
||||||
|
let requested = event_target_value(&ev)
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(stage_list_for_count.len())
|
||||||
|
.clamp(1, 5);
|
||||||
|
let mut stages = stage_list_for_count.clone();
|
||||||
|
stages.truncate(requested);
|
||||||
|
while stages.len() < requested {
|
||||||
|
stages.push([800, 800]);
|
||||||
|
}
|
||||||
|
let active_stage = initial_active_stage.min(requested as u8).max(1);
|
||||||
|
apply_dpi_stages(stages, active_stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_active_stage = move |ev: Event| {
|
||||||
|
let active_stage = event_target_value(&ev)
|
||||||
|
.parse::<u8>()
|
||||||
|
.unwrap_or(initial_active_stage)
|
||||||
|
.clamp(1, stage_list_for_active.len() as u8);
|
||||||
|
apply_dpi_stages(stage_list_for_active.clone(), active_stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
let copy_stage_y_from_x = move |_| {
|
||||||
|
let stages = stage_list_for_copy
|
||||||
|
.iter()
|
||||||
|
.map(|[x, _]| [*x, *x])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
apply_dpi_stages(stages, initial_active_stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel">
|
||||||
|
<h3>"Scroll"</h3>
|
||||||
|
<label class="scroll-toggle-row">
|
||||||
|
<span>"Wheel mode"</span>
|
||||||
|
<span>"Tactile"</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=basic.scroll_mode == "freespin"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let mode = if event_target_checked(&ev) { "freespin" } else { "tactile" };
|
||||||
|
run_setting(
|
||||||
|
"set_scroll_mode",
|
||||||
|
ScrollModeArgs {
|
||||||
|
profile: selected_profile.get_untracked(),
|
||||||
|
mode: mode.to_string(),
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Freespin"</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" prop:checked=basic.scroll_acceleration on:change=apply_acceleration />
|
||||||
|
<span>"Acceleration"</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" prop:checked=basic.scroll_smart_reel on:change=apply_smart_reel />
|
||||||
|
<span>"Smart Reel"</span>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<h3>"Polling"</h3>
|
||||||
|
<p class="subtle">{format!("Report every {} ms.", basic.polling_rate_ms)}</p>
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Polling rate"</span>
|
||||||
|
<select prop:value=basic.polling_rate_ms on:change=apply_polling>
|
||||||
|
<option value="16">"63 Hz"</option>
|
||||||
|
<option value="8">"125 Hz"</option>
|
||||||
|
<option value="4">"250 Hz"</option>
|
||||||
|
<option value="2">"500 Hz"</option>
|
||||||
|
<option value="1">"1000 Hz"</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="metric">{format!("{} Hz", 1000 / u16::from(basic.polling_rate_ms.max(1)))}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"DPI"</h3>
|
||||||
|
<div class="dpi-editor">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Current X"</span>
|
||||||
|
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[0] on:change=set_dpi_x />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Current Y"</span>
|
||||||
|
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[1] on:change=set_dpi_y />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span class="subtle">"Active stage"</span>
|
||||||
|
<p class="metric">{basic.active_dpi_stage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dpi-controls">
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Stages"</span>
|
||||||
|
<input type="number" min="1" max="5" prop:value=basic.dpi_stages.len() on:change=set_stage_count />
|
||||||
|
</label>
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Active"</span>
|
||||||
|
<input type="number" min="1" max="5" prop:value=basic.active_dpi_stage on:change=set_active_stage />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary-action" on:click=copy_stage_y_from_x>"Y = X"</button>
|
||||||
|
</div>
|
||||||
|
<div class="stage-list">
|
||||||
|
{basic.dpi_stages.iter().enumerate().map(|(index, stage)| {
|
||||||
|
let stage_index = index;
|
||||||
|
let label = format!("Stage {}", stage_index + 1);
|
||||||
|
let stage_list_for_x = basic.dpi_stages.clone();
|
||||||
|
let stage_list_for_y = basic.dpi_stages.clone();
|
||||||
|
let active_stage_for_x = basic.active_dpi_stage;
|
||||||
|
let active_stage_for_y = basic.active_dpi_stage;
|
||||||
|
view! {
|
||||||
|
<div class:active=index + 1 == usize::from(basic.active_dpi_stage)>
|
||||||
|
<span>{label}</span>
|
||||||
|
<label class="field">
|
||||||
|
<span>"X"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="25600"
|
||||||
|
step="100"
|
||||||
|
prop:value=stage[0]
|
||||||
|
on:change=move |ev| {
|
||||||
|
let mut stages = stage_list_for_x.clone();
|
||||||
|
let x = event_target_value(&ev)
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or(stages[stage_index][0]);
|
||||||
|
stages[stage_index][0] = x;
|
||||||
|
apply_dpi_stages(stages, active_stage_for_x);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Y"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="25600"
|
||||||
|
step="100"
|
||||||
|
prop:value=stage[1]
|
||||||
|
on:change=move |ev| {
|
||||||
|
let mut stages = stage_list_for_y.clone();
|
||||||
|
let y = event_target_value(&ev)
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or(stages[stage_index][1]);
|
||||||
|
stages[stage_index][1] = y;
|
||||||
|
apply_dpi_stages(stages, active_stage_for_y);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/app/button_actions_info.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
#[component]
|
||||||
|
fn InfoPanel(snapshot: DeviceState) -> impl IntoView {
|
||||||
|
let debug_logs = RwSignal::new(Vec::<DebugLogEntry>::new());
|
||||||
|
let info_state = RwSignal::new(None::<InfoState>);
|
||||||
|
let confirm_reset = RwSignal::new(false);
|
||||||
|
let raw_command = RwSignal::new("0x0082".to_string());
|
||||||
|
let raw_args = RwSignal::new(String::new());
|
||||||
|
let raw_response = RwSignal::new(String::new());
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
|
||||||
|
let load_logs = move || {
|
||||||
|
if demo_mode {
|
||||||
|
debug_logs.set(vec![DebugLogEntry {
|
||||||
|
timestamp_ms: 0,
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "Demo mode active; no hidraw traffic available.".to_string(),
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(logs) = invoke_no_args::<Vec<DebugLogEntry>>("get_debug_logs").await {
|
||||||
|
debug_logs.set(logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| load_logs());
|
||||||
|
|
||||||
|
let load_info = move || {
|
||||||
|
if demo_mode {
|
||||||
|
info_state.set(Some(InfoState {
|
||||||
|
flash_total: 4096,
|
||||||
|
flash_free: 2048,
|
||||||
|
flash_recycled: 256,
|
||||||
|
macro_count: 2,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(state) = invoke_no_args::<InfoState>("get_info_state").await {
|
||||||
|
info_state.set(Some(state));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| load_info());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Device Info"</h3>
|
||||||
|
<dl class="info-list">
|
||||||
|
<dt>"Model"</dt><dd>{snapshot.device.supported_name}</dd>
|
||||||
|
<dt>"Product"</dt><dd>{snapshot.device.product_name}</dd>
|
||||||
|
<dt>"Manufacturer"</dt><dd>{snapshot.device.manufacturer.unwrap_or_else(|| "Unknown".to_string())}</dd>
|
||||||
|
<dt>"Serial"</dt><dd>{snapshot.serial}</dd>
|
||||||
|
<dt>"USB ID"</dt><dd>{format!("1532:{:04x}", snapshot.device.product_id)}</dd>
|
||||||
|
<dt>"Firmware"</dt><dd>{snapshot.firmware}</dd>
|
||||||
|
<dt>"HID path"</dt><dd>{snapshot.device.path}</dd>
|
||||||
|
<dt>"Interface"</dt><dd>{snapshot.device.interface_number.map(|value| value.to_string()).unwrap_or_else(|| "Unknown".to_string())}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{move || info_state.get().map(|info| {
|
||||||
|
let flash_used = info.flash_total.saturating_sub(info.flash_free);
|
||||||
|
let flash_can_use = info.flash_free.saturating_sub(info.flash_recycled);
|
||||||
|
let flash_total = info.flash_total.max(1);
|
||||||
|
let used_pct = flash_used.saturating_mul(100) / flash_total;
|
||||||
|
let avail_pct = flash_can_use.saturating_mul(100) / flash_total;
|
||||||
|
let recycled_pct = info.flash_recycled.saturating_mul(100) / flash_total;
|
||||||
|
view! {
|
||||||
|
<dl class="info-list info-subgrid">
|
||||||
|
<dt>"Flash total"</dt><dd>{info.flash_total}</dd>
|
||||||
|
<dt>"Flash used"</dt><dd>{flash_used}</dd>
|
||||||
|
<dt>"Flash available"</dt><dd>{flash_can_use}</dd>
|
||||||
|
<dt>"Flash recycled"</dt><dd>{info.flash_recycled}</dd>
|
||||||
|
<dt>"Macro count"</dt><dd>{info.macro_count}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="flash-bar" role="img" aria-label="Flash usage">
|
||||||
|
<div class="flash-segment used" style:width=format!("{used_pct}%")></div>
|
||||||
|
<div class="flash-segment available" style:width=format!("{avail_pct}%")></div>
|
||||||
|
<div class="flash-segment recycled" style:width=format!("{recycled_pct}%")></div>
|
||||||
|
</div>
|
||||||
|
<div class="flash-summary">
|
||||||
|
<span>{format!("{} in use", flash_used / 256)}</span>
|
||||||
|
<span>{format!("{} available", flash_can_use / 256)}</span>
|
||||||
|
<span>{format!("{} recycled", info.flash_recycled / 256)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="button-actions inline-actions">
|
||||||
|
{if confirm_reset.get() {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action danger"
|
||||||
|
on:click=move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
confirm_reset.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<InfoState>("reset_flash").await {
|
||||||
|
Ok(state) => {
|
||||||
|
info_state.set(Some(state));
|
||||||
|
confirm_reset.set(false);
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Confirm Reset Flash"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<button type="button" class="secondary-action" on:click=move |_| confirm_reset.set(true)>
|
||||||
|
"Reset Flash"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Raw Report"</h3>
|
||||||
|
<div class="sensor-param-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Command"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || raw_command.get()
|
||||||
|
on:input=move |ev| raw_command.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Argument Bytes"</span>
|
||||||
|
<textarea
|
||||||
|
prop:value=move || raw_args.get()
|
||||||
|
on:input=move |ev| raw_args.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="button-actions inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
on:click=move |_| {
|
||||||
|
let command_text = raw_command.get_untracked();
|
||||||
|
let command = command_text.trim_start_matches("0x");
|
||||||
|
let Ok(command) = u16::from_str_radix(command, 16) else {
|
||||||
|
raw_response.set("Invalid command".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let args = parse_hex_bytes(&raw_args.get_untracked());
|
||||||
|
if demo_mode {
|
||||||
|
raw_response.set(bytes_to_hex(&[0u8; 16]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<Vec<u8>, _>("send_raw_command", &RawCommandArgs { command, args }).await {
|
||||||
|
Ok(response) => raw_response.set(bytes_to_hex(&response)),
|
||||||
|
Err(error) => raw_response.set(error),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Send"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Response"</span>
|
||||||
|
<textarea prop:value=move || raw_response.get() readonly=true />
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel wide">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>"Debug Console"</h3>
|
||||||
|
<div class="button-actions inline-actions">
|
||||||
|
<button type="button" class="secondary-action" on:click=move |_| load_logs()>"Refresh"</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action danger"
|
||||||
|
on:click=move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
debug_logs.set(Vec::new());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
let _ = invoke_no_args::<()>("clear_debug_logs").await;
|
||||||
|
if let Ok(logs) = invoke_no_args::<Vec<DebugLogEntry>>("get_debug_logs").await {
|
||||||
|
debug_logs.set(logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Clear"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="debug-console">
|
||||||
|
{move || debug_logs.get().into_iter().map(|entry| {
|
||||||
|
let line = format!(
|
||||||
|
"{} [{}] {}",
|
||||||
|
format_log_timestamp(entry.timestamp_ms),
|
||||||
|
entry.level,
|
||||||
|
entry.message
|
||||||
|
);
|
||||||
|
view! { <div>{line}</div> }
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn BacklogPanel(title: &'static str, body: &'static str) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<section class="empty-state">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{body}</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/app/button_editor_primary.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
fn render_button_mapping_editor(
|
||||||
|
mapping_state: RwSignal<Option<ButtonMappingState>>,
|
||||||
|
current_category: String,
|
||||||
|
payload: Value,
|
||||||
|
mouse_fn: String,
|
||||||
|
mouse_double_click: bool,
|
||||||
|
mouse_turbo: u16,
|
||||||
|
keyboard_key: u8,
|
||||||
|
keyboard_turbo: Option<u16>,
|
||||||
|
keyboard_modifiers: Vec<String>,
|
||||||
|
macro_id: u16,
|
||||||
|
macro_mode: String,
|
||||||
|
macro_times: u8,
|
||||||
|
dpi_fn: String,
|
||||||
|
dpi_stage: u8,
|
||||||
|
dpi_pair: [u16; 2],
|
||||||
|
profile_fn: String,
|
||||||
|
fixed_profile: String,
|
||||||
|
system_flags: Vec<String>,
|
||||||
|
consumer_code: u16,
|
||||||
|
toggle_value: u8,
|
||||||
|
custom_class: u8,
|
||||||
|
custom_bytes: Vec<u8>,
|
||||||
|
) -> AnyView {
|
||||||
|
match current_category.as_str() {
|
||||||
|
"disabled" => view! {
|
||||||
|
<p class="subtle">"The selected button will not emit any function."</p>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"mouse" => view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Mouse function"</span>
|
||||||
|
<select
|
||||||
|
prop:value=mouse_fn.clone()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| view! { <option value=name>{name}</option> })
|
||||||
|
.collect_view()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="button-radio-group">
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mouse_mode"
|
||||||
|
prop:checked=!mouse_double_click && payload.get("turbo").map(|value| value.is_null()).unwrap_or(true)
|
||||||
|
on:change=move |_| {
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
payload.insert("double_click".to_string(), Value::Bool(false));
|
||||||
|
payload.insert("turbo".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Single click"</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mouse_mode"
|
||||||
|
prop:checked=mouse_double_click
|
||||||
|
on:change=move |_| {
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
payload.insert("double_click".to_string(), Value::Bool(true));
|
||||||
|
payload.insert("turbo".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Double click"</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mouse_mode"
|
||||||
|
prop:checked=payload.get("turbo").map(|value| !value.is_null()).unwrap_or(false)
|
||||||
|
on:change=move |_| {
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
payload.insert("double_click".to_string(), Value::Bool(false));
|
||||||
|
payload.insert("turbo".to_string(), json!(200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Turbo"</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Turbo ms"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
prop:value=mouse_turbo
|
||||||
|
disabled=payload.get("turbo").map(|value| value.is_null()).unwrap_or(true)
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(200).max(1);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("turbo".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"keyboard" => {
|
||||||
|
let key_name = RwSignal::new(keyboard_key_short_name(keyboard_key));
|
||||||
|
let key_error = RwSignal::new(None::<String>);
|
||||||
|
view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>
|
||||||
|
"Key name "
|
||||||
|
<span
|
||||||
|
class="hint-chip"
|
||||||
|
tabindex="0"
|
||||||
|
title="Write names like enter, backspace, a, f5, left_arrow, kp_enter, or 0x28."
|
||||||
|
>
|
||||||
|
"?"
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || key_name.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
key_name.set(event_target_value(&ev));
|
||||||
|
key_error.set(None);
|
||||||
|
}
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
match parse_keyboard_key_name(&value) {
|
||||||
|
Some(code) => {
|
||||||
|
key_name.set(keyboard_key_short_name(code));
|
||||||
|
key_error.set(None);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("key".to_string(), json!(code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
key_error.set(Some("Unknown key name. Use names like enter, backspace, a, f5, left_arrow, or kp_enter.".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="subtle">
|
||||||
|
{format!(
|
||||||
|
"Current HID key: 0x{keyboard_key:02x} {}",
|
||||||
|
keyboard_key_display_name(keyboard_key)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{move || key_error.get().map(|error| view! {
|
||||||
|
<p class="field-error">{error}</p>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div class="modifier-grid">
|
||||||
|
{["left_control", "left_shift", "left_alt", "left_gui", "right_control", "right_shift", "right_alt", "right_gui"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|modifier| {
|
||||||
|
let modifier_name = modifier.to_string();
|
||||||
|
let active = keyboard_modifiers.contains(&modifier_name);
|
||||||
|
view! {
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=active
|
||||||
|
on:change=move |ev| {
|
||||||
|
let checked = event_target_checked(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
toggle_payload_string_array(mapping, "modifier", modifier, checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{modifier}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=keyboard_turbo.is_some()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let checked = event_target_checked(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
payload.insert(
|
||||||
|
"turbo".to_string(),
|
||||||
|
if checked { json!(200) } else { Value::Null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Turbo"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Turbo ms"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
prop:value=keyboard_turbo.unwrap_or(200)
|
||||||
|
disabled=keyboard_turbo.is_none()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(200).max(1);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("turbo".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
_ => render_button_mapping_editor_secondary(
|
||||||
|
mapping_state,
|
||||||
|
current_category,
|
||||||
|
macro_id,
|
||||||
|
macro_mode,
|
||||||
|
macro_times,
|
||||||
|
dpi_fn,
|
||||||
|
dpi_stage,
|
||||||
|
dpi_pair,
|
||||||
|
profile_fn,
|
||||||
|
fixed_profile,
|
||||||
|
system_flags,
|
||||||
|
consumer_code,
|
||||||
|
toggle_value,
|
||||||
|
custom_class,
|
||||||
|
custom_bytes,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/app/button_editor_secondary.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
fn render_button_mapping_editor_secondary(
|
||||||
|
mapping_state: RwSignal<Option<ButtonMappingState>>,
|
||||||
|
current_category: String,
|
||||||
|
macro_id: u16,
|
||||||
|
macro_mode: String,
|
||||||
|
macro_times: u8,
|
||||||
|
dpi_fn: String,
|
||||||
|
dpi_stage: u8,
|
||||||
|
dpi_pair: [u16; 2],
|
||||||
|
profile_fn: String,
|
||||||
|
fixed_profile: String,
|
||||||
|
system_flags: Vec<String>,
|
||||||
|
consumer_code: u16,
|
||||||
|
toggle_value: u8,
|
||||||
|
custom_class: u8,
|
||||||
|
custom_bytes: Vec<u8>,
|
||||||
|
) -> AnyView {
|
||||||
|
match current_category.as_str() {
|
||||||
|
"macro" => view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Macro ID"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="65535"
|
||||||
|
prop:value=macro_id
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(0);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("macro_id".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Mode"</span>
|
||||||
|
<select
|
||||||
|
prop:value=macro_mode.clone()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("mode".to_string(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="macro_fixed">"Fixed repeat"</option>
|
||||||
|
<option value="macro_hold">"Hold"</option>
|
||||||
|
<option value="macro_toggle">"Toggle"</option>
|
||||||
|
<option value="macro_sequence">"Sequence"</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Repeat count"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="255"
|
||||||
|
prop:value=macro_times
|
||||||
|
disabled=macro_mode != "macro_fixed"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("times".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"dpi_switch" => view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Action"</span>
|
||||||
|
<select
|
||||||
|
prop:value=dpi_fn.clone()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
payload.insert("fn".to_string(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="next">"Next"</option>
|
||||||
|
<option value="prev">"Previous"</option>
|
||||||
|
<option value="next_loop">"Next (loop)"</option>
|
||||||
|
<option value="prev_loop">"Previous (loop)"</option>
|
||||||
|
<option value="fixed">"Fixed stage"</option>
|
||||||
|
<option value="aim">"Aim DPI"</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Stage"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
prop:value=dpi_stage
|
||||||
|
disabled=dpi_fn != "fixed"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).clamp(1, 5);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("stage".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="inline-pair">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Aim X"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="25600"
|
||||||
|
step="100"
|
||||||
|
prop:value=dpi_pair[0]
|
||||||
|
disabled=dpi_fn != "aim"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(dpi_pair[0]);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
set_payload_pair_value(mapping, "dpi", 0, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Aim Y"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="25600"
|
||||||
|
step="100"
|
||||||
|
prop:value=dpi_pair[1]
|
||||||
|
disabled=dpi_fn != "aim"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(dpi_pair[1]);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
set_payload_pair_value(mapping, "dpi", 1, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"profile_switch" => view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Action"</span>
|
||||||
|
<select
|
||||||
|
prop:value=profile_fn.clone()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="next">"Next"</option>
|
||||||
|
<option value="prev">"Previous"</option>
|
||||||
|
<option value="next_loop">"Next (loop)"</option>
|
||||||
|
<option value="prev_loop">"Previous (loop)"</option>
|
||||||
|
<option value="fixed">"Fixed profile"</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Fixed profile"</span>
|
||||||
|
<select
|
||||||
|
prop:value=fixed_profile.clone()
|
||||||
|
disabled=profile_fn != "fixed"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("profile".to_string(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{["white", "red", "green", "blue", "cyan"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| view! { <option value=name>{name}</option> })
|
||||||
|
.collect_view()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"system" => view! {
|
||||||
|
<div class="modifier-grid">
|
||||||
|
{["power_down", "sleep", "wake_up"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let active = system_flags.contains(&name.to_string());
|
||||||
|
view! {
|
||||||
|
<label class="check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=active
|
||||||
|
on:change=move |ev| {
|
||||||
|
let checked = event_target_checked(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
toggle_payload_string_array(mapping, "fn", name, checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{name}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"consumer" => view! {
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Consumer code"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="65535"
|
||||||
|
prop:value=consumer_code
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u16>().unwrap_or(0x00b0);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"hypershift_toggle" => view! {
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Toggle value"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=toggle_value
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"scroll_mode_toggle" => view! {
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Toggle value"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=toggle_value
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
"custom" => view! {
|
||||||
|
<div class="button-editor-grid">
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Class"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=custom_class
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u8>().unwrap_or(0);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn_class".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Payload bytes"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=bytes_to_hex(&custom_bytes)
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
payload_object_mut(mapping).insert("fn_value".to_string(), json!(parse_hex_bytes(&value)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
_ => view! {
|
||||||
|
<p class="subtle">"Unknown mapping category."</p>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/app/button_panel.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
#[component]
|
||||||
|
fn ButtonPanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
const BUTTONS: [&str; 13] = [
|
||||||
|
"aim",
|
||||||
|
"left",
|
||||||
|
"middle",
|
||||||
|
"right",
|
||||||
|
"forward",
|
||||||
|
"wheel_up",
|
||||||
|
"middle_forward",
|
||||||
|
"wheel_left",
|
||||||
|
"backward",
|
||||||
|
"wheel_down",
|
||||||
|
"middle_backward",
|
||||||
|
"wheel_right",
|
||||||
|
"bottom",
|
||||||
|
];
|
||||||
|
const CATEGORIES: [&str; 11] = [
|
||||||
|
"disabled",
|
||||||
|
"mouse",
|
||||||
|
"keyboard",
|
||||||
|
"macro",
|
||||||
|
"dpi_switch",
|
||||||
|
"profile_switch",
|
||||||
|
"system",
|
||||||
|
"consumer",
|
||||||
|
"hypershift_toggle",
|
||||||
|
"scroll_mode_toggle",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
let selected_button = RwSignal::new("left".to_string());
|
||||||
|
let selected_hypershift = RwSignal::new(false);
|
||||||
|
let mapping_state = RwSignal::new(None::<ButtonMappingState>);
|
||||||
|
let demo_mappings = RwSignal::new(mock_button_mappings());
|
||||||
|
let button_categories = RwSignal::new(std::collections::BTreeMap::<String, String>::new());
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let profile = selected_profile.get();
|
||||||
|
let button = selected_button.get();
|
||||||
|
let hypershift = selected_hypershift.get();
|
||||||
|
if demo_mode {
|
||||||
|
let mapping = demo_mapping_for(&demo_mappings.get_untracked(), &profile, &button, hypershift)
|
||||||
|
.unwrap_or_else(|| default_button_mapping(profile.clone(), button.clone(), hypershift));
|
||||||
|
mapping_state.set(Some(mapping));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set(format!(
|
||||||
|
"Loading {button} mapping for profile {profile}{}...",
|
||||||
|
if hypershift { " with hypershift" } else { "" }
|
||||||
|
));
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<ButtonMappingState, _>(
|
||||||
|
"get_button_mapping",
|
||||||
|
&ButtonMappingQueryArgs {
|
||||||
|
profile,
|
||||||
|
button,
|
||||||
|
hypershift,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mapping) => {
|
||||||
|
mapping_state.set(Some(mapping));
|
||||||
|
set_status.set("Button mapping loaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let profile = selected_profile.get();
|
||||||
|
let hypershift = selected_hypershift.get();
|
||||||
|
if demo_mode {
|
||||||
|
let categories = demo_mappings
|
||||||
|
.get_untracked()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|mapping| mapping.profile == profile && mapping.hypershift == hypershift)
|
||||||
|
.map(|mapping| (mapping.button, mapping.category))
|
||||||
|
.collect();
|
||||||
|
button_categories.set(categories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local(async move {
|
||||||
|
let mut categories = std::collections::BTreeMap::new();
|
||||||
|
for button in BUTTONS {
|
||||||
|
if let Ok(mapping) = invoke::<ButtonMappingState, _>(
|
||||||
|
"get_button_mapping",
|
||||||
|
&ButtonMappingQueryArgs {
|
||||||
|
profile: profile.clone(),
|
||||||
|
button: button.to_string(),
|
||||||
|
hypershift,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
categories.insert(button.to_string(), mapping.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button_categories.set(categories);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let save_mapping = move |_| {
|
||||||
|
let Some(mapping) = mapping_state.get_untracked() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
demo_mappings.update(|items| upsert_button_mapping(items, mapping.clone()));
|
||||||
|
button_categories.update(|items| {
|
||||||
|
items.insert(mapping.button.clone(), mapping.category.clone());
|
||||||
|
});
|
||||||
|
set_status.set("Updated demo button mapping.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing button mapping...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<ButtonMappingState, _>(
|
||||||
|
"set_button_mapping",
|
||||||
|
&json!({ "mapping": mapping }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(updated) => {
|
||||||
|
button_categories.update(|items| {
|
||||||
|
items.insert(updated.button.clone(), updated.category.clone());
|
||||||
|
});
|
||||||
|
mapping_state.set(Some(updated));
|
||||||
|
set_status.set("Button mapping applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let reset_category = move |category: &'static str| {
|
||||||
|
mapping_state.update(|state| {
|
||||||
|
if let Some(mapping) = state.as_mut() {
|
||||||
|
mapping.category = category.to_string();
|
||||||
|
mapping.payload = default_payload_for_category(category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Button Mapping"</h3>
|
||||||
|
<div class="button-mapper-grid">
|
||||||
|
{BUTTONS.into_iter().map(|button| {
|
||||||
|
let button_name = button.to_string();
|
||||||
|
let button_active = button_name.clone();
|
||||||
|
let button_click = button_name.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tile"
|
||||||
|
class:active=move || selected_button.get() == button_active
|
||||||
|
on:click=move |_| selected_button.set(button_click.clone())
|
||||||
|
>
|
||||||
|
<strong>{button_label(button)}</strong>
|
||||||
|
<span>{move || button_categories.get().get(button).cloned().unwrap_or_else(|| button.to_string())}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check-row button-hypershift-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=move || selected_hypershift.get()
|
||||||
|
on:change=move |ev| selected_hypershift.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
<span>"When Hypershift on"</span>
|
||||||
|
</label>
|
||||||
|
<p class="subtle">
|
||||||
|
"Each button can be assigned a function when Hypershift is off, and another function when Hypershift is on."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"Assign a button to hypershift_toggle to let it switch Hypershift status."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{move || {
|
||||||
|
let Some(mapping) = mapping_state.get() else {
|
||||||
|
return view! { <p>"Loading button mapping..."</p> }.into_any();
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_category = mapping.category.clone();
|
||||||
|
let payload = mapping.payload.clone();
|
||||||
|
let mouse_fn = payload_string(&payload, "fn").unwrap_or_else(|| "left".to_string());
|
||||||
|
let mouse_double_click = payload_bool(&payload, "double_click");
|
||||||
|
let mouse_turbo = payload_u64(&payload, "turbo").unwrap_or(200) as u16;
|
||||||
|
let keyboard_key = payload_u64(&payload, "key").unwrap_or(0x04) as u8;
|
||||||
|
let keyboard_turbo = payload_u64(&payload, "turbo").map(|value| value as u16);
|
||||||
|
let keyboard_modifiers = payload_string_vec(&payload, "modifier");
|
||||||
|
let macro_id = payload_u64(&payload, "macro_id").unwrap_or(0) as u16;
|
||||||
|
let macro_mode = payload_string(&payload, "mode").unwrap_or_else(|| "macro_fixed".to_string());
|
||||||
|
let macro_times = payload_u64(&payload, "times").unwrap_or(1) as u8;
|
||||||
|
let dpi_fn = payload_string(&payload, "fn").unwrap_or_else(|| "next_loop".to_string());
|
||||||
|
let dpi_stage = payload_u64(&payload, "stage").unwrap_or(1) as u8;
|
||||||
|
let dpi_pair = payload_u16_pair(&payload, "dpi").unwrap_or([800, 800]);
|
||||||
|
let profile_fn = payload_string(&payload, "fn").unwrap_or_else(|| "next_loop".to_string());
|
||||||
|
let fixed_profile = payload_string(&payload, "profile").unwrap_or_else(|| "white".to_string());
|
||||||
|
let system_flags = payload_string_vec(&payload, "fn");
|
||||||
|
let consumer_code = payload_u64(&payload, "fn").unwrap_or(0x00b0) as u16;
|
||||||
|
let toggle_value = payload_u64(&payload, "fn").unwrap_or(1) as u8;
|
||||||
|
let custom_class = payload_u64(&payload, "fn_class").unwrap_or(0) as u8;
|
||||||
|
let custom_bytes = payload_byte_vec(&payload, "fn_value");
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<p class="subtle">
|
||||||
|
{format!(
|
||||||
|
"Editing {} on profile {}{}.",
|
||||||
|
mapping.button,
|
||||||
|
mapping.profile,
|
||||||
|
if mapping.hypershift { " with hypershift enabled" } else { "" }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="category-grid">
|
||||||
|
{CATEGORIES.into_iter().map(|category| {
|
||||||
|
let category_name = category.to_string();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active=move || mapping_state.get().map(|item| item.category == category_name).unwrap_or(false)
|
||||||
|
on:click=move |_| reset_category(category)
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{render_button_mapping_editor(
|
||||||
|
mapping_state,
|
||||||
|
current_category,
|
||||||
|
payload,
|
||||||
|
mouse_fn,
|
||||||
|
mouse_double_click,
|
||||||
|
mouse_turbo,
|
||||||
|
keyboard_key,
|
||||||
|
keyboard_turbo,
|
||||||
|
keyboard_modifiers,
|
||||||
|
macro_id,
|
||||||
|
macro_mode,
|
||||||
|
macro_times,
|
||||||
|
dpi_fn,
|
||||||
|
dpi_stage,
|
||||||
|
dpi_pair,
|
||||||
|
profile_fn,
|
||||||
|
fixed_profile,
|
||||||
|
system_flags,
|
||||||
|
consumer_code,
|
||||||
|
toggle_value,
|
||||||
|
custom_class,
|
||||||
|
custom_bytes,
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="button-actions">
|
||||||
|
<button type="button" class="primary-action" on:click=save_mapping>"Apply Mapping"</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
on:click=move |_| {
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
let button = selected_button.get_untracked();
|
||||||
|
let hypershift = selected_hypershift.get_untracked();
|
||||||
|
if demo_mode {
|
||||||
|
let mapping = demo_mapping_for(&demo_mappings.get_untracked(), &profile, &button, hypershift)
|
||||||
|
.unwrap_or_else(|| default_button_mapping(profile, button, hypershift));
|
||||||
|
mapping_state.set(Some(mapping));
|
||||||
|
set_status.set("Reloaded demo button mapping.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Reloading button mapping...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<ButtonMappingState, _>(
|
||||||
|
"get_button_mapping",
|
||||||
|
&ButtonMappingQueryArgs {
|
||||||
|
profile,
|
||||||
|
button,
|
||||||
|
hypershift,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mapping) => {
|
||||||
|
mapping_state.set(Some(mapping));
|
||||||
|
set_status.set("Button mapping reloaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Reload"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/app/helpers.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
include!("helpers/data.rs");
|
||||||
|
include!("helpers/button_state.rs");
|
||||||
|
include!("helpers/keyboard_codes.rs");
|
||||||
|
include!("helpers/runtime.rs");
|
||||||
|
include!("helpers/formatting.rs");
|
||||||
140
src/app/helpers/button_state.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
fn mock_button_mappings() -> Vec<ButtonMappingState> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for profile in ["direct", "white", "red"] {
|
||||||
|
for button in [
|
||||||
|
"aim",
|
||||||
|
"left",
|
||||||
|
"middle",
|
||||||
|
"right",
|
||||||
|
"forward",
|
||||||
|
"wheel_up",
|
||||||
|
"middle_forward",
|
||||||
|
"wheel_left",
|
||||||
|
"backward",
|
||||||
|
"wheel_down",
|
||||||
|
"middle_backward",
|
||||||
|
"wheel_right",
|
||||||
|
"bottom",
|
||||||
|
] {
|
||||||
|
out.push(default_button_mapping(profile.to_string(), button.to_string(), false));
|
||||||
|
out.push(default_button_mapping(profile.to_string(), button.to_string(), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn demo_mapping_for(
|
||||||
|
mappings: &[ButtonMappingState],
|
||||||
|
profile: &str,
|
||||||
|
button: &str,
|
||||||
|
hypershift: bool,
|
||||||
|
) -> Option<ButtonMappingState> {
|
||||||
|
mappings
|
||||||
|
.iter()
|
||||||
|
.find(|mapping| {
|
||||||
|
mapping.profile == profile
|
||||||
|
&& mapping.button == button
|
||||||
|
&& mapping.hypershift == hypershift
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_button_mapping(mappings: &mut Vec<ButtonMappingState>, next: ButtonMappingState) {
|
||||||
|
if let Some(existing) = mappings.iter_mut().find(|mapping| {
|
||||||
|
mapping.profile == next.profile
|
||||||
|
&& mapping.button == next.button
|
||||||
|
&& mapping.hypershift == next.hypershift
|
||||||
|
}) {
|
||||||
|
*existing = next;
|
||||||
|
} else {
|
||||||
|
mappings.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_object_mut(mapping: &mut ButtonMappingState) -> &mut Map<String, Value> {
|
||||||
|
if !mapping.payload.is_object() {
|
||||||
|
mapping.payload = json!({});
|
||||||
|
}
|
||||||
|
mapping.payload.as_object_mut().expect("payload should be object")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_string(payload: &Value, key: &str) -> Option<String> {
|
||||||
|
payload.get(key).and_then(Value::as_str).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_bool(payload: &Value, key: &str) -> bool {
|
||||||
|
payload.get(key).and_then(Value::as_bool).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_u64(payload: &Value, key: &str) -> Option<u64> {
|
||||||
|
payload.get(key).and_then(Value::as_u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_string_vec(payload: &Value, key: &str) -> Vec<String> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|values| {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_byte_vec(payload: &Value, key: &str) -> Vec<u8> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|values| {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_u64)
|
||||||
|
.filter(|value| *value <= 255)
|
||||||
|
.map(|value| value as u8)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_u16_pair(payload: &Value, key: &str) -> Option<[u16; 2]> {
|
||||||
|
let values = payload.get(key)?.as_array()?;
|
||||||
|
Some([
|
||||||
|
values.first()?.as_u64()? as u16,
|
||||||
|
values.get(1)?.as_u64()? as u16,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_payload_string_array(mapping: &mut ButtonMappingState, key: &str, value: &str, enabled: bool) {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
let mut items = payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let value_json = Value::String(value.to_string());
|
||||||
|
let contains = items.iter().any(|item| item == &value_json);
|
||||||
|
if enabled && !contains {
|
||||||
|
items.push(value_json);
|
||||||
|
} else if !enabled && contains {
|
||||||
|
items.retain(|item| item != &value_json);
|
||||||
|
}
|
||||||
|
payload.insert(key.to_string(), Value::Array(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_payload_pair_value(mapping: &mut ButtonMappingState, key: &str, index: usize, value: u16) {
|
||||||
|
let payload = payload_object_mut(mapping);
|
||||||
|
let mut pair = payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| vec![json!(800), json!(800)]);
|
||||||
|
while pair.len() < 2 {
|
||||||
|
pair.push(json!(800));
|
||||||
|
}
|
||||||
|
pair[index.min(1)] = json!(value);
|
||||||
|
payload.insert(key.to_string(), Value::Array(pair));
|
||||||
|
}
|
||||||
|
|
||||||
193
src/app/helpers/data.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
fn button_label(button: &str) -> &'static str {
|
||||||
|
match button {
|
||||||
|
"aim" => "Aim",
|
||||||
|
"left" => "Left",
|
||||||
|
"middle" => "Middle",
|
||||||
|
"right" => "Right",
|
||||||
|
"forward" => "Forward",
|
||||||
|
"wheel_up" => "Wheel Up",
|
||||||
|
"middle_forward" => "Tilt Forward",
|
||||||
|
"wheel_left" => "Wheel Left",
|
||||||
|
"backward" => "Backward",
|
||||||
|
"wheel_down" => "Wheel Down",
|
||||||
|
"middle_backward" => "Tilt Back",
|
||||||
|
"wheel_right" => "Wheel Right",
|
||||||
|
"bottom" => "Bottom",
|
||||||
|
_ => "Button",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_payload_for_category(category: &str) -> Value {
|
||||||
|
match category {
|
||||||
|
"disabled" => json!({}),
|
||||||
|
"mouse" => json!({"fn": "left", "double_click": false, "turbo": Value::Null}),
|
||||||
|
"keyboard" => json!({"key": 0x04, "modifier": [], "turbo": Value::Null}),
|
||||||
|
"macro" => json!({"macro_id": 0, "mode": "macro_fixed", "times": 1}),
|
||||||
|
"dpi_switch" => json!({"fn": "next_loop", "dpi": [800, 800], "stage": 1}),
|
||||||
|
"profile_switch" => json!({"fn": "next_loop", "profile": "white"}),
|
||||||
|
"system" => json!({"fn": ["power_down"]}),
|
||||||
|
"consumer" => json!({"fn": 0x00b0}),
|
||||||
|
"hypershift_toggle" => json!({"fn": 1}),
|
||||||
|
"scroll_mode_toggle" => json!({"fn": 1}),
|
||||||
|
"custom" => json!({"fn_class": 0, "fn_value": []}),
|
||||||
|
_ => json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_button_mapping(profile: String, button: String, hypershift: bool) -> ButtonMappingState {
|
||||||
|
let (category, payload) = match button.as_str() {
|
||||||
|
"left" => ("mouse", json!({"fn": "left", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"right" => ("mouse", json!({"fn": "right", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"middle" => ("mouse", json!({"fn": "middle", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"forward" => ("mouse", json!({"fn": "forward", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"backward" => ("mouse", json!({"fn": "backward", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"wheel_up" => ("mouse", json!({"fn": "wheel_up", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"wheel_down" => ("mouse", json!({"fn": "wheel_down", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"wheel_left" => ("mouse", json!({"fn": "wheel_left", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"wheel_right" => ("mouse", json!({"fn": "wheel_right", "double_click": false, "turbo": Value::Null})),
|
||||||
|
"aim" => ("dpi_switch", json!({"fn": "aim", "dpi": [800, 800], "stage": 1})),
|
||||||
|
"middle_forward" => ("dpi_switch", json!({"fn": "next_loop", "dpi": [800, 800], "stage": 1})),
|
||||||
|
"middle_backward" => ("dpi_switch", json!({"fn": "prev_loop", "dpi": [800, 800], "stage": 1})),
|
||||||
|
"bottom" => ("hypershift_toggle", json!({"fn": 1})),
|
||||||
|
_ => ("disabled", json!({})),
|
||||||
|
};
|
||||||
|
ButtonMappingState {
|
||||||
|
profile,
|
||||||
|
button,
|
||||||
|
hypershift,
|
||||||
|
category: category.to_string(),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_bundle_for_demo(profile: &str, snapshot: &DeviceState) -> ProfileConfigBundle {
|
||||||
|
ProfileConfigBundle {
|
||||||
|
basic: BasicSettings {
|
||||||
|
profile: profile.to_string(),
|
||||||
|
..snapshot.basic.clone()
|
||||||
|
},
|
||||||
|
button_mappings: mock_button_mappings()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|mapping| mapping.profile == profile)
|
||||||
|
.collect(),
|
||||||
|
led: mock_led_state(profile.to_string()),
|
||||||
|
profile_info_hex: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_profile_bundle(mut bundle: ProfileConfigBundle, profile: &str) -> ProfileConfigBundle {
|
||||||
|
bundle.basic.profile = profile.to_string();
|
||||||
|
bundle.led.profile = profile.to_string();
|
||||||
|
for mapping in &mut bundle.button_mappings {
|
||||||
|
mapping.profile = profile.to_string();
|
||||||
|
}
|
||||||
|
bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_macro_definitions() -> Vec<MacroDefinition> {
|
||||||
|
vec![
|
||||||
|
MacroDefinition {
|
||||||
|
macro_id: 0x0001,
|
||||||
|
macro_info_hex: None,
|
||||||
|
operations: vec![
|
||||||
|
MacroOperationState {
|
||||||
|
category: "mouse_button".to_string(),
|
||||||
|
payload: json!({"button": "LEFT"}),
|
||||||
|
},
|
||||||
|
MacroOperationState {
|
||||||
|
category: "delay".to_string(),
|
||||||
|
payload: json!({"value": 100}),
|
||||||
|
},
|
||||||
|
MacroOperationState {
|
||||||
|
category: "mouse_button".to_string(),
|
||||||
|
payload: json!({"button": "NONE"}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MacroDefinition {
|
||||||
|
macro_id: 0x0002,
|
||||||
|
macro_info_hex: None,
|
||||||
|
operations: vec![
|
||||||
|
MacroOperationState {
|
||||||
|
category: "keyboard".to_string(),
|
||||||
|
payload: json!({"key": 4, "is_up": false}),
|
||||||
|
},
|
||||||
|
MacroOperationState {
|
||||||
|
category: "delay".to_string(),
|
||||||
|
payload: json!({"value": 200}),
|
||||||
|
},
|
||||||
|
MacroOperationState {
|
||||||
|
category: "keyboard".to_string(),
|
||||||
|
payload: json!({"key": 4, "is_up": true}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_macro_definition(macros: &mut Vec<MacroDefinition>, next: MacroDefinition) {
|
||||||
|
if let Some(existing) = macros.iter_mut().find(|definition| definition.macro_id == next.macro_id)
|
||||||
|
{
|
||||||
|
*existing = next;
|
||||||
|
} else {
|
||||||
|
macros.push(next);
|
||||||
|
macros.sort_by_key(|definition| definition.macro_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_sensor_state() -> SensorState {
|
||||||
|
SensorState {
|
||||||
|
lift_mode: "sym_1".to_string(),
|
||||||
|
sensor_enabled: false,
|
||||||
|
device_mode: "normal".to_string(),
|
||||||
|
retrieved_calib: vec![0x30, 0x0d, 0x20, 0x02, 0, 0, 0, 0],
|
||||||
|
param_a: vec![0x30, 0x20, 0x0d, 0x08, 0x10, 0x05, 0x0f, 0x0a],
|
||||||
|
param_b: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_sensor_params(
|
||||||
|
mouse_data: &[u8],
|
||||||
|
lift: u8,
|
||||||
|
land: Option<u8>,
|
||||||
|
) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||||
|
if mouse_data.len() < 4 || !(1..=10).contains(&lift) || land.is_some_and(|value| !(1..=10).contains(&value)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let m0 = mouse_data[0];
|
||||||
|
let m1 = mouse_data[1];
|
||||||
|
let m2 = mouse_data[2];
|
||||||
|
let m3 = mouse_data[3];
|
||||||
|
|
||||||
|
let calc0 = |l: u8, base: u8| -> u8 {
|
||||||
|
((base as f32 - (base as f32 - 8.0) / 10.0 * (l.saturating_sub(1) as f32)).round()) as u8
|
||||||
|
};
|
||||||
|
let calc2 = |l: u8, base: u8, step: u8| -> u8 { (l - 1).saturating_mul(step) + base };
|
||||||
|
|
||||||
|
let asym = land.is_some();
|
||||||
|
let a0 = calc0(lift, m0);
|
||||||
|
let a1 = if lift < 5 { m2 } else { [0, 0, 0, 0, 0x30, 0x30, 0x38, 0x38, 0x38, 0x38][lift as usize - 1] };
|
||||||
|
let a2 = calc2(lift, m1, m3);
|
||||||
|
let a3 = if asym { 0x88 } else { 0x08 };
|
||||||
|
let a4 = a2.max(10);
|
||||||
|
let a5 = if asym {
|
||||||
|
[0x5, 0xf, 0xf, 0xf, 0xf, 0xf, 0x0, 0x0, 0x0, 0x0][lift as usize - 1]
|
||||||
|
} else {
|
||||||
|
[0x5, 0x5, 0x5, 0x5, 0x5, 0x5, 0x0, 0x0, 0x0, 0x0][lift as usize - 1]
|
||||||
|
};
|
||||||
|
let a6 = [0x10, 0xf, 0xf, 0xf, 0xf, 0xf, 0xf, 0xe, 0xe, 0xe, 0xe][lift as usize];
|
||||||
|
let a7 = [0xf, 0xd, 0xa, 0xa, 0xa, 0x8, 0x8, 0x8, 0x8, 0x8][lift as usize - 1];
|
||||||
|
let param_a = vec![a0, a1, a2, a3, a4, a5, a6, a7];
|
||||||
|
|
||||||
|
if let Some(land) = land {
|
||||||
|
let b0 = calc0(land, m0);
|
||||||
|
let b1 = if land < 5 { m2 } else { [0, 0, 0, 0, 0x30, 0x30, 0x38, 0x38, 0x38, 0x38][land as usize - 1] };
|
||||||
|
let b2 = calc2(land, m1, m3);
|
||||||
|
let b3 = b2.max(10);
|
||||||
|
let b4 = [0xf, 0xd, 0xa, 0xa, 0xa, 0x8, 0x8, 0x8, 0x8, 0x8][land as usize - 1];
|
||||||
|
Some((param_a, vec![b0, b1, b2, b3, b4]))
|
||||||
|
} else {
|
||||||
|
Some((param_a, Vec::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
137
src/app/helpers/formatting.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
fn js_error_to_string(value: JsValue) -> String {
|
||||||
|
value
|
||||||
|
.as_string()
|
||||||
|
.or_else(|| {
|
||||||
|
js_sys::Reflect::get(&value, &JsValue::from_str("message"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|message| message.as_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Tauri command failed".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_to_hex(color: &[u8; 3]) -> String {
|
||||||
|
format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_to_rgb(hex: &str) -> [u8; 3] {
|
||||||
|
if hex.len() != 7 || !hex.starts_with('#') {
|
||||||
|
return [255, 255, 255];
|
||||||
|
}
|
||||||
|
[
|
||||||
|
u8::from_str_radix(&hex[1..3], 16).unwrap_or(255),
|
||||||
|
u8::from_str_radix(&hex[3..5], 16).unwrap_or(255),
|
||||||
|
u8::from_str_radix(&hex[5..7], 16).unwrap_or(255),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex_bytes(input: &str) -> Vec<u8> {
|
||||||
|
input
|
||||||
|
.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ':')
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.filter_map(|segment| {
|
||||||
|
let cleaned = segment.strip_prefix("0x").unwrap_or(segment);
|
||||||
|
u8::from_str_radix(cleaned, 16).ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_log_timestamp(timestamp_ms: u64) -> String {
|
||||||
|
if timestamp_ms == 0 {
|
||||||
|
return "--:--:--".to_string();
|
||||||
|
}
|
||||||
|
let date = js_sys::Date::new(&JsValue::from_f64(timestamp_ms as f64));
|
||||||
|
format!(
|
||||||
|
"{:02}:{:02}:{:02}",
|
||||||
|
date.get_hours(),
|
||||||
|
date.get_minutes(),
|
||||||
|
date.get_seconds()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_macro_operations_yaml(operations: &[MacroOperationState]) -> Result<String, String> {
|
||||||
|
let yaml_ops = operations
|
||||||
|
.iter()
|
||||||
|
.map(|operation| (operation.category.clone(), operation.payload.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_yaml::to_string(&yaml_ops).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_macro_operations_yaml(input: &str) -> Result<Vec<MacroOperationState>, String> {
|
||||||
|
let value = serde_yaml::from_str::<serde_yaml::Value>(input).map_err(|error| error.to_string())?;
|
||||||
|
let Some(items) = value.as_sequence() else {
|
||||||
|
return Err("Macro YAML must be a list of operations".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
items.iter()
|
||||||
|
.map(|item| {
|
||||||
|
if let Some(mapping) = item.as_mapping() {
|
||||||
|
let category = mapping
|
||||||
|
.get(serde_yaml::Value::String("category".to_string()))
|
||||||
|
.and_then(serde_yaml::Value::as_str)
|
||||||
|
.ok_or_else(|| "Macro operation object is missing category".to_string())?;
|
||||||
|
let payload = mapping
|
||||||
|
.get(serde_yaml::Value::String("payload".to_string()))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_yaml::Value::Null);
|
||||||
|
return Ok(MacroOperationState {
|
||||||
|
category: category.to_string(),
|
||||||
|
payload: serde_json::to_value(payload).map_err(|error| error.to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(pair) = item.as_sequence() else {
|
||||||
|
return Err("Macro operation must be a [category, payload] pair or object".to_string());
|
||||||
|
};
|
||||||
|
if pair.len() != 2 {
|
||||||
|
return Err("Macro operation pair must contain exactly two items".to_string());
|
||||||
|
}
|
||||||
|
let category = pair[0]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| "Macro operation category must be a string".to_string())?;
|
||||||
|
Ok(MacroOperationState {
|
||||||
|
category: category.to_string(),
|
||||||
|
payload: serde_json::to_value(pair[1].clone()).map_err(|error| error.to_string())?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_led_state(profile: String) -> LedState {
|
||||||
|
LedState {
|
||||||
|
profile,
|
||||||
|
regions: vec![
|
||||||
|
LedRegionState {
|
||||||
|
region: "wheel".to_string(),
|
||||||
|
effect: "wave".to_string(),
|
||||||
|
mode: 1,
|
||||||
|
speed: 180,
|
||||||
|
colors: vec![],
|
||||||
|
brightness: 200,
|
||||||
|
},
|
||||||
|
LedRegionState {
|
||||||
|
region: "logo".to_string(),
|
||||||
|
effect: "static".to_string(),
|
||||||
|
mode: 0,
|
||||||
|
speed: 0,
|
||||||
|
colors: vec![[0, 255, 0]],
|
||||||
|
brightness: 180,
|
||||||
|
},
|
||||||
|
LedRegionState {
|
||||||
|
region: "strip".to_string(),
|
||||||
|
effect: "spectrum".to_string(),
|
||||||
|
mode: 1,
|
||||||
|
speed: 180,
|
||||||
|
colors: vec![],
|
||||||
|
brightness: 255,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/app/helpers/keyboard_codes.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
fn keyboard_key_short_name(code: u8) -> String {
|
||||||
|
match code {
|
||||||
|
0x04..=0x1d => char::from(b'a' + (code - 0x04)).to_string(),
|
||||||
|
0x1e..=0x26 => char::from(b'1' + (code - 0x1e)).to_string(),
|
||||||
|
0x27 => "0".to_string(),
|
||||||
|
0x28 => "enter".to_string(),
|
||||||
|
0x29 => "escape".to_string(),
|
||||||
|
0x2a => "backspace".to_string(),
|
||||||
|
0x2b => "tab".to_string(),
|
||||||
|
0x2c => "space".to_string(),
|
||||||
|
0x39 => "caps_lock".to_string(),
|
||||||
|
0x3a..=0x45 => format!("f{}", code - 0x39),
|
||||||
|
0x46 => "print_screen".to_string(),
|
||||||
|
0x47 => "scroll_lock".to_string(),
|
||||||
|
0x48 => "pause".to_string(),
|
||||||
|
0x49 => "insert".to_string(),
|
||||||
|
0x4a => "home".to_string(),
|
||||||
|
0x4b => "page_up".to_string(),
|
||||||
|
0x4c => "delete".to_string(),
|
||||||
|
0x4d => "end".to_string(),
|
||||||
|
0x4e => "page_down".to_string(),
|
||||||
|
0x4f => "right_arrow".to_string(),
|
||||||
|
0x50 => "left_arrow".to_string(),
|
||||||
|
0x51 => "down_arrow".to_string(),
|
||||||
|
0x52 => "up_arrow".to_string(),
|
||||||
|
0x53 => "kp_num_lock".to_string(),
|
||||||
|
0x54 => "kp_slash".to_string(),
|
||||||
|
0x55 => "kp_asterisk".to_string(),
|
||||||
|
0x56 => "kp_minus".to_string(),
|
||||||
|
0x57 => "kp_plus".to_string(),
|
||||||
|
0x58 => "kp_enter".to_string(),
|
||||||
|
0x59 => "kp_1".to_string(),
|
||||||
|
0x5a => "kp_2".to_string(),
|
||||||
|
0x5b => "kp_3".to_string(),
|
||||||
|
0x5c => "kp_4".to_string(),
|
||||||
|
0x5d => "kp_5".to_string(),
|
||||||
|
0x5e => "kp_6".to_string(),
|
||||||
|
0x5f => "kp_7".to_string(),
|
||||||
|
0x60 => "kp_8".to_string(),
|
||||||
|
0x61 => "kp_9".to_string(),
|
||||||
|
0x62 => "kp_0".to_string(),
|
||||||
|
0x63 => "kp_period".to_string(),
|
||||||
|
0x64 => "non_us_backslash".to_string(),
|
||||||
|
0x65 => "application".to_string(),
|
||||||
|
0x66 => "power".to_string(),
|
||||||
|
0x67 => "kp_equals".to_string(),
|
||||||
|
0x68..=0x73 => format!("f{}", code - 0x5b),
|
||||||
|
0x74 => "execute".to_string(),
|
||||||
|
0x75 => "help".to_string(),
|
||||||
|
0x76 => "menu".to_string(),
|
||||||
|
0x77 => "select".to_string(),
|
||||||
|
0x78 => "stop".to_string(),
|
||||||
|
0x79 => "again".to_string(),
|
||||||
|
0x7a => "undo".to_string(),
|
||||||
|
0x7b => "cut".to_string(),
|
||||||
|
0x7c => "copy".to_string(),
|
||||||
|
0x7d => "paste".to_string(),
|
||||||
|
0x7e => "find".to_string(),
|
||||||
|
0x7f => "mute".to_string(),
|
||||||
|
0x80 => "volume_up".to_string(),
|
||||||
|
0x81 => "volume_down".to_string(),
|
||||||
|
0xe0 => "left_control".to_string(),
|
||||||
|
0xe1 => "left_shift".to_string(),
|
||||||
|
0xe2 => "left_alt".to_string(),
|
||||||
|
0xe3 => "left_gui".to_string(),
|
||||||
|
0xe4 => "right_control".to_string(),
|
||||||
|
0xe5 => "right_shift".to_string(),
|
||||||
|
0xe6 => "right_alt".to_string(),
|
||||||
|
0xe7 => "right_gui".to_string(),
|
||||||
|
_ => format!("0x{code:02x}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyboard_key_display_name(code: u8) -> String {
|
||||||
|
match code {
|
||||||
|
0x04..=0x1d => format!("Keyboard {}", char::from(b'a' + (code - 0x04))),
|
||||||
|
0x1e..=0x26 => format!("Keyboard {}", char::from(b'1' + (code - 0x1e))),
|
||||||
|
0x27 => "Keyboard 0".to_string(),
|
||||||
|
0x28 => "Keyboard Return (ENTER)".to_string(),
|
||||||
|
0x29 => "Keyboard ESCAPE".to_string(),
|
||||||
|
0x2a => "Keyboard DELETE (Backspace)".to_string(),
|
||||||
|
0x2b => "Keyboard Tab".to_string(),
|
||||||
|
0x2c => "Keyboard Spacebar".to_string(),
|
||||||
|
0x39 => "Keyboard Caps Lock".to_string(),
|
||||||
|
0x3a..=0x45 => format!("Keyboard F{}", code - 0x39),
|
||||||
|
0x46 => "Keyboard PrintScreen".to_string(),
|
||||||
|
0x47 => "Keyboard Scroll Lock".to_string(),
|
||||||
|
0x48 => "Keyboard Pause".to_string(),
|
||||||
|
0x49 => "Keyboard Insert".to_string(),
|
||||||
|
0x4a => "Keyboard Home".to_string(),
|
||||||
|
0x4b => "Keyboard PageUp".to_string(),
|
||||||
|
0x4c => "Keyboard Delete Forward".to_string(),
|
||||||
|
0x4d => "Keyboard End".to_string(),
|
||||||
|
0x4e => "Keyboard PageDown".to_string(),
|
||||||
|
0x4f => "Keyboard RightArrow".to_string(),
|
||||||
|
0x50 => "Keyboard LeftArrow".to_string(),
|
||||||
|
0x51 => "Keyboard DownArrow".to_string(),
|
||||||
|
0x52 => "Keyboard UpArrow".to_string(),
|
||||||
|
0x53 => "Keypad Num Lock and Clear".to_string(),
|
||||||
|
0x54 => "Keypad /".to_string(),
|
||||||
|
0x55 => "Keypad *".to_string(),
|
||||||
|
0x56 => "Keypad -".to_string(),
|
||||||
|
0x57 => "Keypad +".to_string(),
|
||||||
|
0x58 => "Keypad ENTER".to_string(),
|
||||||
|
0x59..=0x61 => format!("Keypad {}", code - 0x58),
|
||||||
|
0x62 => "Keypad 0 and Insert".to_string(),
|
||||||
|
0x63 => "Keypad . and Delete".to_string(),
|
||||||
|
0x64 => "Keyboard Non-US \\ and |".to_string(),
|
||||||
|
0x65 => "Keyboard Application".to_string(),
|
||||||
|
0x66 => "Keyboard Power".to_string(),
|
||||||
|
0x67 => "Keypad =".to_string(),
|
||||||
|
0x68..=0x73 => format!("Keyboard F{}", code - 0x5b),
|
||||||
|
0x74 => "Keyboard Execute".to_string(),
|
||||||
|
0x75 => "Keyboard Help".to_string(),
|
||||||
|
0x76 => "Keyboard Menu".to_string(),
|
||||||
|
0x77 => "Keyboard Select".to_string(),
|
||||||
|
0x78 => "Keyboard Stop".to_string(),
|
||||||
|
0x79 => "Keyboard Again".to_string(),
|
||||||
|
0x7a => "Keyboard Undo".to_string(),
|
||||||
|
0x7b => "Keyboard Cut".to_string(),
|
||||||
|
0x7c => "Keyboard Copy".to_string(),
|
||||||
|
0x7d => "Keyboard Paste".to_string(),
|
||||||
|
0x7e => "Keyboard Find".to_string(),
|
||||||
|
0x7f => "Keyboard Mute".to_string(),
|
||||||
|
0x80 => "Keyboard Volume Up".to_string(),
|
||||||
|
0x81 => "Keyboard Volume Down".to_string(),
|
||||||
|
0xe0 => "Keyboard LeftControl".to_string(),
|
||||||
|
0xe1 => "Keyboard LeftShift".to_string(),
|
||||||
|
0xe2 => "Keyboard LeftAlt".to_string(),
|
||||||
|
0xe3 => "Keyboard Left GUI".to_string(),
|
||||||
|
0xe4 => "Keyboard RightControl".to_string(),
|
||||||
|
0xe5 => "Keyboard RightShift".to_string(),
|
||||||
|
0xe6 => "Keyboard RightAlt".to_string(),
|
||||||
|
0xe7 => "Keyboard Right GUI".to_string(),
|
||||||
|
_ => format!("0x{code:02x}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_keyboard_key_name(input: &str) -> Option<u8> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(hex) = trimmed.strip_prefix("0x") {
|
||||||
|
if let Ok(value) = u8::from_str_radix(hex, 16) {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(value) = trimmed.parse::<u8>() {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'A'..='Z' => ch.to_ascii_lowercase(),
|
||||||
|
'a'..='z' | '0'..='9' => ch,
|
||||||
|
_ => '_',
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.split('_')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("_");
|
||||||
|
|
||||||
|
if normalized.len() == 1 {
|
||||||
|
let byte = normalized.as_bytes()[0];
|
||||||
|
return match byte {
|
||||||
|
b'a'..=b'z' => Some(0x04 + (byte - b'a')),
|
||||||
|
b'1'..=b'9' => Some(0x1e + (byte - b'1')),
|
||||||
|
b'0' => Some(0x27),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = normalized.strip_prefix('f') {
|
||||||
|
if let Ok(value) = rest.parse::<u8>() {
|
||||||
|
return match value {
|
||||||
|
1..=12 => Some(0x39 + value),
|
||||||
|
13..=24 => Some(0x5b + value),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match normalized.as_str() {
|
||||||
|
"enter" | "return" => Some(0x28),
|
||||||
|
"escape" | "esc" => Some(0x29),
|
||||||
|
"backspace" => Some(0x2a),
|
||||||
|
"tab" => Some(0x2b),
|
||||||
|
"space" | "spacebar" => Some(0x2c),
|
||||||
|
"caps_lock" | "capslock" => Some(0x39),
|
||||||
|
"print_screen" | "printscreen" => Some(0x46),
|
||||||
|
"scroll_lock" | "scrolllock" => Some(0x47),
|
||||||
|
"pause" => Some(0x48),
|
||||||
|
"insert" => Some(0x49),
|
||||||
|
"home" => Some(0x4a),
|
||||||
|
"page_up" | "pageup" | "pgup" => Some(0x4b),
|
||||||
|
"delete" | "delete_forward" | "del" => Some(0x4c),
|
||||||
|
"end" => Some(0x4d),
|
||||||
|
"page_down" | "pagedown" | "pgdown" => Some(0x4e),
|
||||||
|
"right" | "right_arrow" | "rightarrow" => Some(0x4f),
|
||||||
|
"left" | "left_arrow" | "leftarrow" => Some(0x50),
|
||||||
|
"down" | "down_arrow" | "downarrow" => Some(0x51),
|
||||||
|
"up" | "up_arrow" | "uparrow" => Some(0x52),
|
||||||
|
"kp_num_lock" | "numpad_num_lock" | "num_lock" => Some(0x53),
|
||||||
|
"kp_slash" | "numpad_slash" => Some(0x54),
|
||||||
|
"kp_asterisk" | "numpad_asterisk" => Some(0x55),
|
||||||
|
"kp_minus" | "numpad_minus" => Some(0x56),
|
||||||
|
"kp_plus" | "numpad_plus" => Some(0x57),
|
||||||
|
"kp_enter" | "numpad_enter" => Some(0x58),
|
||||||
|
"kp_1" | "numpad_1" => Some(0x59),
|
||||||
|
"kp_2" | "numpad_2" => Some(0x5a),
|
||||||
|
"kp_3" | "numpad_3" => Some(0x5b),
|
||||||
|
"kp_4" | "numpad_4" => Some(0x5c),
|
||||||
|
"kp_5" | "numpad_5" => Some(0x5d),
|
||||||
|
"kp_6" | "numpad_6" => Some(0x5e),
|
||||||
|
"kp_7" | "numpad_7" => Some(0x5f),
|
||||||
|
"kp_8" | "numpad_8" => Some(0x60),
|
||||||
|
"kp_9" | "numpad_9" => Some(0x61),
|
||||||
|
"kp_0" | "numpad_0" => Some(0x62),
|
||||||
|
"kp_period" | "numpad_period" | "kp_delete" => Some(0x63),
|
||||||
|
"application" | "menu" => Some(0x65),
|
||||||
|
"power" => Some(0x66),
|
||||||
|
"left_control" | "left_ctrl" | "lctrl" => Some(0xe0),
|
||||||
|
"left_shift" | "lshift" => Some(0xe1),
|
||||||
|
"left_alt" | "lalt" => Some(0xe2),
|
||||||
|
"left_gui" | "left_super" | "left_meta" | "lgui" => Some(0xe3),
|
||||||
|
"right_control" | "right_ctrl" | "rctrl" => Some(0xe4),
|
||||||
|
"right_shift" | "rshift" => Some(0xe5),
|
||||||
|
"right_alt" | "ralt" => Some(0xe6),
|
||||||
|
"right_gui" | "right_super" | "right_meta" | "rgui" => Some(0xe7),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/app/helpers/runtime.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
fn run_setting<A>(
|
||||||
|
command: &'static str,
|
||||||
|
args: A,
|
||||||
|
set_snapshot: WriteSignal<Option<DeviceState>>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) where
|
||||||
|
A: Serialize + 'static,
|
||||||
|
{
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing setting to device...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<DeviceState, _>(command, &args).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
set_snapshot.set(Some(snapshot));
|
||||||
|
set_status.set("Setting applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_led_effect_change(
|
||||||
|
demo_mode: bool,
|
||||||
|
led_state: RwSignal<Option<LedState>>,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
region: String,
|
||||||
|
effect: String,
|
||||||
|
mode: u8,
|
||||||
|
speed: u8,
|
||||||
|
colors: Vec<[u8; 3]>,
|
||||||
|
) {
|
||||||
|
if demo_mode {
|
||||||
|
if let Some(mut state) = led_state.get_untracked() {
|
||||||
|
if let Some(region_state) = state.regions.iter_mut().find(|item| item.region == region)
|
||||||
|
{
|
||||||
|
region_state.effect = effect;
|
||||||
|
region_state.mode = mode;
|
||||||
|
region_state.speed = speed;
|
||||||
|
region_state.colors = colors;
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("Updated LED demo state.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing LED effect...".to_string());
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<LedState, _>(
|
||||||
|
"set_led_effect",
|
||||||
|
&LedEffectArgs {
|
||||||
|
profile,
|
||||||
|
region,
|
||||||
|
effect,
|
||||||
|
mode,
|
||||||
|
speed,
|
||||||
|
colors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("LED effect applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_led_brightness_change(
|
||||||
|
demo_mode: bool,
|
||||||
|
led_state: RwSignal<Option<LedState>>,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
region: String,
|
||||||
|
brightness: u8,
|
||||||
|
) {
|
||||||
|
if demo_mode {
|
||||||
|
if let Some(mut state) = led_state.get_untracked() {
|
||||||
|
if let Some(region_state) = state.regions.iter_mut().find(|item| item.region == region)
|
||||||
|
{
|
||||||
|
region_state.brightness = brightness;
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("Updated LED demo brightness.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing LED brightness...".to_string());
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<LedState, _>(
|
||||||
|
"set_led_brightness",
|
||||||
|
&LedBrightnessArgs {
|
||||||
|
profile,
|
||||||
|
region,
|
||||||
|
brightness,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("LED brightness applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_no_args<T>(cmd: &str) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let args = js_sys::Object::new();
|
||||||
|
invoke_js(cmd, args.into()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke<T, A>(cmd: &str, args: &A) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
A: Serialize,
|
||||||
|
{
|
||||||
|
let args = serde_wasm_bindgen::to_value(args).map_err(|err| err.to_string())?;
|
||||||
|
invoke_js(cmd, args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_js<T>(cmd: &str, args: JsValue) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let value = JsFuture::from(tauri_invoke(cmd, args))
|
||||||
|
.await
|
||||||
|
.map_err(js_error_to_string)?;
|
||||||
|
serde_wasm_bindgen::from_value(value).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
295
src/app/led_panel.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
#[component]
|
||||||
|
fn LedPanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
let led_state = RwSignal::new(None::<LedState>);
|
||||||
|
let selected_region = RwSignal::new("wheel".to_string());
|
||||||
|
|
||||||
|
let load_led_state = move |profile: String| {
|
||||||
|
if demo_mode {
|
||||||
|
led_state.set(Some(mock_led_state(profile)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Loading LED settings...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
|
||||||
|
Ok(state) => {
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("LED settings loaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let load_led_state = load_led_state.clone();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
load_led_state(selected_profile.get());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let apply_to_all = move |_| {
|
||||||
|
let Some(state) = led_state.get_untracked() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(source_region) = state
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.find(|region| region.region == selected_region.get_untracked())
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
if let Some(mut next_state) = led_state.get_untracked() {
|
||||||
|
for region in &mut next_state.regions {
|
||||||
|
region.effect = source_region.effect.clone();
|
||||||
|
region.mode = source_region.mode;
|
||||||
|
region.speed = source_region.speed;
|
||||||
|
region.colors = source_region.colors.clone();
|
||||||
|
region.brightness = source_region.brightness;
|
||||||
|
}
|
||||||
|
led_state.set(Some(next_state));
|
||||||
|
set_status.set("Updated LED demo state.".to_string());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Applying LED settings to all regions...".to_string());
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<LedState, _>(
|
||||||
|
"apply_led_to_all_regions",
|
||||||
|
&LedApplyAllArgs {
|
||||||
|
profile,
|
||||||
|
region_state: source_region,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
led_state.set(Some(state));
|
||||||
|
set_status.set("LED settings applied to all regions.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"LED"</h3>
|
||||||
|
{move || {
|
||||||
|
let Some(state) = led_state.get() else {
|
||||||
|
return view! { <p>"Loading LED settings..."</p> }.into_any();
|
||||||
|
};
|
||||||
|
|
||||||
|
let led_profile_label = state.profile.clone();
|
||||||
|
let active_region_name = selected_region.get();
|
||||||
|
let active_region = state
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.find(|region| region.region == active_region_name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| state.regions[0].clone());
|
||||||
|
|
||||||
|
let region_id = active_region.region.clone();
|
||||||
|
let effect = active_region.effect.clone();
|
||||||
|
let mode = active_region.mode;
|
||||||
|
let speed = active_region.speed;
|
||||||
|
let colors = active_region.colors.clone();
|
||||||
|
let brightness = active_region.brightness;
|
||||||
|
let static_hex = colors
|
||||||
|
.first()
|
||||||
|
.map(color_to_hex)
|
||||||
|
.unwrap_or_else(|| "#ffffff".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<p class="subtle">{format!("Editing LED settings for profile {led_profile_label}")}</p>
|
||||||
|
<div class="led-region-tabs">
|
||||||
|
{state.regions.iter().map(|region| {
|
||||||
|
let region_name = region.region.clone();
|
||||||
|
let region_name_active = region_name.clone();
|
||||||
|
let region_name_click = region_name.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active=move || selected_region.get() == region_name_active
|
||||||
|
on:click=move |_| selected_region.set(region_name_click.clone())
|
||||||
|
>
|
||||||
|
{region.region.clone()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="led-options">
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "off" || effect == "disabled"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "off".to_string(), 0, 0, vec![])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Disabled"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "static"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![[255, 255, 255]])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Static"</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
prop:value=static_hex
|
||||||
|
disabled=effect != "static"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |ev| {
|
||||||
|
let color = hex_to_rgb(&event_target_value(&ev));
|
||||||
|
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![color]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "spectrum"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "spectrum".to_string(), 1, 180, vec![])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Spectrum"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "wave" && mode == 0
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 0, 180, vec![])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Wave (Static)"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "wave" && mode == 1
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 1, 180, vec![])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Wave (Clockwise)"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="led-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
prop:checked=effect == "wave" && mode == 2
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 2, 180, vec![])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>"Wave (Counter-clockwise)"</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<span>"Speed"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=speed
|
||||||
|
disabled=effect != "spectrum" && effect != "wave"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
let effect_name = effect.clone();
|
||||||
|
let effect_colors = colors.clone();
|
||||||
|
move |ev| {
|
||||||
|
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed);
|
||||||
|
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=speed
|
||||||
|
disabled=effect != "spectrum" && effect != "wave"
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
let effect_name = effect.clone();
|
||||||
|
let effect_colors = colors.clone();
|
||||||
|
move |ev| {
|
||||||
|
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed);
|
||||||
|
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<span>"Brightness"</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=brightness
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |ev| {
|
||||||
|
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
||||||
|
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
prop:value=brightness
|
||||||
|
on:change={
|
||||||
|
let region = region_id.clone();
|
||||||
|
move |ev| {
|
||||||
|
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
||||||
|
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="secondary-action" on:click=apply_to_all>
|
||||||
|
"Apply To All Regions"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
405
src/app/macro_panel.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
#[component]
|
||||||
|
fn MacroPanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
let macro_list = RwSignal::new(Vec::<u16>::new());
|
||||||
|
let selected_macro_id = RwSignal::new(None::<u16>);
|
||||||
|
let save_target_macro_id = RwSignal::new(None::<u16>);
|
||||||
|
let loaded_macro_info_hex = RwSignal::new(None::<String>);
|
||||||
|
let macro_yaml = RwSignal::new(String::new());
|
||||||
|
let all_macros_yaml = RwSignal::new(String::new());
|
||||||
|
let demo_macros = RwSignal::new(mock_macro_definitions());
|
||||||
|
|
||||||
|
let refresh_macros = move || {
|
||||||
|
if demo_mode {
|
||||||
|
let items = demo_macros.get_untracked();
|
||||||
|
macro_list.set(items.iter().map(|item| item.macro_id).collect());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Loading macros...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<Vec<u16>>("list_macros").await {
|
||||||
|
Ok(list) => {
|
||||||
|
macro_list.set(list);
|
||||||
|
set_status.set("Macros loaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| refresh_macros());
|
||||||
|
|
||||||
|
let load_selected = move |_| {
|
||||||
|
let Some(macro_id) = selected_macro_id.get_untracked() else {
|
||||||
|
set_status.set("Select a macro to load.".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
if let Some(definition) = demo_macros
|
||||||
|
.get_untracked()
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| item.macro_id == macro_id)
|
||||||
|
{
|
||||||
|
match format_macro_operations_yaml(&definition.operations) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
macro_yaml.set(yaml);
|
||||||
|
save_target_macro_id.set(Some(macro_id));
|
||||||
|
loaded_macro_info_hex.set(definition.macro_info_hex.clone());
|
||||||
|
set_status.set("Loaded demo macro.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize demo macro: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set(format!("Loading macro 0x{macro_id:04x}..."));
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<MacroDefinition, _>("get_macro_definition", &MacroArgs { macro_id }).await {
|
||||||
|
Ok(definition) => match format_macro_operations_yaml(&definition.operations) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
macro_yaml.set(yaml);
|
||||||
|
save_target_macro_id.set(Some(macro_id));
|
||||||
|
loaded_macro_info_hex.set(definition.macro_info_hex.clone());
|
||||||
|
set_status.set("Macro loaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize macro: {error}")),
|
||||||
|
},
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let delete_selected = move |_| {
|
||||||
|
let Some(macro_id) = selected_macro_id.get_untracked() else {
|
||||||
|
set_status.set("Select a macro to delete.".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
demo_macros.update(|items| items.retain(|item| item.macro_id != macro_id));
|
||||||
|
macro_list.update(|items| items.retain(|item| *item != macro_id));
|
||||||
|
selected_macro_id.set(None);
|
||||||
|
set_status.set("Deleted demo macro.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set(format!("Deleting macro 0x{macro_id:04x}..."));
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<Vec<u16>, _>("delete_macro", &MacroArgs { macro_id }).await {
|
||||||
|
Ok(list) => {
|
||||||
|
macro_list.set(list);
|
||||||
|
selected_macro_id.set(None);
|
||||||
|
loaded_macro_info_hex.set(None);
|
||||||
|
set_status.set("Macro deleted.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let save_macro = move |_| {
|
||||||
|
let Some(macro_id) = save_target_macro_id.get_untracked() else {
|
||||||
|
set_status.set("Choose a target macro ID before saving.".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let operations = match parse_macro_operations_yaml(¯o_yaml.get_untracked()) {
|
||||||
|
Ok(operations) => operations,
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(format!("Could not parse macro YAML: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let definition = MacroDefinition {
|
||||||
|
macro_id,
|
||||||
|
macro_info_hex: loaded_macro_info_hex.get_untracked(),
|
||||||
|
operations,
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
demo_macros.update(|items| upsert_macro_definition(items, definition.clone()));
|
||||||
|
macro_list.set(demo_macros.get_untracked().iter().map(|item| item.macro_id).collect());
|
||||||
|
set_status.set("Saved demo macro.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set(format!("Saving macro 0x{macro_id:04x}..."));
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<Vec<u16>, _>(
|
||||||
|
"set_macro_definition",
|
||||||
|
&MacroMutationArgs {
|
||||||
|
macro_id,
|
||||||
|
definition,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(list) => {
|
||||||
|
macro_list.set(list);
|
||||||
|
set_status.set("Macro saved.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let export_all = move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
let export_map = demo_macros
|
||||||
|
.get_untracked()
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| {
|
||||||
|
let ops = definition
|
||||||
|
.operations
|
||||||
|
.into_iter()
|
||||||
|
.map(|operation| (operation.category, operation.payload))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(definition.macro_id.to_string(), ops)
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeMap<_, _>>();
|
||||||
|
match serde_yaml::to_string(&export_map) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
all_macros_yaml.set(yaml);
|
||||||
|
set_status.set("Exported demo macros.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize demo macros: {error}")),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let list = macro_list.get_untracked();
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Exporting all macros...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
let mut export_map = std::collections::BTreeMap::<String, Vec<(String, Value)>>::new();
|
||||||
|
for macro_id in list {
|
||||||
|
match invoke::<MacroDefinition, _>("get_macro_definition", &MacroArgs { macro_id }).await {
|
||||||
|
Ok(definition) => {
|
||||||
|
export_map.insert(
|
||||||
|
macro_id.to_string(),
|
||||||
|
definition
|
||||||
|
.operations
|
||||||
|
.into_iter()
|
||||||
|
.map(|operation| (operation.category, operation.payload))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(error);
|
||||||
|
set_busy.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match serde_yaml::to_string(&export_map) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
all_macros_yaml.set(yaml);
|
||||||
|
set_status.set("All macros exported.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize macros: {error}")),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let import_all = move |_| {
|
||||||
|
let parsed = match serde_yaml::from_str::<std::collections::BTreeMap<String, serde_yaml::Value>>(&all_macros_yaml.get_untracked()) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(format!("Could not parse all-macros YAML: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
let mut next = Vec::new();
|
||||||
|
for (macro_id, value) in parsed {
|
||||||
|
if let Ok(macro_id) = macro_id.parse::<u16>() {
|
||||||
|
let ops_yaml = match serde_yaml::to_string(&value) {
|
||||||
|
Ok(yaml) => yaml,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let operations = match parse_macro_operations_yaml(&ops_yaml) {
|
||||||
|
Ok(operations) => operations,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
next.push(MacroDefinition { macro_id, macro_info_hex: None, operations });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
demo_macros.set(next.clone());
|
||||||
|
macro_list.set(next.iter().map(|item| item.macro_id).collect());
|
||||||
|
set_status.set("Imported demo macros.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Importing all macros...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
for (macro_id, value) in parsed {
|
||||||
|
let Ok(macro_id) = macro_id.parse::<u16>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ops_yaml = match serde_yaml::to_string(&value) {
|
||||||
|
Ok(yaml) => yaml,
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(format!("Could not serialize imported macro YAML: {error}"));
|
||||||
|
set_busy.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let operations = match parse_macro_operations_yaml(&ops_yaml) {
|
||||||
|
Ok(operations) => operations,
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(format!("Could not parse imported macro {macro_id}: {error}"));
|
||||||
|
set_busy.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let definition = MacroDefinition {
|
||||||
|
macro_id,
|
||||||
|
macro_info_hex: None,
|
||||||
|
operations,
|
||||||
|
};
|
||||||
|
if let Err(error) = invoke::<Vec<u16>, _>(
|
||||||
|
"set_macro_definition",
|
||||||
|
&MacroMutationArgs { macro_id, definition },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
set_status.set(error);
|
||||||
|
set_busy.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match invoke_no_args::<Vec<u16>>("list_macros").await {
|
||||||
|
Ok(list) => {
|
||||||
|
macro_list.set(list);
|
||||||
|
set_status.set("All macros imported.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let reset_flash = move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
demo_macros.set(Vec::new());
|
||||||
|
macro_list.set(Vec::new());
|
||||||
|
set_status.set("Reset demo macro flash.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Resetting macro flash...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<Vec<u16>>("reset_macro_flash").await {
|
||||||
|
Ok(list) => {
|
||||||
|
macro_list.set(list);
|
||||||
|
selected_macro_id.set(None);
|
||||||
|
loaded_macro_info_hex.set(None);
|
||||||
|
set_status.set("Macro flash reset completed.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Macros"</h3>
|
||||||
|
<h4>"Edit Individual Macros"</h4>
|
||||||
|
<div class="macro-toolbar">
|
||||||
|
<label class="field inline">
|
||||||
|
<span>"Select existing"</span>
|
||||||
|
<select
|
||||||
|
prop:value=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
selected_macro_id.set(value.parse::<u16>().ok());
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">"(none)"</option>
|
||||||
|
{move || macro_list.get().into_iter().map(|macro_id| {
|
||||||
|
let label = format!("0x{macro_id:04x}");
|
||||||
|
view! { <option value=macro_id.to_string()>{label}</option> }
|
||||||
|
}).collect_view()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary-action" on:click=load_selected>"Load"</button>
|
||||||
|
<button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Macro YAML"</span>
|
||||||
|
<textarea
|
||||||
|
prop:value=move || macro_yaml.get()
|
||||||
|
on:input=move |ev| macro_yaml.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="macro-toolbar">
|
||||||
|
<button type="button" class="primary-action" on:click=save_macro>"Save as ID"</button>
|
||||||
|
<label class="field inline compact">
|
||||||
|
<span>"Target ID"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || save_target_macro_id.get().map(|value| format!("0x{value:04x}")).unwrap_or_default()
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
let parsed = value
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.and_then(|hex| u16::from_str_radix(hex, 16).ok())
|
||||||
|
.or_else(|| value.parse::<u16>().ok());
|
||||||
|
save_target_macro_id.set(parsed);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>"Export or Import All"</h4>
|
||||||
|
<label class="field">
|
||||||
|
<span>"All macros YAML"</span>
|
||||||
|
<textarea
|
||||||
|
prop:value=move || all_macros_yaml.get()
|
||||||
|
on:input=move |ev| all_macros_yaml.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="button-actions">
|
||||||
|
<button type="button" class="secondary-action" on:click=export_all>"Export All"</button>
|
||||||
|
<button type="button" class="secondary-action" on:click=import_all>"Import All"</button>
|
||||||
|
<button type="button" class="secondary-action danger" on:click=reset_flash>"Reset Flash"</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="subtle">
|
||||||
|
"A macro consists of multiple operations. It is represented as a YAML list in the textarea above."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"Each list item is a pair: [category, payload]. Object-style entries are still accepted on import."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"Mouse button operations use flags like LEFT, RIGHT, MIDDLE, BACKWARD, and FORWARD. A click is usually press, delay, then NONE."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"Keyboard operations use HID key codes. System and consumer operations follow the reverse-engineered protocol values."
|
||||||
|
</p>
|
||||||
|
<div class="macro-help">
|
||||||
|
<p>"Example operations:"</p>
|
||||||
|
<pre>{r#"[
|
||||||
|
[mouse_button, {button: LEFT}],
|
||||||
|
[delay, {value: 100}],
|
||||||
|
[mouse_button, {button: NONE}],
|
||||||
|
[keyboard, {key: 4, is_up: false}],
|
||||||
|
[delay, {value: 200}],
|
||||||
|
[keyboard, {key: 4, is_up: true}]
|
||||||
|
]"#}</pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/app/models.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeviceSummary {
|
||||||
|
path: String,
|
||||||
|
product_id: u16,
|
||||||
|
product_name: String,
|
||||||
|
manufacturer: Option<String>,
|
||||||
|
serial: Option<String>,
|
||||||
|
interface_number: Option<u8>,
|
||||||
|
supported_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BasicSettings {
|
||||||
|
profile: String,
|
||||||
|
scroll_mode: String,
|
||||||
|
scroll_acceleration: bool,
|
||||||
|
scroll_smart_reel: bool,
|
||||||
|
polling_rate_ms: u8,
|
||||||
|
dpi_xy: [u16; 2],
|
||||||
|
dpi_stages: Vec<[u16; 2]>,
|
||||||
|
active_dpi_stage: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeviceState {
|
||||||
|
device: DeviceSummary,
|
||||||
|
serial: String,
|
||||||
|
firmware: String,
|
||||||
|
profiles: Vec<String>,
|
||||||
|
basic: BasicSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LedRegionState {
|
||||||
|
region: String,
|
||||||
|
effect: String,
|
||||||
|
mode: u8,
|
||||||
|
speed: u8,
|
||||||
|
colors: Vec<[u8; 3]>,
|
||||||
|
brightness: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LedState {
|
||||||
|
profile: String,
|
||||||
|
regions: Vec<LedRegionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ProfileConfigBundle {
|
||||||
|
basic: BasicSettings,
|
||||||
|
button_mappings: Vec<ButtonMappingState>,
|
||||||
|
led: LedState,
|
||||||
|
profile_info_hex: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ConnectArgs {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProfileArgs {
|
||||||
|
profile: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ScrollModeArgs {
|
||||||
|
profile: String,
|
||||||
|
mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct BoolSettingArgs {
|
||||||
|
profile: String,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PollingRateArgs {
|
||||||
|
profile: String,
|
||||||
|
polling_rate_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DpiArgs {
|
||||||
|
profile: String,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DpiStagesArgs {
|
||||||
|
profile: String,
|
||||||
|
dpi_stages: Vec<[u16; 2]>,
|
||||||
|
active_stage: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProfileMutationArgs {
|
||||||
|
profile: String,
|
||||||
|
current_profile: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DeleteProfileArgs {
|
||||||
|
profile: String,
|
||||||
|
next_profile: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LedProfileArgs {
|
||||||
|
profile: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LedEffectArgs {
|
||||||
|
profile: String,
|
||||||
|
region: String,
|
||||||
|
effect: String,
|
||||||
|
mode: u8,
|
||||||
|
speed: u8,
|
||||||
|
colors: Vec<[u8; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LedBrightnessArgs {
|
||||||
|
profile: String,
|
||||||
|
region: String,
|
||||||
|
brightness: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LedApplyAllArgs {
|
||||||
|
profile: String,
|
||||||
|
region_state: LedRegionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
struct ButtonMappingState {
|
||||||
|
profile: String,
|
||||||
|
button: String,
|
||||||
|
hypershift: bool,
|
||||||
|
category: String,
|
||||||
|
payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ButtonMappingQueryArgs {
|
||||||
|
profile: String,
|
||||||
|
button: String,
|
||||||
|
hypershift: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProfileImportArgs {
|
||||||
|
profile: String,
|
||||||
|
bundle: ProfileConfigBundle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
struct MacroOperationState {
|
||||||
|
category: String,
|
||||||
|
payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MacroDefinition {
|
||||||
|
macro_id: u16,
|
||||||
|
macro_info_hex: Option<String>,
|
||||||
|
operations: Vec<MacroOperationState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MacroArgs {
|
||||||
|
macro_id: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MacroMutationArgs {
|
||||||
|
macro_id: u16,
|
||||||
|
definition: MacroDefinition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DebugLogEntry {
|
||||||
|
timestamp_ms: u64,
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SensorState {
|
||||||
|
lift_mode: String,
|
||||||
|
sensor_enabled: bool,
|
||||||
|
device_mode: String,
|
||||||
|
retrieved_calib: Vec<u8>,
|
||||||
|
param_a: Vec<u8>,
|
||||||
|
param_b: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LiftModeArgs {
|
||||||
|
lift_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SensorParamsArgs {
|
||||||
|
param_a: Vec<u8>,
|
||||||
|
param_b: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct InfoState {
|
||||||
|
flash_total: u32,
|
||||||
|
flash_free: u32,
|
||||||
|
flash_recycled: u32,
|
||||||
|
macro_count: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RawCommandArgs {
|
||||||
|
command: u16,
|
||||||
|
args: Vec<u8>,
|
||||||
|
}
|
||||||
221
src/app/profile_panel.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
#[component]
|
||||||
|
fn ProfilePanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
set_snapshot: WriteSignal<Option<DeviceState>>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
const ALL_PROFILES: [&str; 6] = ["direct", "white", "red", "green", "blue", "cyan"];
|
||||||
|
let profile_yaml = RwSignal::new(String::new());
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
let delete_confirm = RwSignal::new(None::<String>);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="panel-grid">
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Profiles"</h3>
|
||||||
|
<p>"Create and delete profiles:"</p>
|
||||||
|
<div class="profile-admin-list">
|
||||||
|
{ALL_PROFILES.into_iter().map(|profile| {
|
||||||
|
let present = snapshot.profiles.iter().any(|value| value == profile);
|
||||||
|
let can_create = profile != "direct" && !present;
|
||||||
|
let can_delete = profile != "direct" && present;
|
||||||
|
let profile_name = profile.to_string();
|
||||||
|
view! {
|
||||||
|
<div class:active=present>
|
||||||
|
<strong>{profile}</strong>
|
||||||
|
<span>{if present { "Available" } else { "Missing" }}</span>
|
||||||
|
{if can_create {
|
||||||
|
let profile_name = profile_name.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
on:click=move |_| {
|
||||||
|
delete_confirm.set(None);
|
||||||
|
run_setting(
|
||||||
|
"create_profile",
|
||||||
|
ProfileMutationArgs {
|
||||||
|
profile: profile_name.clone(),
|
||||||
|
current_profile: selected_profile.get_untracked(),
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"New"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else if can_delete {
|
||||||
|
let profile_name = profile_name.clone();
|
||||||
|
let profile_name_for_class = profile_name.clone();
|
||||||
|
let profile_name_for_click = profile_name.clone();
|
||||||
|
let profile_name_for_label = profile_name.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || {
|
||||||
|
if delete_confirm.get() == Some(profile_name_for_class.clone()) {
|
||||||
|
"secondary-action danger"
|
||||||
|
} else {
|
||||||
|
"secondary-action"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
if delete_confirm.get_untracked() != Some(profile_name_for_click.clone()) {
|
||||||
|
delete_confirm.set(Some(profile_name_for_click.clone()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete_confirm.set(None);
|
||||||
|
let current_profile = selected_profile.get_untracked();
|
||||||
|
let next_profile = if current_profile == profile_name_for_click {
|
||||||
|
"direct".to_string()
|
||||||
|
} else {
|
||||||
|
current_profile
|
||||||
|
};
|
||||||
|
if next_profile == "direct" && selected_profile.get_untracked() == profile_name_for_click {
|
||||||
|
selected_profile.set(next_profile.clone());
|
||||||
|
}
|
||||||
|
run_setting(
|
||||||
|
"delete_profile",
|
||||||
|
DeleteProfileArgs {
|
||||||
|
profile: profile_name_for_click.clone(),
|
||||||
|
next_profile,
|
||||||
|
},
|
||||||
|
set_snapshot,
|
||||||
|
set_status,
|
||||||
|
set_busy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || if delete_confirm.get() == Some(profile_name_for_label.clone()) { "Confirm" } else { "Delete" }}
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<button type="button" class="secondary-action profile-slot-fixed" disabled=true>
|
||||||
|
""
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>"YAML"</h3>
|
||||||
|
<p>"Export and import profile configs (YAML):"</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"Export and import the selected profile's basic, button, and LED configuration. Import can be used to clone one profile into another."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"The config does not include macro and sensor data. These are stored separately."
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Profile YAML"</span>
|
||||||
|
<textarea
|
||||||
|
prop:value=move || profile_yaml.get()
|
||||||
|
on:input=move |ev| profile_yaml.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="button-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
on:click={
|
||||||
|
let snapshot = snapshot.clone();
|
||||||
|
move |_| {
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
if demo_mode {
|
||||||
|
let bundle = profile_bundle_for_demo(&profile, &snapshot);
|
||||||
|
match serde_yaml::to_string(&bundle) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
profile_yaml.set(yaml);
|
||||||
|
set_status.set("Exported demo profile YAML.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize demo profile YAML: {error}")),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Exporting profile YAML...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<ProfileConfigBundle, _>("export_profile_config", &ProfileArgs { profile: profile.clone() }).await {
|
||||||
|
Ok(bundle) => match serde_yaml::to_string(&bundle) {
|
||||||
|
Ok(yaml) => {
|
||||||
|
profile_yaml.set(yaml);
|
||||||
|
set_status.set("Profile YAML exported.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(format!("Could not serialize profile YAML: {error}")),
|
||||||
|
},
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Export Configs"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-action"
|
||||||
|
on:click={
|
||||||
|
let snapshot = snapshot.clone();
|
||||||
|
move |_| {
|
||||||
|
let profile = selected_profile.get_untracked();
|
||||||
|
let yaml = profile_yaml.get_untracked();
|
||||||
|
let parsed = match serde_yaml::from_str::<ProfileConfigBundle>(&yaml) {
|
||||||
|
Ok(bundle) => normalize_profile_bundle(bundle, &profile),
|
||||||
|
Err(error) => {
|
||||||
|
set_status.set(format!("Could not parse profile YAML: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if demo_mode {
|
||||||
|
let mut next_snapshot = snapshot.clone();
|
||||||
|
next_snapshot.basic = parsed.basic.clone();
|
||||||
|
set_snapshot.set(Some(next_snapshot));
|
||||||
|
set_status.set("Imported demo profile YAML into the current preview.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Importing profile YAML...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<DeviceState, _>(
|
||||||
|
"import_profile_config",
|
||||||
|
&ProfileImportArgs {
|
||||||
|
profile,
|
||||||
|
bundle: parsed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(next_snapshot) => {
|
||||||
|
set_snapshot.set(Some(next_snapshot));
|
||||||
|
set_status.set("Profile YAML imported.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Import Configs"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="subtle">
|
||||||
|
"Importing and exporting operates on the currently selected profile from the profile row above."
|
||||||
|
</p>
|
||||||
|
<p class="subtle">
|
||||||
|
"The config can be exported and imported to another profile, effectively cloning an existing profile."
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/app/root.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
let devices = RwSignal::new(Vec::<DeviceSummary>::new());
|
||||||
|
let selected_path = RwSignal::new(String::new());
|
||||||
|
let selected_profile = RwSignal::new("direct".to_string());
|
||||||
|
let device_state = RwSignal::new(None::<DeviceState>);
|
||||||
|
let active_tab = RwSignal::new("basic".to_string());
|
||||||
|
let status = RwSignal::new("Scanning for a supported Basilisk mouse.".to_string());
|
||||||
|
let busy = RwSignal::new(false);
|
||||||
|
|
||||||
|
let connect_path = move |path: String| {
|
||||||
|
if path.is_empty() {
|
||||||
|
status.set("Select a device path before connecting.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.set(true);
|
||||||
|
status.set(format!("Connecting to {path}..."));
|
||||||
|
spawn_local(async move {
|
||||||
|
let args = ConnectArgs { path };
|
||||||
|
match invoke::<DeviceState, _>("connect_device", &args).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
selected_profile.set(snapshot.basic.profile.clone());
|
||||||
|
status.set(format!("Connected to {}.", snapshot.device.supported_name));
|
||||||
|
device_state.set(Some(snapshot));
|
||||||
|
}
|
||||||
|
Err(error) => status.set(error),
|
||||||
|
}
|
||||||
|
busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let scan = move || {
|
||||||
|
busy.set(true);
|
||||||
|
status.set("Scanning /sys/class/hidraw for Basilisk V3 devices...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<Vec<DeviceSummary>>("list_supported_devices").await {
|
||||||
|
Ok(found) => {
|
||||||
|
if let Some(first) = found.first() {
|
||||||
|
let path = first.path.clone();
|
||||||
|
selected_path.set(path.clone());
|
||||||
|
devices.set(found);
|
||||||
|
status.set(format!("Found supported device. Connecting to {path}..."));
|
||||||
|
match invoke::<DeviceState, _>("connect_device", &ConnectArgs { path }).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
selected_profile.set(snapshot.basic.profile.clone());
|
||||||
|
status.set(format!("Connected to {}.", snapshot.device.supported_name));
|
||||||
|
device_state.set(Some(snapshot));
|
||||||
|
}
|
||||||
|
Err(error) => status.set(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selected_path.set(String::new());
|
||||||
|
devices.set(found);
|
||||||
|
status.set("No supported Razer hidraw devices found.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => status.set(error),
|
||||||
|
}
|
||||||
|
busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| scan());
|
||||||
|
|
||||||
|
let connect = move |_| {
|
||||||
|
let path = selected_path.get_untracked();
|
||||||
|
connect_path(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
let refresh_profile = move |profile: String| {
|
||||||
|
selected_profile.set(profile.clone());
|
||||||
|
busy.set(true);
|
||||||
|
status.set(format!("Loading {profile} profile..."));
|
||||||
|
spawn_local(async move {
|
||||||
|
let args = ProfileArgs { profile };
|
||||||
|
match invoke::<DeviceState, _>("refresh_device_state", &args).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
status.set("Profile settings loaded.".to_string());
|
||||||
|
device_state.set(Some(snapshot));
|
||||||
|
}
|
||||||
|
Err(error) => status.set(error),
|
||||||
|
}
|
||||||
|
busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<main class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="public/snakemouse.svg" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>"Razer Basilisk V3"</h1>
|
||||||
|
<p>"Onboard memory tools"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary-action" type="button" on:click=move |_| scan() disabled=move || busy.get()>
|
||||||
|
"Scan"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Device"</span>
|
||||||
|
<select
|
||||||
|
prop:value=move || selected_path.get()
|
||||||
|
on:change=move |ev| selected_path.set(event_target_value(&ev))
|
||||||
|
disabled=move || busy.get()
|
||||||
|
>
|
||||||
|
<option value="">"No supported device selected"</option>
|
||||||
|
{move || devices.get().into_iter().map(|device| {
|
||||||
|
let label = format!(
|
||||||
|
"{} - {} ({:04x}:{:04x}){}",
|
||||||
|
device.path,
|
||||||
|
device.supported_name,
|
||||||
|
0x1532,
|
||||||
|
device.product_id,
|
||||||
|
device
|
||||||
|
.serial
|
||||||
|
.as_ref()
|
||||||
|
.map(|serial| format!(" - {serial}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
view! {
|
||||||
|
<option value=device.path.clone()>{label}</option>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
|
||||||
|
"Connect"
|
||||||
|
</button>
|
||||||
|
<nav>
|
||||||
|
<button class:active=move || active_tab.get() == "basic" on:click=move |_| active_tab.set("basic".to_string())>"Basic"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "led" on:click=move |_| active_tab.set("led".to_string())>"LED"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "button" on:click=move |_| active_tab.set("button".to_string())>"Button"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "profile" on:click=move |_| active_tab.set("profile".to_string())>"Profiles"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "macro" on:click=move |_| active_tab.set("macro".to_string())>"Macros"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "sensor" on:click=move |_| active_tab.set("sensor".to_string())>"Sensor"</button>
|
||||||
|
<button class:active=move || active_tab.get() == "info" on:click=move |_| active_tab.set("info".to_string())>"Info"</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">"Linux desktop configuration"</p>
|
||||||
|
<h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="status" class:busy=move || busy.get()>{move || status.get()}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{move || match device_state.get() {
|
||||||
|
Some(snapshot) => view! {
|
||||||
|
<ConnectedView
|
||||||
|
snapshot=snapshot
|
||||||
|
active_tab=active_tab
|
||||||
|
selected_profile=selected_profile
|
||||||
|
refresh_profile=refresh_profile
|
||||||
|
set_snapshot=device_state.write_only()
|
||||||
|
set_status=status.write_only()
|
||||||
|
set_busy=busy.write_only()
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<section class="empty-state">
|
||||||
|
<h3>"Connect to a Basilisk V3 or V3 Pro"</h3>
|
||||||
|
<p>"This app uses Linux hidraw feature reports through the Tauri backend instead of WebHID and Pyodide."</p>
|
||||||
|
<p>"If your mouse is connected but does not appear, check that the current user can read and write the matching /dev/hidraw node."</p>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ConnectedView(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
active_tab: RwSignal<String>,
|
||||||
|
selected_profile: RwSignal<String>,
|
||||||
|
refresh_profile: impl Fn(String) + Copy + 'static,
|
||||||
|
set_snapshot: WriteSignal<Option<DeviceState>>,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="profile-row">
|
||||||
|
<span>"Profile"</span>
|
||||||
|
<div class="segments">
|
||||||
|
{snapshot.profiles.iter().map(|profile| {
|
||||||
|
let profile_name = profile.clone();
|
||||||
|
let profile_for_click = profile.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active=move || selected_profile.get() == profile_name
|
||||||
|
on:click=move |_| refresh_profile(profile_for_click.clone())
|
||||||
|
>
|
||||||
|
{profile.clone()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{move || match active_tab.get().as_str() {
|
||||||
|
"basic" => view! {
|
||||||
|
<BasicPanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
selected_profile=selected_profile
|
||||||
|
set_snapshot=set_snapshot
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
"info" => view! { <InfoPanel snapshot=snapshot.clone() /> }.into_any(),
|
||||||
|
"profile" => view! {
|
||||||
|
<ProfilePanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
selected_profile=selected_profile
|
||||||
|
set_snapshot=set_snapshot
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
"led" => view! {
|
||||||
|
<LedPanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
selected_profile=selected_profile
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
"button" => view! {
|
||||||
|
<ButtonPanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
selected_profile=selected_profile
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
"macro" => view! {
|
||||||
|
<MacroPanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
"sensor" => view! {
|
||||||
|
<SensorPanel
|
||||||
|
snapshot=snapshot.clone()
|
||||||
|
set_status=set_status
|
||||||
|
set_busy=set_busy
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! { <BacklogPanel title="Unknown Section" body="Select a section from the sidebar." /> }.into_any(),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/app/sensor_panel.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
#[component]
|
||||||
|
fn SensorPanel(
|
||||||
|
snapshot: DeviceState,
|
||||||
|
set_status: WriteSignal<String>,
|
||||||
|
set_busy: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
|
let sensor_state = RwSignal::new(Some(mock_sensor_state()));
|
||||||
|
let lift = RwSignal::new(4u8);
|
||||||
|
let land = RwSignal::new(4u8);
|
||||||
|
let asym = RwSignal::new(false);
|
||||||
|
let param_a = RwSignal::new(Vec::<u8>::new());
|
||||||
|
let param_b = RwSignal::new(Vec::<u8>::new());
|
||||||
|
|
||||||
|
let load_sensor = move || {
|
||||||
|
if demo_mode {
|
||||||
|
let state = mock_sensor_state();
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Loading sensor settings...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<SensorState>("get_sensor_state").await {
|
||||||
|
Ok(state) => {
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
set_status.set("Sensor settings loaded.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| load_sensor());
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let Some(retrieved) = sensor_state.get().map(|state| state.retrieved_calib.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some((next_param_a, next_param_b)) =
|
||||||
|
calculate_sensor_params(&retrieved, lift.get(), if asym.get() { Some(land.get()) } else { None })
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if param_a.get_untracked() != next_param_a {
|
||||||
|
param_a.set(next_param_a);
|
||||||
|
}
|
||||||
|
if param_b.get_untracked() != next_param_b {
|
||||||
|
param_b.set(next_param_b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let apply_lift_mode = move |lift_mode: &'static str| {
|
||||||
|
if demo_mode {
|
||||||
|
sensor_state.update(|state| {
|
||||||
|
if let Some(state) = state.as_mut() {
|
||||||
|
state.lift_mode = lift_mode.to_string();
|
||||||
|
state.sensor_enabled = !(lift_mode.starts_with("sym_") || lift_mode.starts_with("asym_"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_status.set("Updated demo sensor lift mode.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing sensor lift mode...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<SensorState, _>(
|
||||||
|
"set_sensor_lift_mode",
|
||||||
|
&LiftModeArgs {
|
||||||
|
lift_mode: lift_mode.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
set_status.set("Sensor lift mode applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let start_calibration = move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
sensor_state.update(|state| {
|
||||||
|
if let Some(state) = state.as_mut() {
|
||||||
|
state.device_mode = "driver".to_string();
|
||||||
|
state.sensor_enabled = true;
|
||||||
|
state.lift_mode = "calib1".to_string();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_status.set("Started demo calibration.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Starting sensor calibration...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<SensorState>("start_sensor_calibration").await {
|
||||||
|
Ok(state) => {
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
set_status.set("Sensor calibration started.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let stop_calibration = move || {
|
||||||
|
if demo_mode {
|
||||||
|
sensor_state.update(|state| {
|
||||||
|
if let Some(state) = state.as_mut() {
|
||||||
|
state.device_mode = "normal".to_string();
|
||||||
|
state.sensor_enabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_status.set("Stopped demo calibration.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Stopping sensor calibration...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke_no_args::<SensorState>("stop_sensor_calibration").await {
|
||||||
|
Ok(state) => {
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
set_status.set("Sensor calibration stopped.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_params = move |_| {
|
||||||
|
if demo_mode {
|
||||||
|
set_status.set("Applied demo sensor params.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_busy.set(true);
|
||||||
|
set_status.set("Writing sensor parameters...".to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
match invoke::<SensorState, _>(
|
||||||
|
"set_sensor_params",
|
||||||
|
&SensorParamsArgs {
|
||||||
|
param_a: param_a.get_untracked(),
|
||||||
|
param_b: param_b.get_untracked(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(state) => {
|
||||||
|
param_a.set(state.param_a.clone());
|
||||||
|
param_b.set(state.param_b.clone());
|
||||||
|
sensor_state.set(Some(state));
|
||||||
|
set_status.set("Sensor parameters applied.".to_string());
|
||||||
|
}
|
||||||
|
Err(error) => set_status.set(error),
|
||||||
|
}
|
||||||
|
set_busy.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section
|
||||||
|
class="panel-grid"
|
||||||
|
tabindex="0"
|
||||||
|
on:keydown=move |ev: KeyboardEvent| {
|
||||||
|
let key = ev.key();
|
||||||
|
if key == "s" || key == "S" {
|
||||||
|
stop_calibration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<article class="panel wide">
|
||||||
|
<h3>"Sensor"</h3>
|
||||||
|
<p class="subtle">"Please see reverse-engineer documentation for details."</p>
|
||||||
|
{move || {
|
||||||
|
let Some(state) = sensor_state.get() else {
|
||||||
|
return view! { <p>"Loading sensor settings..."</p> }.into_any();
|
||||||
|
};
|
||||||
|
let current_mode = state.lift_mode.clone();
|
||||||
|
let retr_json = serde_json::to_string(&state.retrieved_calib).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let param_a_json = serde_json::to_string(¶m_a.get()).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let param_b_json = serde_json::to_string(¶m_b.get()).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<p class="subtle">{format!("Device mode: {}. Lift sensor enabled: {}.", state.device_mode, state.sensor_enabled)}</p>
|
||||||
|
<div class="sensor-mode-grid">
|
||||||
|
{[
|
||||||
|
("Smart", "sym_1", "1"), ("Smart", "sym_2", "2"), ("Smart", "sym_3", "3"),
|
||||||
|
("Smart Asym", "asym_12", "1-2"), ("Smart Asym", "asym_13", "1-3"), ("Smart Asym", "asym_23", "2-3"),
|
||||||
|
("Calib", "config1", "Razer"), ("Calib", "calib1", "Self"),
|
||||||
|
("Calib Asym", "config2", "Razer"), ("Calib Asym", "calib2", "Self"),
|
||||||
|
].into_iter().map(|(group, value, label)| {
|
||||||
|
let active_mode = current_mode.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
class:active=move || active_mode == value
|
||||||
|
on:click=move |_| apply_lift_mode(value)
|
||||||
|
>
|
||||||
|
{format!("{group}: {label}")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-actions">
|
||||||
|
<button type="button" class="primary-action" on:click=start_calibration>"Start Calibration"</button>
|
||||||
|
<button type="button" class="secondary-action danger" on:click=move |_| stop_calibration()>"Stop"</button>
|
||||||
|
</div>
|
||||||
|
<p class="subtle">"Use Stop to exit calibration mode after collecting values. Alternatively, press S to stop."</p>
|
||||||
|
|
||||||
|
<div class="sensor-slider-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Retrieved calib data"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=retr_json
|
||||||
|
on:change=move |ev| {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Vec<u8>>(&event_target_value(&ev)) {
|
||||||
|
sensor_state.update(|state| {
|
||||||
|
if let Some(state) = state.as_mut() {
|
||||||
|
state.retrieved_calib = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="button-actions inline-actions">
|
||||||
|
<button type="button" class="secondary-action" on:click=move |_| load_sensor()>"From Calib"</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-action"
|
||||||
|
on:click=move |_| {
|
||||||
|
sensor_state.update(|state| {
|
||||||
|
if let Some(state) = state.as_mut() {
|
||||||
|
state.retrieved_calib = vec![0x30, 0x0d, 0x20, 0x02];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Razer"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Lift"</span>
|
||||||
|
<input type="range" min="1" max="10" prop:value=move || lift.get() on:input=move |ev| lift.set(event_target_value(&ev).parse::<u8>().unwrap_or(4).clamp(1, 10)) />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>"Land"</span>
|
||||||
|
<input type="range" min="1" max="10" prop:value=move || land.get() disabled=move || !asym.get() on:input=move |ev| land.set(event_target_value(&ev).parse::<u8>().unwrap_or(4).clamp(1, 10)) />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" prop:checked=move || asym.get() on:change=move |ev| asym.set(event_target_checked(&ev)) />
|
||||||
|
<span>"Asymmetric"</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sensor-param-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>"Param A"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=param_a_json
|
||||||
|
on:change=move |ev| {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Vec<u8>>(&event_target_value(&ev)) {
|
||||||
|
param_a.set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>"Param B"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=param_b_json
|
||||||
|
on:change=move |ev| {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Vec<u8>>(&event_target_value(&ev)) {
|
||||||
|
param_b.set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="secondary-action" on:click=set_params>"Set Params"</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
mod app;
|
||||||
|
|
||||||
|
use app::*;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
mount_to_body(|| {
|
||||||
|
view! {
|
||||||
|
<App/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
121
styles/details.css
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
.button-actions.inline-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.inline.compact {
|
||||||
|
grid-template-columns: 72px 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
background: #d8e0dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-segment.used {
|
||||||
|
background: #a64545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-segment.available {
|
||||||
|
background: #60d394;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-segment.recycled {
|
||||||
|
background: #d4b25f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #66736f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-console {
|
||||||
|
min-height: 220px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d8e0dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f4f7f5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-console div + div {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-mode-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-slider-grid,
|
||||||
|
.sensor-param-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-slider-grid,
|
||||||
|
.sensor-param-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-help pre {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d8e0dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f4f7f5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1f5c4c;
|
||||||
|
background: #dfe7e2;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #a64545;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
300
styles/panels.css
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #1f5c4c;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpi-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpi-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpi-controls .field.inline {
|
||||||
|
grid-template-columns: 72px 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #d8e0dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f4f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list div.active {
|
||||||
|
border-color: #226957;
|
||||||
|
background: #e3f3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list input {
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-admin-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-admin-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
align-content: start;
|
||||||
|
border: 1px solid #d8e0dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f4f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-admin-list div.active {
|
||||||
|
border-color: #226957;
|
||||||
|
background: #e3f3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-admin-list span {
|
||||||
|
color: #66736f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-slot-fixed {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-mapper-grid,
|
||||||
|
.category-grid,
|
||||||
|
.modifier-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-mapper-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tile,
|
||||||
|
.category-grid button {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d8e0dc;
|
||||||
|
text-align: left;
|
||||||
|
background: #f4f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tile span {
|
||||||
|
color: #66736f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tile strong,
|
||||||
|
.button-tile span,
|
||||||
|
.profile-admin-list strong,
|
||||||
|
.led-region-tabs button,
|
||||||
|
.flash-summary span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tile.active {
|
||||||
|
border-color: #226957;
|
||||||
|
background: #e3f3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-hypershift-toggle {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid button {
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #25322f;
|
||||||
|
background: #dfe7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid button.active {
|
||||||
|
color: #f8fbf9;
|
||||||
|
background: #226957;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-radio-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-toggle-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(110px, max-content) max-content 18px max-content;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-toggle-row span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-toggle-row input {
|
||||||
|
width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-region-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-region-tabs button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #25322f;
|
||||||
|
background: #dfe7e2;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-region-tabs button.active {
|
||||||
|
color: #f8fbf9;
|
||||||
|
background: #226957;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, max-content) max-content;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-option span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-option input[type="color"] {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 110px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list span,
|
||||||
|
.info-list dt {
|
||||||
|
color: #66736f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
gap: 10px 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-shell,
|
||||||
|
.panel-grid,
|
||||||
|
.dpi-editor,
|
||||||
|
.field.inline,
|
||||||
|
.info-list,
|
||||||
|
.inline-pair {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.profile-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
styles/structure.css
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
:root {
|
||||||
|
color: #18201f;
|
||||||
|
background: #eef1ee;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-synthesis: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #f4f7f5;
|
||||||
|
background: #12201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1,
|
||||||
|
.brand p,
|
||||||
|
.topbar h2,
|
||||||
|
.topbar p,
|
||||||
|
.panel h3,
|
||||||
|
.empty-state h3,
|
||||||
|
.metric {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p,
|
||||||
|
.eyebrow,
|
||||||
|
.subtle {
|
||||||
|
color: #6f7f7a;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .brand p {
|
||||||
|
color: #9baca6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action,
|
||||||
|
.secondary-action,
|
||||||
|
nav button,
|
||||||
|
.segments button {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action {
|
||||||
|
color: #09201a;
|
||||||
|
background: #60d394;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-action {
|
||||||
|
padding: 0 14px;
|
||||||
|
color: #25322f;
|
||||||
|
background: #dfe7e2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-action.danger {
|
||||||
|
color: #fff8f8;
|
||||||
|
background: #a64545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action:disabled,
|
||||||
|
nav button:disabled,
|
||||||
|
select:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav button {
|
||||||
|
color: #dfe9e4;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav button:hover,
|
||||||
|
nav button.active {
|
||||||
|
background: #223b36;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 28px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
max-width: 420px;
|
||||||
|
border: 1px solid #cbd5d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #33413d;
|
||||||
|
background: #fbfdfb;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.busy {
|
||||||
|
border-color: #d4b25f;
|
||||||
|
background: #fff7de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.inline {
|
||||||
|
grid-template-columns: minmax(120px, 1fr) 180px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span,
|
||||||
|
.profile-row > span {
|
||||||
|
color: #52605c;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #bec9c4;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #18201f;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar select {
|
||||||
|
color: #10201c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments button {
|
||||||
|
padding: 0 14px;
|
||||||
|
color: #25322f;
|
||||||
|
background: #dfe7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments button.active {
|
||||||
|
color: #f8fbf9;
|
||||||
|
background: #226957;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid #d5ddd8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fbfdfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.wide,
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3,
|
||||||
|
.empty-state h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row input {
|
||||||
|
width: 18px;
|
||||||
|
min-height: 18px;
|
||||||