fix a bunch of issues

This commit is contained in:
Alexander Daichendt 2026-06-06 18:22:16 +02:00
parent 91f88dc9ef
commit 2c3df28c48
66 changed files with 1546 additions and 542 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/dist/
/target/
/Cargo.lock
/tmp/

115
README.md
View file

@ -1,7 +1,114 @@
# Tauri + Leptos
# Razer Linux Desktop
This template should help get you started developing with Tauri and Leptos.
A Tauri + Leptos desktop app for configuring supported Razer devices on Linux.
## Recommended IDE Setup
## CachyOS / Arch 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).
Install the Tauri Linux build dependencies:
```sh
sudo pacman -S --needed \
webkit2gtk-4.1 \
base-devel \
curl \
wget \
file \
openssl \
appmenu-gtk-module \
libappindicator-gtk3 \
librsvg \
xdotool
```
Make sure the Rust Tauri CLI and Trunk are available:
```sh
cargo install tauri-cli --locked
cargo install trunk --locked
```
## Build
From this directory:
```sh
NO_STRIP=true cargo tauri build
```
`NO_STRIP=true` avoids a known AppImage bundling failure on rolling Linux
distributions where linuxdeploy's bundled `strip` can fail on newer ELF
sections.
The AppImage is written under:
```text
target/release/bundle/appimage/
```
## Install Desktop Launcher
This installs the built AppImage into a stable per-user path and creates a
desktop launcher. The launcher continues to work after updates because the
AppImage is always copied to the same filename.
```sh
appimage="$(find target/release/bundle/appimage -maxdepth 1 -name 'razer-linux-desktop_*_amd64.AppImage' -print -quit)"
mkdir -p "$HOME/.local/opt/razer-linux-desktop"
mkdir -p "$HOME/.local/share/applications"
mkdir -p "$HOME/.local/share/icons/hicolor/128x128/apps"
install -m 0755 "$appimage" \
"$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage"
install -m 0644 src-tauri/icons/128x128.png \
"$HOME/.local/share/icons/hicolor/128x128/apps/one.daichendt.razer-linux-desktop.png"
cat > "$HOME/.local/share/applications/one.daichendt.razer-linux-desktop.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Razer Linux Desktop
Comment=Configure Razer devices on Linux
Exec=$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage
Icon=one.daichendt.razer-linux-desktop
Terminal=false
Categories=Settings;HardwareSettings;
StartupNotify=true
EOF
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
kbuildsycoca6 2>/dev/null || true
```
After this, the app should appear in the desktop environment launcher as
`Razer Linux Desktop`.
## Update Installed App
Pull the latest source, rebuild, and replace the stable AppImage:
```sh
git pull --ff-only
NO_STRIP=true cargo tauri build
appimage="$(find target/release/bundle/appimage -maxdepth 1 -name 'razer-linux-desktop_*_amd64.AppImage' -print -quit)"
install -m 0755 "$appimage" \
"$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage"
```
The desktop launcher does not need to be recreated unless the launcher metadata
or icon changes.
## Uninstall
```sh
rm -f "$HOME/.local/share/applications/one.daichendt.razer-linux-desktop.desktop"
rm -f "$HOME/.local/share/icons/hicolor/128x128/apps/one.daichendt.razer-linux-desktop.png"
rm -rf "$HOME/.local/opt/razer-linux-desktop"
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
kbuildsycoca6 2>/dev/null || true
```

View file

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<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" />

