fix a bunch of issues
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
/dist/
|
/dist/
|
||||||
/target/
|
/target/
|
||||||
/Cargo.lock
|
/Cargo.lock
|
||||||
|
/tmp/
|
||||||
|
|
|
||||||
115
README.md
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Razer Basilisk V3 Onboard Memory Tools</title>
|
<title>Razer Basilisk V3 Onboard Memory Tools</title>
|
||||||
<link data-trunk rel="css" href="styles/structure.css" />
|
<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/panels.css" />
|
||||||
|
|
|
||||||
BIN
public/app-icon.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4 KiB |
|
|
@ -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>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 107 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 140 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 864 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 427 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -8,10 +8,6 @@ fn BasicPanel(
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let basic = snapshot.basic.clone();
|
let basic = snapshot.basic.clone();
|
||||||
let initial_dpi_stages = basic.dpi_stages.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| {
|
let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| {
|
||||||
run_setting(
|
run_setting(
|
||||||
|
|
@ -53,8 +49,7 @@ fn BasicPanel(
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let apply_polling = move |ev: Event| {
|
let apply_polling = move |value: u8| {
|
||||||
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
|
|
||||||
run_setting(
|
run_setting(
|
||||||
"set_polling_rate",
|
"set_polling_rate",
|
||||||
PollingRateArgs {
|
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! {
|
view! {
|
||||||
<section class="panel-grid">
|
<section class="panel-grid">
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
|
|
@ -169,97 +100,186 @@ fn BasicPanel(
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<h3>"Polling"</h3>
|
<h3>"Polling"</h3>
|
||||||
<p class="subtle">{format!("Report every {} ms.", basic.polling_rate_ms)}</p>
|
<div class="polling-options" role="group" aria-label="Polling rate">
|
||||||
<label class="field inline">
|
{[(16_u8, "63 Hz"), (8, "125 Hz"), (4, "250 Hz"), (2, "500 Hz"), (1, "1000 Hz")]
|
||||||
<span>"Polling rate"</span>
|
.into_iter()
|
||||||
<select prop:value=basic.polling_rate_ms on:change=apply_polling>
|
.map(|(value, label)| view! {
|
||||||
<option value="16">"63 Hz"</option>
|
<button
|
||||||
<option value="8">"125 Hz"</option>
|
type="button"
|
||||||
<option value="4">"250 Hz"</option>
|
class:active=basic.polling_rate_ms == value
|
||||||
<option value="2">"500 Hz"</option>
|
on:click=move |_| apply_polling(value)
|
||||||
<option value="1">"1000 Hz"</option>
|
>
|
||||||
</select>
|
{label}
|
||||||
</label>
|
</button>
|
||||||
<p class="metric">{format!("{} Hz", 1000 / u16::from(basic.polling_rate_ms.max(1)))}</p>
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel wide">
|
<article class="panel wide dpi-panel">
|
||||||
<h3>"DPI"</h3>
|
<div class="dpi-card-header">
|
||||||
<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>
|
<div>
|
||||||
<span class="subtle">"Active stage"</span>
|
<h3>"DPI Stages"</h3>
|
||||||
<p class="metric">{basic.active_dpi_stage}</p>
|
<p class="subtle">"Click a stage to make it active. Edit one DPI value for normal X/Y-linked stages."</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="stage-list">
|
||||||
{basic.dpi_stages.iter().enumerate().map(|(index, stage)| {
|
{basic.dpi_stages.iter().enumerate().map(|(index, stage)| {
|
||||||
let stage_index = index;
|
let stage_index = index;
|
||||||
|
let stage_number = (stage_index + 1) as u8;
|
||||||
let label = format!("Stage {}", stage_index + 1);
|
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_x = basic.dpi_stages.clone();
|
||||||
let stage_list_for_y = 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 = basic.active_dpi_stage;
|
||||||
let active_stage_for_y = basic.active_dpi_stage;
|
let can_delete = basic.dpi_stages.len() > 1;
|
||||||
view! {
|
view! {
|
||||||
<div class:active=index + 1 == usize::from(basic.active_dpi_stage)>
|
<section
|
||||||
<span>{label}</span>
|
class="dpi-stage-card"
|
||||||
<label class="field">
|
class:active=is_active
|
||||||
<span>"X"</span>
|
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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="100"
|
||||||
max="25600"
|
max="25600"
|
||||||
step="100"
|
step="100"
|
||||||
prop:value=stage[0]
|
prop:value=stage[0]
|
||||||
|
on:click=move |ev| ev.stop_propagation()
|
||||||
on:change=move |ev| {
|
on:change=move |ev| {
|
||||||
let mut stages = stage_list_for_x.clone();
|
let mut stages = stage_list_for_linked.clone();
|
||||||
let x = event_target_value(&ev)
|
let dpi = event_target_value(&ev)
|
||||||
.parse::<u16>()
|
.parse::<u16>()
|
||||||
.unwrap_or(stages[stage_index][0]);
|
.unwrap_or(stages[stage_index][0]);
|
||||||
stages[stage_index][0] = x;
|
stages[stage_index] = [dpi, dpi];
|
||||||
apply_dpi_stages(stages, active_stage_for_x);
|
apply_dpi_stages(stages, active_stage);
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
|
||||||
<span>"Y"</span>
|
<details
|
||||||
<input
|
class="stage-advanced"
|
||||||
type="number"
|
prop:open=stage[0] != stage[1]
|
||||||
min="100"
|
on:click=move |ev| ev.stop_propagation()
|
||||||
max="25600"
|
>
|
||||||
step="100"
|
<summary>"Fine tune X/Y"</summary>
|
||||||
prop:value=stage[1]
|
<div class="inline-pair">
|
||||||
on:change=move |ev| {
|
<label class="field">
|
||||||
let mut stages = stage_list_for_y.clone();
|
<span>"X"</span>
|
||||||
let y = event_target_value(&ev)
|
<input
|
||||||
.parse::<u16>()
|
type="number"
|
||||||
.unwrap_or(stages[stage_index][1]);
|
min="100"
|
||||||
stages[stage_index][1] = y;
|
max="25600"
|
||||||
apply_dpi_stages(stages, active_stage_for_y);
|
step="100"
|
||||||
}
|
prop:value=stage[0]
|
||||||
/>
|
on:change=move |ev| {
|
||||||
</label>
|
let mut stages = stage_list_for_x.clone();
|
||||||
</div>
|
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);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
}).collect_view()}
|
}).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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -29,25 +29,24 @@ fn render_button_mapping_editor(
|
||||||
.into_any(),
|
.into_any(),
|
||||||
"mouse" => view! {
|
"mouse" => view! {
|
||||||
<div class="button-editor-grid">
|
<div class="button-editor-grid">
|
||||||
<label class="field">
|
<AppDropdown
|
||||||
<span>"Mouse function"</span>
|
label="Mouse function"
|
||||||
<select
|
selected=move || mouse_fn.clone()
|
||||||
prop:value=mouse_fn.clone()
|
disabled=move || false
|
||||||
on:change=move |ev| {
|
options=move || {
|
||||||
let value = event_target_value(&ev);
|
["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| {
|
mapping_state.update(|state| {
|
||||||
if let Some(mapping) = state.as_mut() {
|
if let Some(mapping) = state.as_mut() {
|
||||||
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
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">
|
<div class="button-radio-group">
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
|
|
|
||||||
|
|
@ -36,25 +36,32 @@ fn render_button_mapping_editor_secondary(
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<AppDropdown
|
||||||
<span>"Mode"</span>
|
label="Mode"
|
||||||
<select
|
selected={
|
||||||
prop:value=macro_mode.clone()
|
let macro_mode = macro_mode.clone();
|
||||||
on:change=move |ev| {
|
move || macro_mode.clone()
|
||||||
let value = event_target_value(&ev);
|
}
|
||||||
|
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| {
|
mapping_state.update(|state| {
|
||||||
if let Some(mapping) = state.as_mut() {
|
if let Some(mapping) = state.as_mut() {
|
||||||
payload_object_mut(mapping).insert("mode".to_string(), Value::String(value.clone()));
|
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">
|
<label class="field inline">
|
||||||
<span>"Repeat count"</span>
|
<span>"Repeat count"</span>
|
||||||
|
|
@ -79,12 +86,27 @@ fn render_button_mapping_editor_secondary(
|
||||||
.into_any(),
|
.into_any(),
|
||||||
"dpi_switch" => view! {
|
"dpi_switch" => view! {
|
||||||
<div class="button-editor-grid">
|
<div class="button-editor-grid">
|
||||||
<label class="field">
|
<AppDropdown
|
||||||
<span>"Action"</span>
|
label="Action"
|
||||||
<select
|
selected={
|
||||||
prop:value=dpi_fn.clone()
|
let dpi_fn = dpi_fn.clone();
|
||||||
on:change=move |ev| {
|
move || dpi_fn.clone()
|
||||||
let value = event_target_value(&ev);
|
}
|
||||||
|
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| {
|
mapping_state.update(|state| {
|
||||||
if let Some(mapping) = state.as_mut() {
|
if let Some(mapping) = state.as_mut() {
|
||||||
let payload = payload_object_mut(mapping);
|
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">
|
<label class="field inline">
|
||||||
<span>"Stage"</span>
|
<span>"Stage"</span>
|
||||||
|
|
@ -166,47 +180,55 @@ fn render_button_mapping_editor_secondary(
|
||||||
.into_any(),
|
.into_any(),
|
||||||
"profile_switch" => view! {
|
"profile_switch" => view! {
|
||||||
<div class="button-editor-grid">
|
<div class="button-editor-grid">
|
||||||
<label class="field">
|
<AppDropdown
|
||||||
<span>"Action"</span>
|
label="Action"
|
||||||
<select
|
selected={
|
||||||
prop:value=profile_fn.clone()
|
let profile_fn = profile_fn.clone();
|
||||||
on:change=move |ev| {
|
move || profile_fn.clone()
|
||||||
let value = event_target_value(&ev);
|
}
|
||||||
|
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| {
|
mapping_state.update(|state| {
|
||||||
if let Some(mapping) = state.as_mut() {
|
if let Some(mapping) = state.as_mut() {
|
||||||
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
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">
|
<AppDropdown
|
||||||
<span>"Fixed profile"</span>
|
label="Fixed profile"
|
||||||
<select
|
selected=move || fixed_profile.clone()
|
||||||
prop:value=fixed_profile.clone()
|
disabled={
|
||||||
disabled=profile_fn != "fixed"
|
let profile_fn = profile_fn.clone();
|
||||||
on:change=move |ev| {
|
move || profile_fn != "fixed"
|
||||||
let value = event_target_value(&ev);
|
}
|
||||||
|
options=move || {
|
||||||
|
["white", "red", "green", "blue", "cyan"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| SelectChoice::new(name, name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
on_select=move |value| {
|
||||||
mapping_state.update(|state| {
|
mapping_state.update(|state| {
|
||||||
if let Some(mapping) = state.as_mut() {
|
if let Some(mapping) = state.as_mut() {
|
||||||
payload_object_mut(mapping).insert("profile".to_string(), Value::String(value.clone()));
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
.into_any(),
|
.into_any(),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[component]
|
||||||
fn LedPanel(
|
fn LedPanel(
|
||||||
snapshot: DeviceState,
|
snapshot: DeviceState,
|
||||||
|
|
@ -8,10 +55,14 @@ fn LedPanel(
|
||||||
let demo_mode = snapshot.device.path.starts_with("demo://");
|
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||||
let led_state = RwSignal::new(None::<LedState>);
|
let led_state = RwSignal::new(None::<LedState>);
|
||||||
let selected_region = RwSignal::new("wheel".to_string());
|
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| {
|
let load_led_state = move |profile: String| {
|
||||||
if demo_mode {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
set_busy.set(true);
|
set_busy.set(true);
|
||||||
|
|
@ -19,6 +70,7 @@ fn LedPanel(
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
|
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
|
remember_static_led_colors(static_color_memory, &state);
|
||||||
led_state.set(Some(state));
|
led_state.set(Some(state));
|
||||||
set_status.set("LED settings loaded.".to_string());
|
set_status.set("LED settings loaded.".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -39,12 +91,15 @@ fn LedPanel(
|
||||||
let Some(state) = led_state.get_untracked() else {
|
let Some(state) = led_state.get_untracked() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let source_name = selected_region.get_untracked();
|
||||||
let Some(source_region) = state
|
let Some(source_region) = state
|
||||||
.regions
|
.regions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|region| region.region == selected_region.get_untracked())
|
.find(|region| region.region == source_name)
|
||||||
|
.or_else(|| state.regions.first())
|
||||||
.cloned()
|
.cloned()
|
||||||
else {
|
else {
|
||||||
|
set_status.set("No LED regions are available.".to_string());
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if demo_mode {
|
if demo_mode {
|
||||||
|
|
@ -56,6 +111,7 @@ fn LedPanel(
|
||||||
region.colors = source_region.colors.clone();
|
region.colors = source_region.colors.clone();
|
||||||
region.brightness = source_region.brightness;
|
region.brightness = source_region.brightness;
|
||||||
}
|
}
|
||||||
|
remember_static_led_colors(static_color_memory, &next_state);
|
||||||
led_state.set(Some(next_state));
|
led_state.set(Some(next_state));
|
||||||
set_status.set("Updated LED demo state.".to_string());
|
set_status.set("Updated LED demo state.".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +131,7 @@ fn LedPanel(
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
|
remember_static_led_colors(static_color_memory, &state);
|
||||||
led_state.set(Some(state));
|
led_state.set(Some(state));
|
||||||
set_status.set("LED settings applied to all regions.".to_string());
|
set_status.set("LED settings applied to all regions.".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -86,210 +143,366 @@ fn LedPanel(
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<section class="panel-grid">
|
<section class="panel-grid">
|
||||||
<article class="panel wide">
|
<article class="panel wide led-panel">
|
||||||
<h3>"LED"</h3>
|
|
||||||
{move || {
|
{move || {
|
||||||
let Some(state) = led_state.get() else {
|
let Some(state) = led_state.get() else {
|
||||||
return view! { <p>"Loading LED settings..."</p> }.into_any();
|
return view! { <p>"Loading LED settings..."</p> }.into_any();
|
||||||
};
|
};
|
||||||
|
|
||||||
let led_profile_label = state.profile.clone();
|
if state.regions.is_empty() {
|
||||||
let active_region_name = selected_region.get();
|
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
|
let active_region = state
|
||||||
.regions
|
.regions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|region| region.region == active_region_name)
|
.find(|region| region.region == selected_region_name)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| state.regions[0].clone());
|
.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 effect = active_region.effect.clone();
|
||||||
let mode = active_region.mode;
|
let mode = active_region.mode;
|
||||||
let speed = active_region.speed;
|
let speed = active_region.speed;
|
||||||
let colors = active_region.colors.clone();
|
let colors = active_region.colors.clone();
|
||||||
let brightness = active_region.brightness;
|
let brightness = active_region.brightness;
|
||||||
let static_hex = colors
|
let effect_key = led_effect_key(&effect, mode);
|
||||||
.first()
|
let static_color = led_static_color(&active_region_id, &colors, static_color_memory);
|
||||||
.map(color_to_hex)
|
let static_hex = color_to_hex(&static_color);
|
||||||
.unwrap_or_else(|| "#ffffff".to_string());
|
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! {
|
view! {
|
||||||
<p class="subtle">{format!("Editing LED settings for profile {led_profile_label}")}</p>
|
<div class="led-panel-header">
|
||||||
<div class="led-region-tabs">
|
<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| {
|
{state.regions.iter().map(|region| {
|
||||||
let region_name = region.region.clone();
|
let region_name = region.region.clone();
|
||||||
let region_name_active = region_name.clone();
|
let region_click = region_name.clone();
|
||||||
let region_name_click = region_name.clone();
|
let region_is_active = region_name == active_region_id;
|
||||||
|
let region_label = led_region_label(®ion.region);
|
||||||
|
let swatch = led_region_swatch(region);
|
||||||
view! {
|
view! {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active=move || selected_region.get() == region_name_active
|
role="tab"
|
||||||
on:click=move |_| selected_region.set(region_name_click.clone())
|
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>
|
</button>
|
||||||
}
|
}
|
||||||
}).collect_view()}
|
}).collect_view()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="led-options">
|
<div class="led-section">
|
||||||
<label class="led-option">
|
<h4>"Effect"</h4>
|
||||||
<input
|
<div class="led-effect-grid" role="group" aria-label="LED effect">
|
||||||
type="radio"
|
{LED_EFFECT_CHOICES.into_iter().map(|choice| {
|
||||||
prop:checked=effect == "off" || effect == "disabled"
|
let is_active = effect_key == choice.key;
|
||||||
on:change={
|
let region = active_region_id.clone();
|
||||||
let region = region_id.clone();
|
let current_speed = control_speed;
|
||||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "off".to_string(), 0, 0, vec![])
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
}).collect_view()}
|
||||||
<span>"Disabled"</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="led-option">
|
{if effect == "custom" {
|
||||||
<input
|
view! {
|
||||||
type="radio"
|
<button type="button" class="active" disabled=true>"Custom"</button>
|
||||||
prop:checked=effect == "static"
|
}.into_any()
|
||||||
on:change={
|
} else {
|
||||||
let region = region_id.clone();
|
view! {}.into_any()
|
||||||
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]])
|
}}
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
|
||||||
<div class="slider-row">
|
<div class="led-controls">
|
||||||
<span>"Speed"</span>
|
{if show_color {
|
||||||
<input
|
view! {
|
||||||
type="number"
|
<label class="led-color-control">
|
||||||
min="0"
|
<span>"Color"</span>
|
||||||
max="255"
|
<input
|
||||||
prop:value=speed
|
type="color"
|
||||||
disabled=effect != "spectrum" && effect != "wave"
|
prop:value=static_hex.clone()
|
||||||
on:change={
|
on:change={
|
||||||
let region = region_id.clone();
|
let region = active_region_id.clone();
|
||||||
let effect_name = effect.clone();
|
move |ev| {
|
||||||
let effect_colors = colors.clone();
|
let color = hex_to_rgb(&event_target_value(&ev));
|
||||||
move |ev| {
|
static_color_memory.update(|items| {
|
||||||
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed);
|
items.insert(region.clone(), color);
|
||||||
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());
|
});
|
||||||
}
|
apply_led_effect_change(
|
||||||
}
|
demo_mode,
|
||||||
/>
|
led_state,
|
||||||
<input
|
selected_profile,
|
||||||
type="range"
|
set_status,
|
||||||
min="0"
|
set_busy,
|
||||||
max="255"
|
region.clone(),
|
||||||
prop:value=speed
|
"static".to_string(),
|
||||||
disabled=effect != "spectrum" && effect != "wave"
|
0,
|
||||||
on:change={
|
0,
|
||||||
let region = region_id.clone();
|
vec![color],
|
||||||
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>{static_hex.to_uppercase()}</strong>
|
||||||
}
|
</label>
|
||||||
}
|
}.into_any()
|
||||||
/>
|
} else {
|
||||||
</div>
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
<div class="slider-row">
|
{if show_speed {
|
||||||
<span>"Brightness"</span>
|
view! {
|
||||||
<input
|
<div class="led-slider-row">
|
||||||
type="number"
|
<div class="led-slider-label">
|
||||||
min="0"
|
<span>"Speed"</span>
|
||||||
max="255"
|
<strong>{control_speed}</strong>
|
||||||
prop:value=brightness
|
</div>
|
||||||
on:change={
|
<input
|
||||||
let region = region_id.clone();
|
type="range"
|
||||||
move |ev| {
|
min="0"
|
||||||
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
max="255"
|
||||||
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
prop:value=control_speed
|
||||||
}
|
on:change={
|
||||||
}
|
let region = active_region_id.clone();
|
||||||
/>
|
let effect_name = effect.clone();
|
||||||
<input
|
let effect_colors = colors.clone();
|
||||||
type="range"
|
move |ev| {
|
||||||
min="0"
|
let next_speed = parse_led_u8(&event_target_value(&ev), control_speed);
|
||||||
max="255"
|
apply_led_effect_change(
|
||||||
prop:value=brightness
|
demo_mode,
|
||||||
on:change={
|
led_state,
|
||||||
let region = region_id.clone();
|
selected_profile,
|
||||||
move |ev| {
|
set_status,
|
||||||
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
set_busy,
|
||||||
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
region.clone(),
|
||||||
}
|
effect_name.clone(),
|
||||||
}
|
mode,
|
||||||
/>
|
next_speed,
|
||||||
</div>
|
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()
|
||||||
|
}}
|
||||||
|
|
||||||
<button type="button" class="secondary-action" on:click=apply_to_all>
|
{if show_brightness {
|
||||||
"Apply To All Regions"
|
view! {
|
||||||
</button>
|
<div class="led-slider-row">
|
||||||
|
<div class="led-slider-label">
|
||||||
|
<span>"Brightness"</span>
|
||||||
|
<strong>{brightness}</strong>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}}
|
}}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,22 +315,19 @@ fn MacroPanel(
|
||||||
<h3>"Macros"</h3>
|
<h3>"Macros"</h3>
|
||||||
<h4>"Edit Individual Macros"</h4>
|
<h4>"Edit Individual Macros"</h4>
|
||||||
<div class="macro-toolbar">
|
<div class="macro-toolbar">
|
||||||
<label class="field inline">
|
<AppDropdown
|
||||||
<span>"Select existing"</span>
|
label="Select existing"
|
||||||
<select
|
selected=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
|
||||||
prop:value=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
|
disabled=move || false
|
||||||
on:change=move |ev| {
|
options=move || {
|
||||||
let value = event_target_value(&ev);
|
let mut options = vec![SelectChoice::new("", "(none)")];
|
||||||
selected_macro_id.set(value.parse::<u16>().ok());
|
options.extend(macro_list.get().into_iter().map(|macro_id| {
|
||||||
}
|
SelectChoice::new(macro_id.to_string(), format!("0x{macro_id:04x}"))
|
||||||
>
|
}));
|
||||||
<option value="">"(none)"</option>
|
options
|
||||||
{move || macro_list.get().into_iter().map(|macro_id| {
|
}
|
||||||
let label = format!("0x{macro_id:04x}");
|
on_select=move |value| selected_macro_id.set(value.parse::<u16>().ok())
|
||||||
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" on:click=load_selected>"Load"</button>
|
||||||
<button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button>
|
<button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,6 @@ struct PollingRateArgs {
|
||||||
polling_rate_ms: u8,
|
polling_rate_ms: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct DpiArgs {
|
|
||||||
profile: String,
|
|
||||||
x: u16,
|
|
||||||
y: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DpiStagesArgs {
|
struct DpiStagesArgs {
|
||||||
|
|
|
||||||
236
src/app/root.rs
|
|
@ -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]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
let devices = RwSignal::new(Vec::<DeviceSummary>::new());
|
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 selected_profile = RwSignal::new("direct".to_string());
|
||||||
let device_state = RwSignal::new(None::<DeviceState>);
|
let device_state = RwSignal::new(None::<DeviceState>);
|
||||||
let active_tab = RwSignal::new("basic".to_string());
|
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);
|
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| {
|
let connect_path = move |path: String| {
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
status.set("Select a device path before connecting.".to_string());
|
status.set("Select a device path before connecting.".to_string());
|
||||||
|
|
@ -89,7 +274,7 @@ pub fn App() -> impl IntoView {
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="public/snakemouse.svg" alt="" />
|
<img src="public/app-icon.png" alt="" />
|
||||||
<div>
|
<div>
|
||||||
<h1>"Razer Basilisk V3"</h1>
|
<h1>"Razer Basilisk V3"</h1>
|
||||||
<p>"Onboard memory tools"</p>
|
<p>"Onboard memory tools"</p>
|
||||||
|
|
@ -100,15 +285,13 @@ pub fn App() -> impl IntoView {
|
||||||
"Scan"
|
"Scan"
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<label class="field">
|
<AppDropdown
|
||||||
<span>"Device"</span>
|
label="Device"
|
||||||
<select
|
selected=move || selected_path.get()
|
||||||
prop:value=move || selected_path.get()
|
disabled=move || busy.get()
|
||||||
on:change=move |ev| selected_path.set(event_target_value(&ev))
|
options=move || {
|
||||||
disabled=move || busy.get()
|
let mut options = vec![SelectChoice::new("", "No supported device selected")];
|
||||||
>
|
options.extend(devices.get().into_iter().map(|device| {
|
||||||
<option value="">"No supported device selected"</option>
|
|
||||||
{move || devices.get().into_iter().map(|device| {
|
|
||||||
let label = format!(
|
let label = format!(
|
||||||
"{} - {} ({:04x}:{:04x}){}",
|
"{} - {} ({:04x}:{:04x}){}",
|
||||||
device.path,
|
device.path,
|
||||||
|
|
@ -121,12 +304,12 @@ pub fn App() -> impl IntoView {
|
||||||
.map(|serial| format!(" - {serial}"))
|
.map(|serial| format!(" - {serial}"))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
);
|
);
|
||||||
view! {
|
SelectChoice::new(device.path, label)
|
||||||
<option value=device.path.clone()>{label}</option>
|
}));
|
||||||
}
|
options
|
||||||
}).collect_view()}
|
}
|
||||||
</select>
|
on_select=move |value| selected_path.set(value)
|
||||||
</label>
|
/>
|
||||||
|
|
||||||
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
|
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
|
||||||
"Connect"
|
"Connect"
|
||||||
|
|
@ -148,9 +331,26 @@ pub fn App() -> impl IntoView {
|
||||||
<p class="eyebrow">"Linux desktop configuration"</p>
|
<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>
|
<h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" class:busy=move || busy.get()>{move || status.get()}</div>
|
|
||||||
</header>
|
</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() {
|
{move || match device_state.get() {
|
||||||
Some(snapshot) => view! {
|
Some(snapshot) => view! {
|
||||||
<ConnectedView
|
<ConnectedView
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.macro-toolbar .app-select-field {
|
||||||
|
min-width: min(260px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.field.inline.compact {
|
.field.inline.compact {
|
||||||
grid-template-columns: 72px 140px;
|
grid-template-columns: 72px 140px;
|
||||||
}
|
}
|
||||||
|
|
@ -27,26 +31,26 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 16px 0 10px;
|
margin: 16px 0 10px;
|
||||||
background: #d8e0dc;
|
background: var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-segment.used {
|
.flash-segment.used {
|
||||||
background: #a64545;
|
background: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-segment.available {
|
.flash-segment.available {
|
||||||
background: #60d394;
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-segment.recycled {
|
.flash-segment.recycled {
|
||||||
background: #d4b25f;
|
background: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-summary {
|
.flash-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: #66736f;
|
color: var(--color-small-text);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
@ -56,10 +60,10 @@
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d8e0dc;
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #f4f7f5;
|
background: var(--color-panel-subtle);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
|
@ -90,10 +94,10 @@
|
||||||
|
|
||||||
.macro-help pre {
|
.macro-help pre {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d8e0dc;
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #f4f7f5;
|
background: var(--color-panel-subtle);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +110,8 @@
|
||||||
height: 18px;
|
height: 18px;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #1f5c4c;
|
color: var(--color-metric);
|
||||||
background: #dfe7e2;
|
background: var(--color-secondary-bg);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
|
@ -115,7 +119,7 @@
|
||||||
|
|
||||||
.field-error {
|
.field-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #a64545;
|
color: var(--color-danger);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,185 @@
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: #1f5c4c;
|
color: var(--color-metric);
|
||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dpi-editor {
|
.polling-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
align-items: end;
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
align-items: end;
|
align-items: flex-start;
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dpi-controls .field.inline {
|
.dpi-card-header h3 {
|
||||||
grid-template-columns: 72px 110px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-list {
|
.stage-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-list div {
|
.dpi-stage-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 10px;
|
||||||
border: 1px solid #d8e0dc;
|
align-content: start;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
background: #f4f7f5;
|
background: var(--color-panel-subtle);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-list div.active {
|
.dpi-add-stage {
|
||||||
border-color: #226957;
|
display: grid;
|
||||||
background: #e3f3ec;
|
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 {
|
.stage-list input {
|
||||||
|
|
@ -62,19 +198,19 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
border: 1px solid #d8e0dc;
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #f4f7f5;
|
background: var(--color-panel-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-admin-list div.active {
|
.profile-admin-list div.active {
|
||||||
border-color: #226957;
|
border-color: var(--color-active);
|
||||||
background: #e3f3ec;
|
background: var(--color-active-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-admin-list span {
|
.profile-admin-list span {
|
||||||
color: #66736f;
|
color: var(--color-small-text);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,13 +242,13 @@
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #d8e0dc;
|
border: 1px solid var(--color-border-subtle);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #f4f7f5;
|
background: var(--color-panel-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-tile span {
|
.button-tile span {
|
||||||
color: #66736f;
|
color: var(--color-small-text);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +261,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-tile.active {
|
.button-tile.active {
|
||||||
border-color: #226957;
|
border-color: var(--color-active);
|
||||||
background: #e3f3ec;
|
background: var(--color-active-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-hypershift-toggle {
|
.button-hypershift-toggle {
|
||||||
|
|
@ -140,13 +276,13 @@
|
||||||
|
|
||||||
.category-grid button {
|
.category-grid button {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: #25322f;
|
color: var(--color-secondary-text);
|
||||||
background: #dfe7e2;
|
background: var(--color-secondary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-grid button.active {
|
.category-grid button.active {
|
||||||
color: #f8fbf9;
|
color: var(--color-active-text);
|
||||||
background: #226957;
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-editor-grid {
|
.button-editor-grid {
|
||||||
|
|
@ -192,71 +328,150 @@
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.led-region-tabs {
|
.led-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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;
|
gap: 8px;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.led-region-tabs button {
|
.led-region-tabs button {
|
||||||
min-height: 38px;
|
display: grid;
|
||||||
padding: 8px 14px;
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
border-radius: 6px;
|
gap: 10px;
|
||||||
color: #25322f;
|
|
||||||
background: #dfe7e2;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
min-height: 42px;
|
||||||
text-align: center;
|
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 {
|
.led-region-tabs button.active {
|
||||||
color: #f8fbf9;
|
border-color: var(--color-active);
|
||||||
background: #226957;
|
background: var(--color-active-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.led-options {
|
.led-region-tabs strong {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
gap: 10px;
|
overflow: hidden;
|
||||||
margin-bottom: 18px;
|
text-overflow: ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.led-option input[type="color"] {
|
.led-region-swatch {
|
||||||
width: 52px;
|
width: 14px;
|
||||||
min-width: 52px;
|
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;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-row {
|
.led-color-control strong {
|
||||||
display: grid;
|
color: var(--color-small-text);
|
||||||
grid-template-columns: 90px 110px minmax(0, 1fr);
|
font-size: 0.82rem;
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.led-slider-row input[type="number"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stage-list span,
|
.stage-list span,
|
||||||
.info-list dt {
|
.info-list dt {
|
||||||
color: #66736f;
|
color: var(--color-small-text);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
@ -285,14 +500,16 @@
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.panel-grid,
|
.panel-grid,
|
||||||
.dpi-editor,
|
|
||||||
.field.inline,
|
.field.inline,
|
||||||
.info-list,
|
.info-list,
|
||||||
.inline-pair {
|
.inline-pair,
|
||||||
|
.led-color-control,
|
||||||
|
.led-slider-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
|
.led-panel-header,
|
||||||
.profile-row {
|
.profile-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,39 @@
|
||||||
:root {
|
:root {
|
||||||
color: #18201f;
|
color-scheme: light;
|
||||||
background: #eef1ee;
|
--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-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
|
|
@ -10,6 +43,43 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
@ -42,8 +112,8 @@ button {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
color: #f4f7f5;
|
color: var(--color-sidebar-text);
|
||||||
background: #12201d;
|
background: var(--color-sidebar-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
|
|
@ -76,12 +146,12 @@ button {
|
||||||
.brand p,
|
.brand p,
|
||||||
.eyebrow,
|
.eyebrow,
|
||||||
.subtle {
|
.subtle {
|
||||||
color: #6f7f7a;
|
color: var(--color-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .brand p {
|
.sidebar .brand p {
|
||||||
color: #9baca6;
|
color: var(--color-sidebar-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-action,
|
.primary-action,
|
||||||
|
|
@ -94,24 +164,25 @@ nav button,
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-action {
|
.primary-action {
|
||||||
color: #09201a;
|
color: var(--color-primary-text);
|
||||||
background: #60d394;
|
background: var(--color-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-action {
|
.secondary-action {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
color: #25322f;
|
color: var(--color-secondary-text);
|
||||||
background: #dfe7e2;
|
background: var(--color-secondary-bg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-action.danger {
|
.secondary-action.danger {
|
||||||
color: #fff8f8;
|
color: var(--color-danger-text);
|
||||||
background: #a64545;
|
background: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-action:disabled,
|
.primary-action:disabled,
|
||||||
|
.secondary-action:disabled,
|
||||||
nav button:disabled,
|
nav button:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -125,7 +196,7 @@ nav {
|
||||||
}
|
}
|
||||||
|
|
||||||
nav button {
|
nav button {
|
||||||
color: #dfe9e4;
|
color: var(--color-nav-text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
|
@ -133,10 +204,11 @@ nav button {
|
||||||
|
|
||||||
nav button:hover,
|
nav button:hover,
|
||||||
nav button.active {
|
nav button.active {
|
||||||
background: #223b36;
|
background: var(--color-nav-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
@ -159,19 +231,50 @@ nav button.active {
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.toast-region {
|
||||||
max-width: 420px;
|
position: fixed;
|
||||||
border: 1px solid #cbd5d0;
|
top: 20px;
|
||||||
border-radius: 6px;
|
right: 24px;
|
||||||
padding: 10px 12px;
|
z-index: 20;
|
||||||
color: #33413d;
|
display: grid;
|
||||||
background: #fbfdfb;
|
width: min(420px, calc(100vw - 48px));
|
||||||
overflow-wrap: anywhere;
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.busy {
|
.toast {
|
||||||
border-color: #d4b25f;
|
display: grid;
|
||||||
background: #fff7de;
|
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 {
|
.field {
|
||||||
|
|
@ -186,7 +289,7 @@ nav button.active {
|
||||||
|
|
||||||
.field span,
|
.field span,
|
||||||
.profile-row > span {
|
.profile-row > span {
|
||||||
color: #52605c;
|
color: var(--color-label);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
@ -196,11 +299,121 @@ textarea,
|
||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border: 1px solid #bec9c4;
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
color: #18201f;
|
color: var(--color-text);
|
||||||
background: #ffffff;
|
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 {
|
textarea {
|
||||||
|
|
@ -210,7 +423,15 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar select {
|
.sidebar select {
|
||||||
color: #10201c;
|
color: var(--color-sidebar-input-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
select,
|
||||||
|
option,
|
||||||
|
optgroup {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-row {
|
.profile-row {
|
||||||
|
|
@ -228,13 +449,13 @@ textarea {
|
||||||
|
|
||||||
.segments button {
|
.segments button {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
color: #25322f;
|
color: var(--color-secondary-text);
|
||||||
background: #dfe7e2;
|
background: var(--color-secondary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segments button.active {
|
.segments button.active {
|
||||||
color: #f8fbf9;
|
color: var(--color-active-text);
|
||||||
background: #226957;
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-grid {
|
.panel-grid {
|
||||||
|
|
@ -245,10 +466,10 @@ textarea {
|
||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px solid #d5ddd8;
|
border: 1px solid var(--color-panel-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #fbfdfb;
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.wide,
|
.panel.wide,
|
||||||
|
|
|
||||||