BIN
public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -8,10 +8,6 @@ fn BasicPanel(
) -> 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(
@ -53,8 +49,7 @@ fn BasicPanel(
);
};
let apply_polling = move |ev: Event| {
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
let apply_polling = move |value: u8| {
run_setting(
"set_polling_rate",
PollingRateArgs {
@ -67,70 +62,6 @@ fn BasicPanel(
);
};
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">
@ -169,58 +100,120 @@ fn BasicPanel(
<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>
<div class="polling-options" role="group" aria-label="Polling rate">
{[(16_u8, "63 Hz"), (8, "125 Hz"), (4, "250 Hz"), (2, "500 Hz"), (1, "1000 Hz")]
.into_iter()
.map(|(value, label)| view! {
<button
type="button"
class:active=basic.polling_rate_ms == value
on:click=move |_| apply_polling(value)
>
{label}
</button>
})
.collect_view()}
</div>
</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>
<article class="panel wide dpi-panel">
<div class="dpi-card-header">
<div>
<span class="subtle">"Active stage"</span>
<p class="metric">{basic.active_dpi_stage}</p>
<h3>"DPI Stages"</h3>
<p class="subtle">"Click a stage to make it active. Edit one DPI value for normal X/Y-linked stages."</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 stage_number = (stage_index + 1) as u8;
let label = format!("Stage {}", stage_index + 1);
let is_active = index + 1 == usize::from(basic.active_dpi_stage);
let stage_list_for_select = basic.dpi_stages.clone();
let stage_list_for_delete = basic.dpi_stages.clone();
let stage_list_for_linked = basic.dpi_stages.clone();
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;
let active_stage = basic.active_dpi_stage;
let can_delete = basic.dpi_stages.len() > 1;
view! {
<div class:active=index + 1 == usize::from(basic.active_dpi_stage)>
<section
class="dpi-stage-card"
class:active=is_active
on:click=move |_| apply_dpi_stages(stage_list_for_select.clone(), stage_number)
>
<div class="dpi-stage-card-header">
<span>{label}</span>
{if is_active {
view! { <strong>"Active"</strong> }.into_any()
} else {
view! {}.into_any()
}}
{if can_delete {
view! {
<button
type="button"
class="stage-delete"
aria-label=format!("Delete stage {}", stage_number)
title="Delete stage"
on:click=move |ev| {
ev.stop_propagation();
let mut stages = stage_list_for_delete.clone();
stages.remove(stage_index);
let next_len = stages.len() as u8;
let next_active = if active_stage == stage_number {
stage_number.min(next_len)
} else if active_stage > stage_number {
active_stage.saturating_sub(1)
} else {
active_stage
}
.max(1);
apply_dpi_stages(stages, next_active);
}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M6 6l1 18h10l1-18" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<label class="field" on:click=move |ev| ev.stop_propagation()>
<span>"DPI"</span>
<input
type="number"
min="100"
max="25600"
step="100"
prop:value=stage[0]
on:click=move |ev| ev.stop_propagation()
on:change=move |ev| {
let mut stages = stage_list_for_linked.clone();
let dpi = event_target_value(&ev)
.parse::<u16>()
.unwrap_or(stages[stage_index][0]);
stages[stage_index] = [dpi, dpi];
apply_dpi_stages(stages, active_stage);
}
/>
</label>
<details
class="stage-advanced"
prop:open=stage[0] != stage[1]
on:click=move |ev| ev.stop_propagation()
>
<summary>"Fine tune X/Y"</summary>
<div class="inline-pair">
<label class="field">
<span>"X"</span>
<input
@ -235,7 +228,7 @@ fn BasicPanel(
.parse::<u16>()
.unwrap_or(stages[stage_index][0]);
stages[stage_index][0] = x;
apply_dpi_stages(stages, active_stage_for_x);
apply_dpi_stages(stages, active_stage);
}
/>
</label>
@ -253,13 +246,40 @@ fn BasicPanel(
.parse::<u16>()
.unwrap_or(stages[stage_index][1]);
stages[stage_index][1] = y;
apply_dpi_stages(stages, active_stage_for_y);
apply_dpi_stages(stages, active_stage);
}
/>
</label>
</div>
</details>
</section>
}
}).collect_view()}
{
let can_add = basic.dpi_stages.len() < 5;
let stage_template = initial_dpi_stages.clone();
let last_stage = stage_template.last().copied().unwrap_or([800, 800]);
let next_dpi = last_stage[0].max(last_stage[1]).saturating_add(400).min(25600);
view! {
<button
type="button"
class="dpi-add-stage"
class:disabled=!can_add
disabled=!can_add
on:click=move |_| {
if !can_add {
return;
}
let mut stages = stage_template.clone();
stages.push([next_dpi, next_dpi]);
apply_dpi_stages(stages.clone(), stages.len() as u8);
}
>
<span>"+"</span>
<strong>{if can_add { "Add stage" } else { "Max 5 stages" }}</strong>
</button>
}
}
</div>
</article>
</section>

View file

@ -29,25 +29,24 @@ fn render_button_mapping_editor(
.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);
<AppDropdown
label="Mouse function"
selected=move || mouse_fn.clone()
disabled=move || false
options=move || {
["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
.into_iter()
.map(|name| SelectChoice::new(name, name))
.collect()
}
on_select=move |value| {
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">

View file

@ -36,25 +36,32 @@ fn render_button_mapping_editor_secondary(
/>
</label>
<label class="field">
<span>"Mode"</span>
<select
prop:value=macro_mode.clone()
on:change=move |ev| {
let value = event_target_value(&ev);
<AppDropdown
label="Mode"
selected={
let macro_mode = macro_mode.clone();
move || macro_mode.clone()
}
disabled=move || false
options=move || {
[
("macro_fixed", "Fixed repeat"),
("macro_hold", "Hold"),
("macro_toggle", "Toggle"),
("macro_sequence", "Sequence"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
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>
@ -79,12 +86,27 @@ fn render_button_mapping_editor_secondary(
.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);
<AppDropdown
label="Action"
selected={
let dpi_fn = dpi_fn.clone();
move || dpi_fn.clone()
}
disabled=move || false
options=move || {
[
("next", "Next"),
("prev", "Previous"),
("next_loop", "Next (loop)"),
("prev_loop", "Previous (loop)"),
("fixed", "Fixed stage"),
("aim", "Aim DPI"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() {
let payload = payload_object_mut(mapping);
@ -92,15 +114,7 @@ fn render_button_mapping_editor_secondary(
}
});
}
>
<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>
@ -166,47 +180,55 @@ fn render_button_mapping_editor_secondary(
.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);
<AppDropdown
label="Action"
selected={
let profile_fn = profile_fn.clone();
move || profile_fn.clone()
}
disabled=move || false
options=move || {
[
("next", "Next"),
("prev", "Previous"),
("next_loop", "Next (loop)"),
("prev_loop", "Previous (loop)"),
("fixed", "Fixed profile"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
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);
<AppDropdown
label="Fixed profile"
selected=move || fixed_profile.clone()
disabled={
let profile_fn = profile_fn.clone();
move || profile_fn != "fixed"
}
options=move || {
["white", "red", "green", "blue", "cyan"]
.into_iter()
.map(|name| SelectChoice::new(name, name))
.collect()
}
on_select=move |value| {
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(),

View file

@ -1,3 +1,50 @@
#[derive(Clone, Copy)]
struct LedEffectChoice {
key: &'static str,
label: &'static str,
effect: &'static str,
mode: u8,
}
const LED_EFFECT_CHOICES: [LedEffectChoice; 6] = [
LedEffectChoice {
key: "off",
label: "Off",
effect: "off",
mode: 0,
},
LedEffectChoice {
key: "static",
label: "Static",
effect: "static",
mode: 0,
},
LedEffectChoice {
key: "spectrum",
label: "Spectrum",
effect: "spectrum",
mode: 1,
},
LedEffectChoice {
key: "wave_fixed",
label: "Wave fixed",
effect: "wave",
mode: 0,
},
LedEffectChoice {
key: "wave_clockwise",
label: "Wave clockwise",
effect: "wave",
mode: 1,
},
LedEffectChoice {
key: "wave_counter",
label: "Wave counter",
effect: "wave",
mode: 2,
},
];
#[component]
fn LedPanel(
snapshot: DeviceState,
@ -8,10 +55,14 @@ fn LedPanel(
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 static_color_memory =
RwSignal::new(std::collections::BTreeMap::<String, [u8; 3]>::new());
let load_led_state = move |profile: String| {
if demo_mode {
led_state.set(Some(mock_led_state(profile)));
let state = mock_led_state(profile);
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state));
return;
}
set_busy.set(true);
@ -19,6 +70,7 @@ fn LedPanel(
spawn_local(async move {
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
Ok(state) => {
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state));
set_status.set("LED settings loaded.".to_string());
}
@ -39,12 +91,15 @@ fn LedPanel(
let Some(state) = led_state.get_untracked() else {
return;
};
let source_name = selected_region.get_untracked();
let Some(source_region) = state
.regions
.iter()
.find(|region| region.region == selected_region.get_untracked())
.find(|region| region.region == source_name)
.or_else(|| state.regions.first())
.cloned()
else {
set_status.set("No LED regions are available.".to_string());
return;
};
if demo_mode {
@ -56,6 +111,7 @@ fn LedPanel(
region.colors = source_region.colors.clone();
region.brightness = source_region.brightness;
}
remember_static_led_colors(static_color_memory, &next_state);
led_state.set(Some(next_state));
set_status.set("Updated LED demo state.".to_string());
}
@ -75,6 +131,7 @@ fn LedPanel(
.await
{
Ok(state) => {
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state));
set_status.set("LED settings applied to all regions.".to_string());
}
@ -86,210 +143,366 @@ fn LedPanel(
view! {
<section class="panel-grid">
<article class="panel wide">
<h3>"LED"</h3>
<article class="panel wide led-panel">
{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();
if state.regions.is_empty() {
return view! {
<div class="led-panel-header">
<div>
<h3>"LED"</h3>
<p class="subtle">{format!("Profile {}", state.profile)}</p>
</div>
</div>
<p class="subtle">"No LED regions reported by this device."</p>
}.into_any();
}
let selected_region_name = selected_region.get();
let active_region = state
.regions
.iter()
.find(|region| region.region == active_region_name)
.find(|region| region.region == selected_region_name)
.cloned()
.unwrap_or_else(|| state.regions[0].clone());
let region_id = active_region.region.clone();
let active_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());
let effect_key = led_effect_key(&effect, mode);
let static_color = led_static_color(&active_region_id, &colors, static_color_memory);
let static_hex = color_to_hex(&static_color);
let control_speed = led_control_speed(speed);
let show_color = effect == "static";
let show_speed = led_effect_has_speed(&effect);
let show_brightness = effect != "off" && effect != "disabled";
view! {
<p class="subtle">{format!("Editing LED settings for profile {led_profile_label}")}</p>
<div class="led-region-tabs">
<div class="led-panel-header">
<div>
<h3>"LED"</h3>
<p class="subtle">{format!("Profile {}", state.profile)}</p>
</div>
<button
type="button"
class="secondary-action"
disabled=state.regions.len() < 2
on:click=apply_to_all
>
"Apply to all"
</button>
</div>
<div class="led-region-tabs" role="tablist" aria-label="LED regions">
{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();
let region_click = region_name.clone();
let region_is_active = region_name == active_region_id;
let region_label = led_region_label(&region.region);
let swatch = led_region_swatch(region);
view! {
<button
type="button"
class:active=move || selected_region.get() == region_name_active
on:click=move |_| selected_region.set(region_name_click.clone())
role="tab"
class:active=region_is_active
aria-selected=region_is_active.to_string()
on:click=move |_| selected_region.set(region_click.clone())
>
{region.region.clone()}
<span class="led-region-swatch" style=format!("background: {swatch};")></span>
<strong>{region_label}</strong>
</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![])
<div class="led-section">
<h4>"Effect"</h4>
<div class="led-effect-grid" role="group" aria-label="LED effect">
{LED_EFFECT_CHOICES.into_iter().map(|choice| {
let is_active = effect_key == choice.key;
let region = active_region_id.clone();
let current_speed = control_speed;
let color = static_color;
view! {
<button
type="button"
class:active=is_active
on:click=move |_| {
let colors = if choice.effect == "static" {
vec![color]
} else {
vec![]
};
let speed = if led_effect_has_speed(choice.effect) {
current_speed
} else {
0
};
apply_led_effect_change(
demo_mode,
led_state,
selected_profile,
set_status,
set_busy,
region.clone(),
choice.effect.to_string(),
choice.mode,
speed,
colors,
);
}
/>
<span>"Disabled"</span>
</label>
>
{choice.label}
</button>
}
}).collect_view()}
<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>
{if effect == "custom" {
view! {
<button type="button" class="active" disabled=true>"Custom"</button>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
</div>
<div class="led-controls">
{if show_color {
view! {
<label class="led-color-control">
<span>"Color"</span>
<input
type="color"
prop:value=static_hex
disabled=effect != "static"
prop:value=static_hex.clone()
on:change={
let region = region_id.clone();
let region = active_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]);
static_color_memory.update(|items| {
items.insert(region.clone(), color);
});
apply_led_effect_change(
demo_mode,
led_state,
selected_profile,
set_status,
set_busy,
region.clone(),
"static".to_string(),
0,
0,
vec![color],
);
}
}
/>
<strong>{static_hex.to_uppercase()}</strong>
</label>
}.into_any()
} else {
view! {}.into_any()
}}
<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">
{if show_speed {
view! {
<div class="led-slider-row">
<div class="led-slider-label">
<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());
}
}
/>
<strong>{control_speed}</strong>
</div>
<input
type="range"
min="0"
max="255"
prop:value=speed
disabled=effect != "spectrum" && effect != "wave"
prop:value=control_speed
on:change={
let region = region_id.clone();
let region = active_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());
let next_speed = parse_led_u8(&event_target_value(&ev), control_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="number"
min="0"
max="255"
prop:value=control_speed
on:change={
let region = active_region_id.clone();
let effect_name = effect.clone();
let effect_colors = colors.clone();
move |ev| {
let next_speed = parse_led_u8(&event_target_value(&ev), control_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>
}.into_any()
} else {
view! {}.into_any()
}}
<div class="slider-row">
{if show_brightness {
view! {
<div class="led-slider-row">
<div class="led-slider-label">
<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);
}
}
/>
<strong>{brightness}</strong>
</div>
<input
type="range"
min="0"
max="255"
prop:value=brightness
on:change={
let region = region_id.clone();
let region = active_region_id.clone();
move |ev| {
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
let next_brightness = parse_led_u8(&event_target_value(&ev), brightness);
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
}
}
/>
<input
type="number"
min="0"
max="255"
prop:value=brightness
on:change={
let region = active_region_id.clone();
move |ev| {
let next_brightness = parse_led_u8(&event_target_value(&ev), 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()
} else {
view! {}.into_any()
}}
</div>
}.into_any()
}}
</article>
</section>
}
}
fn remember_static_led_colors(
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
state: &LedState,
) {
memory.update(|items| {
for region in &state.regions {
if region.effect == "static" {
if let Some(color) = region.colors.first() {
items.insert(region.region.clone(), *color);
}
}
}
});
}
fn led_static_color(
region: &str,
colors: &[[u8; 3]],
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
) -> [u8; 3] {
colors
.first()
.copied()
.or_else(|| memory.get_untracked().get(region).copied())
.unwrap_or([105, 230, 162])
}
fn led_region_label(region: &str) -> String {
match region {
"wheel" => "Wheel".to_string(),
"logo" => "Logo".to_string(),
"strip" => "Underglow".to_string(),
other => other.replace('_', " "),
}
}
fn led_effect_key(effect: &str, mode: u8) -> String {
match (effect, mode) {
("off", _) | ("disabled", _) => "off".to_string(),
("static", _) => "static".to_string(),
("spectrum", _) => "spectrum".to_string(),
("wave", 0) => "wave_fixed".to_string(),
("wave", 1) => "wave_clockwise".to_string(),
("wave", 2) => "wave_counter".to_string(),
("custom", _) => "custom".to_string(),
_ => effect.to_string(),
}
}
fn led_effect_has_speed(effect: &str) -> bool {
effect == "spectrum" || effect == "wave"
}
fn led_control_speed(speed: u8) -> u8 {
if speed == 0 {
180
} else {
speed
}
}
fn parse_led_u8(value: &str, fallback: u8) -> u8 {
value
.parse::<u16>()
.map(|value| value.min(u16::from(u8::MAX)) as u8)
.unwrap_or(fallback)
}
fn led_region_swatch(region: &LedRegionState) -> String {
if region.effect == "static" {
return region
.colors
.first()
.map(color_to_hex)
.unwrap_or_else(|| "#69e6a2".to_string());
}
match region.effect.as_str() {
"off" | "disabled" => "var(--color-border-subtle)".to_string(),
"spectrum" => "linear-gradient(90deg, #ff4d6d, #ffd166, #69e6a2, #4cc9f0, #b983ff)"
.to_string(),
"wave" => "linear-gradient(90deg, #69e6a2, #4cc9f0)".to_string(),
_ => "var(--color-metric)".to_string(),
}
}

View file

@ -315,22 +315,19 @@ fn MacroPanel(
<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());
<AppDropdown
label="Select existing"
selected=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
disabled=move || false
options=move || {
let mut options = vec![SelectChoice::new("", "(none)")];
options.extend(macro_list.get().into_iter().map(|macro_id| {
SelectChoice::new(macro_id.to_string(), format!("0x{macro_id:04x}"))
}));
options
}
>
<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>
on_select=move |value| selected_macro_id.set(value.parse::<u16>().ok())
/>
<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>

View file

@ -90,13 +90,6 @@ struct PollingRateArgs {
polling_rate_ms: u8,
}
#[derive(Serialize)]
struct DpiArgs {
profile: String,
x: u16,
y: u16,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DpiStagesArgs {

View file

@ -1,3 +1,161 @@
#[derive(Clone)]
struct SelectChoice {
value: String,
label: String,
}
impl SelectChoice {
fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
}
#[component]
fn AppDropdown<OptionsFn, SelectedFn, DisabledFn, OnSelectFn>(
label: &'static str,
selected: SelectedFn,
options: OptionsFn,
disabled: DisabledFn,
on_select: OnSelectFn,
) -> impl IntoView
where
OptionsFn: Fn() -> Vec<SelectChoice> + Clone + Send + 'static,
SelectedFn: Fn() -> String + Clone + Send + 'static,
DisabledFn: Fn() -> bool + Clone + Send + 'static,
OnSelectFn: Fn(String) + Clone + Send + 'static,
{
let open = RwSignal::new(false);
let selected_for_label = selected.clone();
let options_for_label = options.clone();
let disabled_for_field = disabled.clone();
let disabled_for_trigger = disabled.clone();
let disabled_for_click_away = disabled.clone();
let disabled_for_menu = disabled.clone();
let selected_label = move || {
let selected_value = selected_for_label();
options_for_label()
.into_iter()
.find(|choice| choice.value == selected_value)
.map(|choice| choice.label)
.unwrap_or_else(|| {
if selected_value.is_empty() {
"None".to_string()
} else {
selected_value
}
})
};
view! {
<div class="field app-select-field" class:disabled=move || disabled_for_field()>
<span>{label}</span>
<div class="app-select" class:open=move || open.get()>
{move || {
if open.get() && !disabled_for_click_away() {
view! {
<button
type="button"
class="app-select-click-away"
aria-label="Close dropdown"
tabindex="-1"
on:click=move |_| open.set(false)
/>
}
.into_any()
} else {
view! {}.into_any()
}
}}
<button
type="button"
class="app-select-trigger"
aria-haspopup="listbox"
aria-expanded=move || open.get().to_string()
disabled=move || disabled_for_trigger()
on:click={
let disabled = disabled.clone();
move |_| {
if !disabled() {
open.update(|is_open| *is_open = !*is_open);
}
}
}
on:keydown={
let disabled = disabled.clone();
move |ev: KeyboardEvent| {
let key = ev.key();
match key.as_str() {
"Escape" => open.set(false),
"Enter" | " " | "ArrowDown" => {
ev.prevent_default();
if !disabled() {
open.set(true);
}
}
_ => {}
}
}
}
>
<span>{selected_label}</span>
<span class="app-select-arrow" aria-hidden="true">"v"</span>
</button>
{move || {
if !open.get() || disabled_for_menu() {
return view! {}.into_any();
}
let selected_value = selected();
view! {
<div class="app-select-menu" role="listbox">
{options().into_iter().map(|choice| {
let is_selected = choice.value == selected_value;
let value = choice.value.clone();
let on_select = on_select.clone();
view! {
<button
type="button"
role="option"
class:active=is_selected
on:click=move |_| {
on_select(value.clone());
open.set(false);
}
>
{choice.label}
</button>
}
}).collect_view()}
</div>
}.into_any()
}}
</div>
</div>
}
}
#[derive(Clone)]
struct ToastMessage {
id: u64,
message: String,
busy: bool,
}
fn should_show_toast(message: &str) -> bool {
let normalized = message.trim().to_ascii_lowercase();
!(normalized.starts_with("loading ")
|| normalized.starts_with("reloading ")
|| normalized.starts_with("writing ")
|| normalized.ends_with(" loaded.")
|| normalized.ends_with(" reloaded."))
}
#[component]
pub fn App() -> impl IntoView {
let devices = RwSignal::new(Vec::<DeviceSummary>::new());
@ -5,9 +163,36 @@ pub fn App() -> impl IntoView {
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 status = RwSignal::new(String::new());
let toasts = RwSignal::new(Vec::<ToastMessage>::new());
let next_toast_id = RwSignal::new(0_u64);
let busy = RwSignal::new(false);
Effect::new(move |_| {
let message = status.get();
if message.trim().is_empty() || !should_show_toast(&message) {
return;
}
let id = next_toast_id.get_untracked();
let busy_now = busy.get_untracked();
next_toast_id.set(id + 1);
toasts.update(|items| {
items.push(ToastMessage {
id,
message,
busy: busy_now,
});
});
set_timeout(
move || {
toasts.update(|items| items.retain(|item| item.id != id));
},
std::time::Duration::from_millis(if busy_now { 3000 } else { 4500 }),
);
});
let connect_path = move |path: String| {
if path.is_empty() {
status.set("Select a device path before connecting.".to_string());
@ -89,7 +274,7 @@ pub fn App() -> impl IntoView {
<main class="app-shell">
<aside class="sidebar">
<div class="brand">
<img src="public/snakemouse.svg" alt="" />
<img src="public/app-icon.png" alt="" />
<div>
<h1>"Razer Basilisk V3"</h1>
<p>"Onboard memory tools"</p>
@ -100,15 +285,13 @@ pub fn App() -> impl IntoView {
"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))
<AppDropdown
label="Device"
selected=move || selected_path.get()
disabled=move || busy.get()
>
<option value="">"No supported device selected"</option>
{move || devices.get().into_iter().map(|device| {
options=move || {
let mut options = vec![SelectChoice::new("", "No supported device selected")];
options.extend(devices.get().into_iter().map(|device| {
let label = format!(
"{} - {} ({:04x}:{:04x}){}",
device.path,
@ -121,12 +304,12 @@ pub fn App() -> impl IntoView {
.map(|serial| format!(" - {serial}"))
.unwrap_or_default()
);
view! {
<option value=device.path.clone()>{label}</option>
SelectChoice::new(device.path, label)
}));
options
}
}).collect_view()}
</select>
</label>
on_select=move |value| selected_path.set(value)
/>
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
"Connect"
@ -148,9 +331,26 @@ pub fn App() -> impl IntoView {
<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>
<div class="toast-region" aria-live="polite" aria-atomic="false">
{move || toasts.get().into_iter().map(|toast| {
let toast_id = toast.id;
view! {
<div class="toast" class:busy=toast.busy>
<span>{toast.message}</span>
<button
type="button"
aria-label="Dismiss notification"
on:click=move |_| toasts.update(|items| items.retain(|item| item.id != toast_id))
>
"x"
</button>
</div>
}
}).collect_view()}
</div>
{move || match device_state.get() {
Some(snapshot) => view! {
<ConnectedView

View file

@ -10,6 +10,10 @@
margin-bottom: 16px;
}
.macro-toolbar .app-select-field {
min-width: min(260px, 100%);
}
.field.inline.compact {
grid-template-columns: 72px 140px;
}
@ -27,26 +31,26 @@
overflow: hidden;
border-radius: 6px;
margin: 16px 0 10px;
background: #d8e0dc;
background: var(--color-border-subtle);
}
.flash-segment.used {
background: #a64545;
background: var(--color-danger);
}
.flash-segment.available {
background: #60d394;
background: var(--color-primary);
}
.flash-segment.recycled {
background: #d4b25f;
background: var(--color-warning);
}
.flash-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #66736f;
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
margin-bottom: 12px;
@ -56,10 +60,10 @@
min-height: 220px;
max-height: 320px;
overflow: auto;
border: 1px solid #d8e0dc;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 10px;
background: #f4f7f5;
background: var(--color-panel-subtle);
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
@ -90,10 +94,10 @@
.macro-help pre {
overflow: auto;
border: 1px solid #d8e0dc;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 10px;
background: #f4f7f5;
background: var(--color-panel-subtle);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
@ -106,8 +110,8 @@
height: 18px;
margin-left: 6px;
border-radius: 999px;
color: #1f5c4c;
background: #dfe7e2;
color: var(--color-metric);
background: var(--color-secondary-bg);
font-size: 0.75rem;
font-weight: 700;
cursor: help;
@ -115,7 +119,7 @@
.field-error {
margin: 0;
color: #a64545;
color: var(--color-danger);
font-size: 0.82rem;
font-weight: 600;
}

View file

@ -2,49 +2,185 @@
.metric {
margin-top: 12px;
color: #1f5c4c;
color: var(--color-metric);
font-size: 1.45rem;
font-weight: 800;
}
.dpi-editor {
.polling-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: end;
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 8px;
margin-top: 14px;
}
.dpi-controls {
.polling-options button,
.led-effect-grid button {
min-height: 42px;
border-radius: 6px;
padding: 8px 12px;
color: var(--color-secondary-text);
background: var(--color-secondary-bg);
cursor: pointer;
font-weight: 700;
text-align: center;
}
.polling-options button:hover,
.polling-options button.active,
.led-effect-grid button:hover,
.led-effect-grid button.active {
color: var(--color-active-text);
background: var(--color-active);
}
.dpi-panel {
display: grid;
gap: 18px;
}
.dpi-card-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
margin-top: 16px;
gap: 16px;
align-items: flex-start;
}
.dpi-controls .field.inline {
grid-template-columns: 72px 110px;
.dpi-card-header h3 {
margin-bottom: 6px;
}
.stage-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px;
margin-top: 18px;
}
.stage-list div {
.dpi-stage-card {
display: grid;
gap: 4px;
border: 1px solid #d8e0dc;
gap: 10px;
align-content: start;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 10px;
background: #f4f7f5;
padding: 12px;
background: var(--color-panel-subtle);
cursor: pointer;
}
.stage-list div.active {
border-color: #226957;
background: #e3f3ec;
.dpi-add-stage {
display: grid;
min-height: 156px;
place-items: center;
align-content: center;
gap: 6px;
border: 1px dashed var(--color-border-subtle);
border-radius: 6px;
padding: 12px;
color: var(--color-secondary-text);
background: transparent;
cursor: pointer;
}
.dpi-add-stage:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dpi-add-stage span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 999px;
color: var(--color-primary-text);
background: var(--color-primary);
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.dpi-add-stage strong {
font-size: 0.9rem;
}
.dpi-stage-card:hover,
.dpi-stage-card:focus-visible,
.dpi-add-stage:hover,
.dpi-add-stage:focus-visible {
border-color: var(--color-active);
}
.dpi-stage-card.active {
border-color: var(--color-active);
background: var(--color-active-surface);
}
.dpi-stage-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.dpi-stage-card-header span {
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
}
.dpi-stage-card-header strong {
color: var(--color-metric);
font-size: 0.95rem;
}
.stage-delete {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
margin-left: auto;
border-radius: 6px;
color: var(--color-danger);
background: transparent;
cursor: pointer;
opacity: 0;
}
.dpi-stage-card:hover .stage-delete,
.dpi-stage-card:focus-within .stage-delete {
opacity: 1;
}
.stage-delete:hover,
.stage-delete:focus-visible {
color: var(--color-danger-text);
background: var(--color-danger);
}
.stage-delete svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.stage-advanced {
display: grid;
gap: 8px;
}
.stage-advanced summary {
color: var(--color-small-text);
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
}
.stage-advanced .inline-pair {
margin-top: 8px;
}
.stage-list input {
@ -62,19 +198,19 @@
display: grid;
gap: 6px;
align-content: start;
border: 1px solid #d8e0dc;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 12px;
background: #f4f7f5;
background: var(--color-panel-subtle);
}
.profile-admin-list div.active {
border-color: #226957;
background: #e3f3ec;
border-color: var(--color-active);
background: var(--color-active-surface);
}
.profile-admin-list span {
color: #66736f;
color: var(--color-small-text);
font-size: 0.78rem;
}
@ -106,13 +242,13 @@
gap: 4px;
align-content: start;
padding: 12px;
border: 1px solid #d8e0dc;
border: 1px solid var(--color-border-subtle);
text-align: left;
background: #f4f7f5;
background: var(--color-panel-subtle);
}
.button-tile span {
color: #66736f;
color: var(--color-small-text);
font-size: 0.78rem;
}
@ -125,8 +261,8 @@
}
.button-tile.active {
border-color: #226957;
background: #e3f3ec;
border-color: var(--color-active);
background: var(--color-active-surface);
}
.button-hypershift-toggle {
@ -140,13 +276,13 @@
.category-grid button {
padding: 0 12px;
color: #25322f;
background: #dfe7e2;
color: var(--color-secondary-text);
background: var(--color-secondary-bg);
}
.category-grid button.active {
color: #f8fbf9;
background: #226957;
color: var(--color-active-text);
background: var(--color-active);
}
.button-editor-grid {
@ -192,71 +328,150 @@
min-height: 18px;
}
.led-region-tabs {
.led-panel {
display: grid;
gap: 18px;
}
.led-panel-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.led-panel-header h3 {
margin-bottom: 4px;
}
.led-region-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
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;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 10px;
align-items: center;
white-space: nowrap;
text-align: center;
min-height: 42px;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 8px 10px;
color: var(--color-secondary-text);
background: var(--color-panel-subtle);
cursor: pointer;
text-align: left;
}
.led-region-tabs button.active {
color: #f8fbf9;
background: #226957;
border-color: var(--color-active);
background: var(--color-active-surface);
}
.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 {
.led-region-tabs strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.led-option input[type="color"] {
width: 52px;
min-width: 52px;
.led-region-swatch {
width: 14px;
height: 14px;
border: 1px solid var(--color-input-border);
border-radius: 999px;
}
.led-section h4,
.led-slider-label span,
.led-color-control span {
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
}
.led-slider-label strong {
font-size: 0.95rem;
}
.led-section {
display: grid;
gap: 10px;
}
.led-section h4 {
margin: 0;
}
.led-effect-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 8px;
}
.led-effect-grid button {
min-height: 44px;
}
.led-effect-grid button:disabled {
cursor: default;
opacity: 1;
}
.led-controls {
display: grid;
gap: 12px;
}
.led-color-control {
display: grid;
grid-template-columns: 120px 72px minmax(92px, 1fr);
gap: 12px;
align-items: center;
}
.led-color-control input[type="color"] {
width: 64px;
min-width: 64px;
height: 42px;
min-height: 42px;
padding: 3px;
}
.slider-row {
display: grid;
grid-template-columns: 90px 110px minmax(0, 1fr);
gap: 12px;
align-items: center;
margin-bottom: 12px;
.led-color-control strong {
color: var(--color-small-text);
font-size: 0.82rem;
}
.slider-row input[type="range"] {
.led-slider-row {
display: grid;
grid-template-columns: 120px minmax(180px, 1fr) 92px;
gap: 12px;
align-items: center;
}
.led-slider-label {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
}
.led-slider-row input[type="range"] {
width: 100%;
}
.led-slider-row input[type="number"] {
min-width: 0;
}
.stage-list span,
.info-list dt {
color: #66736f;
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
}
@ -285,14 +500,16 @@
@media (max-width: 760px) {
.app-shell,
.panel-grid,
.dpi-editor,
.field.inline,
.info-list,
.inline-pair {
.inline-pair,
.led-color-control,
.led-slider-row {
grid-template-columns: 1fr;
}
.topbar,
.led-panel-header,
.profile-row {
align-items: stretch;
flex-direction: column;

View file

@ -1,6 +1,39 @@
:root {
color: #18201f;
background: #eef1ee;
color-scheme: light;
--color-text: #18201f;
--color-page: #eef1ee;
--color-sidebar-text: #f4f7f5;
--color-sidebar-bg: #12201d;
--color-muted: #6f7f7a;
--color-sidebar-muted: #9baca6;
--color-primary-text: #09201a;
--color-primary: #60d394;
--color-secondary-text: #25322f;
--color-secondary-bg: #dfe7e2;
--color-danger-text: #fff8f8;
--color-danger: #a64545;
--color-nav-text: #dfe9e4;
--color-nav-hover: #223b36;
--color-status-border: #cbd5d0;
--color-status-text: #33413d;
--color-surface: #fbfdfb;
--color-warning: #d4b25f;
--color-warning-bg: #fff7de;
--color-label: #52605c;
--color-input-border: #bec9c4;
--color-input-bg: #ffffff;
--color-sidebar-input-text: #10201c;
--color-active-text: #f8fbf9;
--color-active: #226957;
--color-panel-border: #d5ddd8;
--color-border-subtle: #d8e0dc;
--color-panel-subtle: #f4f7f5;
--color-active-surface: #e3f3ec;
--color-metric: #1f5c4c;
--color-small-text: #66736f;
color: var(--color-text);
background: var(--color-page);
accent-color: var(--color-primary);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-synthesis: none;
@ -10,6 +43,43 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-text: #e4ece8;
--color-page: #0f1514;
--color-sidebar-text: #f3faf6;
--color-sidebar-bg: #0b1715;
--color-muted: #9fb2ab;
--color-sidebar-muted: #9eb2ab;
--color-primary-text: #06130f;
--color-primary: #69e6a2;
--color-secondary-text: #dce7e2;
--color-secondary-bg: #22302d;
--color-danger-text: #fff8f8;
--color-danger: #d05a5a;
--color-nav-text: #d8e7e1;
--color-nav-hover: #1b332f;
--color-status-border: #37504a;
--color-status-text: #d8e7e1;
--color-surface: #161f1d;
--color-warning: #e1bd66;
--color-warning-bg: #3a2f14;
--color-label: #b3c2bd;
--color-input-border: #435750;
--color-input-bg: #101917;
--color-sidebar-input-text: #e6f0ec;
--color-active-text: #f8fbf9;
--color-active: #2f967c;
--color-panel-border: #2a3a36;
--color-border-subtle: #344942;
--color-panel-subtle: #1b2925;
--color-active-surface: #17392f;
--color-metric: #7ce3b4;
--color-small-text: #9badb0;
}
}
* {
box-sizing: border-box;
}
@ -42,8 +112,8 @@ button {
flex-direction: column;
gap: 18px;
padding: 24px;
color: #f4f7f5;
background: #12201d;
color: var(--color-sidebar-text);
background: var(--color-sidebar-bg);
}
.brand {
@ -76,12 +146,12 @@ button {
.brand p,
.eyebrow,
.subtle {
color: #6f7f7a;
color: var(--color-muted);
font-size: 0.82rem;
}
.sidebar .brand p {
color: #9baca6;
color: var(--color-sidebar-muted);
}
.primary-action,
@ -94,24 +164,25 @@ nav button,
}
.primary-action {
color: #09201a;
background: #60d394;
color: var(--color-primary-text);
background: var(--color-primary);
font-weight: 700;
}
.secondary-action {
padding: 0 14px;
color: #25322f;
background: #dfe7e2;
color: var(--color-secondary-text);
background: var(--color-secondary-bg);
font-weight: 600;
}
.secondary-action.danger {
color: #fff8f8;
background: #a64545;
color: var(--color-danger-text);
background: var(--color-danger);
}
.primary-action:disabled,
.secondary-action:disabled,
nav button:disabled,
select:disabled {
cursor: not-allowed;
@ -125,7 +196,7 @@ nav {
}
nav button {
color: #dfe9e4;
color: var(--color-nav-text);
background: transparent;
text-align: left;
padding: 0 12px;
@ -133,10 +204,11 @@ nav button {
nav button:hover,
nav button.active {
background: #223b36;
background: var(--color-nav-hover);
}
.workspace {
position: relative;
min-width: 0;
padding: 28px;
overflow-x: hidden;
@ -159,19 +231,50 @@ nav button.active {
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;
.toast-region {
position: fixed;
top: 20px;
right: 24px;
z-index: 20;
display: grid;
width: min(420px, calc(100vw - 48px));
gap: 10px;
pointer-events: none;
}
.status.busy {
border-color: #d4b25f;
background: #fff7de;
.toast {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 12px;
align-items: start;
border: 1px solid var(--color-status-border);
border-radius: 6px;
padding: 10px 12px;
color: var(--color-status-text);
background: var(--color-surface);
box-shadow: 0 12px 32px rgb(0 0 0 / 18%);
overflow-wrap: anywhere;
pointer-events: auto;
}
.toast.busy {
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.toast button {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: inherit;
background: transparent;
cursor: pointer;
}
.toast button:hover {
background: var(--color-secondary-bg);
}
.field {
@ -186,7 +289,7 @@ nav button.active {
.field span,
.profile-row > span {
color: #52605c;
color: var(--color-label);
font-size: 0.85rem;
font-weight: 700;
}
@ -196,11 +299,121 @@ textarea,
select {
width: 100%;
min-height: 38px;
border: 1px solid #bec9c4;
border: 1px solid var(--color-input-border);
border-radius: 6px;
padding: 0 10px;
color: #18201f;
background: #ffffff;
color: var(--color-text);
background: var(--color-input-bg);
}
.app-select-field {
min-width: 0;
}
.app-select {
position: relative;
width: 100%;
}
.app-select-click-away {
position: fixed;
inset: 0;
z-index: 29;
border-radius: 0;
padding: 0;
background: transparent;
cursor: default;
}
.app-select-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
gap: 10px;
align-items: center;
width: 100%;
min-height: 38px;
border: 1px solid var(--color-input-border);
border-radius: 6px;
padding: 0 10px;
color: var(--color-text);
background: var(--color-input-bg);
cursor: pointer;
text-align: left;
}
.app-select.open .app-select-trigger {
position: relative;
z-index: 31;
}
.sidebar .app-select-trigger {
color: var(--color-sidebar-input-text);
}
.app-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-select-trigger span {
min-width: 0;
overflow: hidden;
color: inherit;
font-size: 0.9rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-select-trigger .app-select-arrow {
color: var(--color-label);
font-size: 0.75rem;
text-align: right;
}
.app-select-menu {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
right: 0;
left: 0;
display: grid;
max-height: 260px;
overflow: auto;
border: 1px solid var(--color-input-border);
border-radius: 6px;
padding: 4px;
background: var(--color-input-bg);
box-shadow: 0 16px 32px rgb(0 0 0 / 24%);
}
.app-select-menu button {
min-height: 36px;
border-radius: 4px;
padding: 8px 10px;
color: var(--color-text);
background: transparent;
cursor: pointer;
overflow-wrap: anywhere;
text-align: left;
}
.app-select-menu button:hover,
.app-select-menu button.active {
color: var(--color-active-text);
background: var(--color-active);
}
select,
option,
optgroup {
color-scheme: light dark;
}
option,
optgroup {
color: var(--color-text);
background: var(--color-input-bg);
}
textarea {
@ -210,7 +423,15 @@ textarea {
}
.sidebar select {
color: #10201c;
color: var(--color-sidebar-input-text);
}
@media (prefers-color-scheme: dark) {
select,
option,
optgroup {
color-scheme: dark;
}
}
.profile-row {
@ -228,13 +449,13 @@ textarea {
.segments button {
padding: 0 14px;
color: #25322f;
background: #dfe7e2;
color: var(--color-secondary-text);
background: var(--color-secondary-bg);
}
.segments button.active {
color: #f8fbf9;
background: #226957;
color: var(--color-active-text);
background: var(--color-active);
}
.panel-grid {
@ -245,10 +466,10 @@ textarea {
.panel,
.empty-state {
border: 1px solid #d5ddd8;
border: 1px solid var(--color-panel-border);
border-radius: 8px;
padding: 20px;
background: #fbfdfb;
background: var(--color-surface);
}
.panel.wide,