fix a bunch of issues
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
/dist/
|
||||
/target/
|
||||
/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>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Razer Basilisk V3 Onboard Memory Tools</title>
|
||||
<link data-trunk rel="css" href="styles/structure.css" />
|
||||
<link data-trunk rel="css" href="styles/panels.css" />
|
||||
|
|
|
|||
BIN
public/app-icon.png
Normal file
|
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 {
|
||||
let basic = snapshot.basic.clone();
|
||||
let initial_dpi_stages = basic.dpi_stages.clone();
|
||||
let initial_active_stage = basic.active_dpi_stage;
|
||||
let stage_list_for_count = initial_dpi_stages.clone();
|
||||
let stage_list_for_active = initial_dpi_stages.clone();
|
||||
let stage_list_for_copy = initial_dpi_stages.clone();
|
||||
|
||||
let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| {
|
||||
run_setting(
|
||||
|
|
@ -53,8 +49,7 @@ fn BasicPanel(
|
|||
);
|
||||
};
|
||||
|
||||
let apply_polling = move |ev: Event| {
|
||||
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
|
||||
let apply_polling = move |value: u8| {
|
||||
run_setting(
|
||||
"set_polling_rate",
|
||||
PollingRateArgs {
|
||||
|
|
@ -67,70 +62,6 @@ fn BasicPanel(
|
|||
);
|
||||
};
|
||||
|
||||
let set_dpi_x = move |ev: Event| {
|
||||
let x = event_target_value(&ev)
|
||||
.parse::<u16>()
|
||||
.unwrap_or(basic.dpi_xy[0]);
|
||||
run_setting(
|
||||
"set_dpi_xy",
|
||||
DpiArgs {
|
||||
profile: selected_profile.get_untracked(),
|
||||
x,
|
||||
y: basic.dpi_xy[1],
|
||||
},
|
||||
set_snapshot,
|
||||
set_status,
|
||||
set_busy,
|
||||
);
|
||||
};
|
||||
|
||||
let set_dpi_y = move |ev: Event| {
|
||||
let y = event_target_value(&ev)
|
||||
.parse::<u16>()
|
||||
.unwrap_or(basic.dpi_xy[1]);
|
||||
run_setting(
|
||||
"set_dpi_xy",
|
||||
DpiArgs {
|
||||
profile: selected_profile.get_untracked(),
|
||||
x: basic.dpi_xy[0],
|
||||
y,
|
||||
},
|
||||
set_snapshot,
|
||||
set_status,
|
||||
set_busy,
|
||||
);
|
||||
};
|
||||
|
||||
let set_stage_count = move |ev: Event| {
|
||||
let requested = event_target_value(&ev)
|
||||
.parse::<usize>()
|
||||
.unwrap_or(stage_list_for_count.len())
|
||||
.clamp(1, 5);
|
||||
let mut stages = stage_list_for_count.clone();
|
||||
stages.truncate(requested);
|
||||
while stages.len() < requested {
|
||||
stages.push([800, 800]);
|
||||
}
|
||||
let active_stage = initial_active_stage.min(requested as u8).max(1);
|
||||
apply_dpi_stages(stages, active_stage);
|
||||
};
|
||||
|
||||
let set_active_stage = move |ev: Event| {
|
||||
let active_stage = event_target_value(&ev)
|
||||
.parse::<u8>()
|
||||
.unwrap_or(initial_active_stage)
|
||||
.clamp(1, stage_list_for_active.len() as u8);
|
||||
apply_dpi_stages(stage_list_for_active.clone(), active_stage);
|
||||
};
|
||||
|
||||
let copy_stage_y_from_x = move |_| {
|
||||
let stages = stage_list_for_copy
|
||||
.iter()
|
||||
.map(|[x, _]| [*x, *x])
|
||||
.collect::<Vec<_>>();
|
||||
apply_dpi_stages(stages, initial_active_stage);
|
||||
};
|
||||
|
||||
view! {
|
||||
<section class="panel-grid">
|
||||
<article class="panel">
|
||||
|
|
@ -169,58 +100,120 @@ fn BasicPanel(
|
|||
|
||||
<article class="panel">
|
||||
<h3>"Polling"</h3>
|
||||
<p class="subtle">{format!("Report every {} ms.", basic.polling_rate_ms)}</p>
|
||||
<label class="field inline">
|
||||
<span>"Polling rate"</span>
|
||||
<select prop:value=basic.polling_rate_ms on:change=apply_polling>
|
||||
<option value="16">"63 Hz"</option>
|
||||
<option value="8">"125 Hz"</option>
|
||||
<option value="4">"250 Hz"</option>
|
||||
<option value="2">"500 Hz"</option>
|
||||
<option value="1">"1000 Hz"</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="metric">{format!("{} Hz", 1000 / u16::from(basic.polling_rate_ms.max(1)))}</p>
|
||||
<div class="polling-options" role="group" aria-label="Polling rate">
|
||||
{[(16_u8, "63 Hz"), (8, "125 Hz"), (4, "250 Hz"), (2, "500 Hz"), (1, "1000 Hz")]
|
||||
.into_iter()
|
||||
.map(|(value, label)| view! {
|
||||
<button
|
||||
type="button"
|
||||
class:active=basic.polling_rate_ms == value
|
||||
on:click=move |_| apply_polling(value)
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel wide">
|
||||
<h3>"DPI"</h3>
|
||||
<div class="dpi-editor">
|
||||
<label class="field">
|
||||
<span>"Current X"</span>
|
||||
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[0] on:change=set_dpi_x />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>"Current Y"</span>
|
||||
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[1] on:change=set_dpi_y />
|
||||
</label>
|
||||
<article class="panel wide dpi-panel">
|
||||
<div class="dpi-card-header">
|
||||
<div>
|
||||
<span class="subtle">"Active stage"</span>
|
||||
<p class="metric">{basic.active_dpi_stage}</p>
|
||||
<h3>"DPI Stages"</h3>
|
||||
<p class="subtle">"Click a stage to make it active. Edit one DPI value for normal X/Y-linked stages."</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dpi-controls">
|
||||
<label class="field inline">
|
||||
<span>"Stages"</span>
|
||||
<input type="number" min="1" max="5" prop:value=basic.dpi_stages.len() on:change=set_stage_count />
|
||||
</label>
|
||||
<label class="field inline">
|
||||
<span>"Active"</span>
|
||||
<input type="number" min="1" max="5" prop:value=basic.active_dpi_stage on:change=set_active_stage />
|
||||
</label>
|
||||
<button type="button" class="secondary-action" on:click=copy_stage_y_from_x>"Y = X"</button>
|
||||
</div>
|
||||
|
||||
<div class="stage-list">
|
||||
{basic.dpi_stages.iter().enumerate().map(|(index, stage)| {
|
||||
let stage_index = index;
|
||||
let stage_number = (stage_index + 1) as u8;
|
||||
let label = format!("Stage {}", stage_index + 1);
|
||||
let is_active = index + 1 == usize::from(basic.active_dpi_stage);
|
||||
let stage_list_for_select = basic.dpi_stages.clone();
|
||||
let stage_list_for_delete = basic.dpi_stages.clone();
|
||||
let stage_list_for_linked = basic.dpi_stages.clone();
|
||||
let stage_list_for_x = basic.dpi_stages.clone();
|
||||
let stage_list_for_y = basic.dpi_stages.clone();
|
||||
let active_stage_for_x = basic.active_dpi_stage;
|
||||
let active_stage_for_y = basic.active_dpi_stage;
|
||||
let active_stage = basic.active_dpi_stage;
|
||||
let can_delete = basic.dpi_stages.len() > 1;
|
||||
view! {
|
||||
<div class:active=index + 1 == usize::from(basic.active_dpi_stage)>
|
||||
<section
|
||||
class="dpi-stage-card"
|
||||
class:active=is_active
|
||||
on:click=move |_| apply_dpi_stages(stage_list_for_select.clone(), stage_number)
|
||||
>
|
||||
<div class="dpi-stage-card-header">
|
||||
<span>{label}</span>
|
||||
{if is_active {
|
||||
view! { <strong>"Active"</strong> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
{if can_delete {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="stage-delete"
|
||||
aria-label=format!("Delete stage {}", stage_number)
|
||||
title="Delete stage"
|
||||
on:click=move |ev| {
|
||||
ev.stop_propagation();
|
||||
let mut stages = stage_list_for_delete.clone();
|
||||
stages.remove(stage_index);
|
||||
let next_len = stages.len() as u8;
|
||||
let next_active = if active_stage == stage_number {
|
||||
stage_number.min(next_len)
|
||||
} else if active_stage > stage_number {
|
||||
active_stage.saturating_sub(1)
|
||||
} else {
|
||||
active_stage
|
||||
}
|
||||
.max(1);
|
||||
apply_dpi_stages(stages, next_active);
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M6 6l1 18h10l1-18" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
|
||||
<label class="field" on:click=move |ev| ev.stop_propagation()>
|
||||
<span>"DPI"</span>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
max="25600"
|
||||
step="100"
|
||||
prop:value=stage[0]
|
||||
on:click=move |ev| ev.stop_propagation()
|
||||
on:change=move |ev| {
|
||||
let mut stages = stage_list_for_linked.clone();
|
||||
let dpi = event_target_value(&ev)
|
||||
.parse::<u16>()
|
||||
.unwrap_or(stages[stage_index][0]);
|
||||
stages[stage_index] = [dpi, dpi];
|
||||
apply_dpi_stages(stages, active_stage);
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<details
|
||||
class="stage-advanced"
|
||||
prop:open=stage[0] != stage[1]
|
||||
on:click=move |ev| ev.stop_propagation()
|
||||
>
|
||||
<summary>"Fine tune X/Y"</summary>
|
||||
<div class="inline-pair">
|
||||
<label class="field">
|
||||
<span>"X"</span>
|
||||
<input
|
||||
|
|
@ -235,7 +228,7 @@ fn BasicPanel(
|
|||
.parse::<u16>()
|
||||
.unwrap_or(stages[stage_index][0]);
|
||||
stages[stage_index][0] = x;
|
||||
apply_dpi_stages(stages, active_stage_for_x);
|
||||
apply_dpi_stages(stages, active_stage);
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -253,13 +246,40 @@ fn BasicPanel(
|
|||
.parse::<u16>()
|
||||
.unwrap_or(stages[stage_index][1]);
|
||||
stages[stage_index][1] = y;
|
||||
apply_dpi_stages(stages, active_stage_for_y);
|
||||
apply_dpi_stages(stages, active_stage);
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
}).collect_view()}
|
||||
{
|
||||
let can_add = basic.dpi_stages.len() < 5;
|
||||
let stage_template = initial_dpi_stages.clone();
|
||||
let last_stage = stage_template.last().copied().unwrap_or([800, 800]);
|
||||
let next_dpi = last_stage[0].max(last_stage[1]).saturating_add(400).min(25600);
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="dpi-add-stage"
|
||||
class:disabled=!can_add
|
||||
disabled=!can_add
|
||||
on:click=move |_| {
|
||||
if !can_add {
|
||||
return;
|
||||
}
|
||||
let mut stages = stage_template.clone();
|
||||
stages.push([next_dpi, next_dpi]);
|
||||
apply_dpi_stages(stages.clone(), stages.len() as u8);
|
||||
}
|
||||
>
|
||||
<span>"+"</span>
|
||||
<strong>{if can_add { "Add stage" } else { "Max 5 stages" }}</strong>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -29,25 +29,24 @@ fn render_button_mapping_editor(
|
|||
.into_any(),
|
||||
"mouse" => view! {
|
||||
<div class="button-editor-grid">
|
||||
<label class="field">
|
||||
<span>"Mouse function"</span>
|
||||
<select
|
||||
prop:value=mouse_fn.clone()
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
<AppDropdown
|
||||
label="Mouse function"
|
||||
selected=move || mouse_fn.clone()
|
||||
disabled=move || false
|
||||
options=move || {
|
||||
["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
|
||||
.into_iter()
|
||||
.map(|name| SelectChoice::new(name, name))
|
||||
.collect()
|
||||
}
|
||||
on_select=move |value| {
|
||||
mapping_state.update(|state| {
|
||||
if let Some(mapping) = state.as_mut() {
|
||||
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
{["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
|
||||
.into_iter()
|
||||
.map(|name| view! { <option value=name>{name}</option> })
|
||||
.collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
|
||||
<div class="button-radio-group">
|
||||
<label class="check-row">
|
||||
|
|
|
|||
|
|
@ -36,25 +36,32 @@ fn render_button_mapping_editor_secondary(
|
|||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>"Mode"</span>
|
||||
<select
|
||||
prop:value=macro_mode.clone()
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
<AppDropdown
|
||||
label="Mode"
|
||||
selected={
|
||||
let macro_mode = macro_mode.clone();
|
||||
move || macro_mode.clone()
|
||||
}
|
||||
disabled=move || false
|
||||
options=move || {
|
||||
[
|
||||
("macro_fixed", "Fixed repeat"),
|
||||
("macro_hold", "Hold"),
|
||||
("macro_toggle", "Toggle"),
|
||||
("macro_sequence", "Sequence"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(value, label)| SelectChoice::new(value, label))
|
||||
.collect()
|
||||
}
|
||||
on_select=move |value| {
|
||||
mapping_state.update(|state| {
|
||||
if let Some(mapping) = state.as_mut() {
|
||||
payload_object_mut(mapping).insert("mode".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<option value="macro_fixed">"Fixed repeat"</option>
|
||||
<option value="macro_hold">"Hold"</option>
|
||||
<option value="macro_toggle">"Toggle"</option>
|
||||
<option value="macro_sequence">"Sequence"</option>
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
|
||||
<label class="field inline">
|
||||
<span>"Repeat count"</span>
|
||||
|
|
@ -79,12 +86,27 @@ fn render_button_mapping_editor_secondary(
|
|||
.into_any(),
|
||||
"dpi_switch" => view! {
|
||||
<div class="button-editor-grid">
|
||||
<label class="field">
|
||||
<span>"Action"</span>
|
||||
<select
|
||||
prop:value=dpi_fn.clone()
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
<AppDropdown
|
||||
label="Action"
|
||||
selected={
|
||||
let dpi_fn = dpi_fn.clone();
|
||||
move || dpi_fn.clone()
|
||||
}
|
||||
disabled=move || false
|
||||
options=move || {
|
||||
[
|
||||
("next", "Next"),
|
||||
("prev", "Previous"),
|
||||
("next_loop", "Next (loop)"),
|
||||
("prev_loop", "Previous (loop)"),
|
||||
("fixed", "Fixed stage"),
|
||||
("aim", "Aim DPI"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(value, label)| SelectChoice::new(value, label))
|
||||
.collect()
|
||||
}
|
||||
on_select=move |value| {
|
||||
mapping_state.update(|state| {
|
||||
if let Some(mapping) = state.as_mut() {
|
||||
let payload = payload_object_mut(mapping);
|
||||
|
|
@ -92,15 +114,7 @@ fn render_button_mapping_editor_secondary(
|
|||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<option value="next">"Next"</option>
|
||||
<option value="prev">"Previous"</option>
|
||||
<option value="next_loop">"Next (loop)"</option>
|
||||
<option value="prev_loop">"Previous (loop)"</option>
|
||||
<option value="fixed">"Fixed stage"</option>
|
||||
<option value="aim">"Aim DPI"</option>
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
|
||||
<label class="field inline">
|
||||
<span>"Stage"</span>
|
||||
|
|
@ -166,47 +180,55 @@ fn render_button_mapping_editor_secondary(
|
|||
.into_any(),
|
||||
"profile_switch" => view! {
|
||||
<div class="button-editor-grid">
|
||||
<label class="field">
|
||||
<span>"Action"</span>
|
||||
<select
|
||||
prop:value=profile_fn.clone()
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
<AppDropdown
|
||||
label="Action"
|
||||
selected={
|
||||
let profile_fn = profile_fn.clone();
|
||||
move || profile_fn.clone()
|
||||
}
|
||||
disabled=move || false
|
||||
options=move || {
|
||||
[
|
||||
("next", "Next"),
|
||||
("prev", "Previous"),
|
||||
("next_loop", "Next (loop)"),
|
||||
("prev_loop", "Previous (loop)"),
|
||||
("fixed", "Fixed profile"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(value, label)| SelectChoice::new(value, label))
|
||||
.collect()
|
||||
}
|
||||
on_select=move |value| {
|
||||
mapping_state.update(|state| {
|
||||
if let Some(mapping) = state.as_mut() {
|
||||
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<option value="next">"Next"</option>
|
||||
<option value="prev">"Previous"</option>
|
||||
<option value="next_loop">"Next (loop)"</option>
|
||||
<option value="prev_loop">"Previous (loop)"</option>
|
||||
<option value="fixed">"Fixed profile"</option>
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
|
||||
<label class="field">
|
||||
<span>"Fixed profile"</span>
|
||||
<select
|
||||
prop:value=fixed_profile.clone()
|
||||
disabled=profile_fn != "fixed"
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
<AppDropdown
|
||||
label="Fixed profile"
|
||||
selected=move || fixed_profile.clone()
|
||||
disabled={
|
||||
let profile_fn = profile_fn.clone();
|
||||
move || profile_fn != "fixed"
|
||||
}
|
||||
options=move || {
|
||||
["white", "red", "green", "blue", "cyan"]
|
||||
.into_iter()
|
||||
.map(|name| SelectChoice::new(name, name))
|
||||
.collect()
|
||||
}
|
||||
on_select=move |value| {
|
||||
mapping_state.update(|state| {
|
||||
if let Some(mapping) = state.as_mut() {
|
||||
payload_object_mut(mapping).insert("profile".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
{["white", "red", "green", "blue", "cyan"]
|
||||
.into_iter()
|
||||
.map(|name| view! { <option value=name>{name}</option> })
|
||||
.collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,50 @@
|
|||
#[derive(Clone, Copy)]
|
||||
struct LedEffectChoice {
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
effect: &'static str,
|
||||
mode: u8,
|
||||
}
|
||||
|
||||
const LED_EFFECT_CHOICES: [LedEffectChoice; 6] = [
|
||||
LedEffectChoice {
|
||||
key: "off",
|
||||
label: "Off",
|
||||
effect: "off",
|
||||
mode: 0,
|
||||
},
|
||||
LedEffectChoice {
|
||||
key: "static",
|
||||
label: "Static",
|
||||
effect: "static",
|
||||
mode: 0,
|
||||
},
|
||||
LedEffectChoice {
|
||||
key: "spectrum",
|
||||
label: "Spectrum",
|
||||
effect: "spectrum",
|
||||
mode: 1,
|
||||
},
|
||||
LedEffectChoice {
|
||||
key: "wave_fixed",
|
||||
label: "Wave fixed",
|
||||
effect: "wave",
|
||||
mode: 0,
|
||||
},
|
||||
LedEffectChoice {
|
||||
key: "wave_clockwise",
|
||||
label: "Wave clockwise",
|
||||
effect: "wave",
|
||||
mode: 1,
|
||||
},
|
||||
LedEffectChoice {
|
||||
key: "wave_counter",
|
||||
label: "Wave counter",
|
||||
effect: "wave",
|
||||
mode: 2,
|
||||
},
|
||||
];
|
||||
|
||||
#[component]
|
||||
fn LedPanel(
|
||||
snapshot: DeviceState,
|
||||
|
|
@ -8,10 +55,14 @@ fn LedPanel(
|
|||
let demo_mode = snapshot.device.path.starts_with("demo://");
|
||||
let led_state = RwSignal::new(None::<LedState>);
|
||||
let selected_region = RwSignal::new("wheel".to_string());
|
||||
let static_color_memory =
|
||||
RwSignal::new(std::collections::BTreeMap::<String, [u8; 3]>::new());
|
||||
|
||||
let load_led_state = move |profile: String| {
|
||||
if demo_mode {
|
||||
led_state.set(Some(mock_led_state(profile)));
|
||||
let state = mock_led_state(profile);
|
||||
remember_static_led_colors(static_color_memory, &state);
|
||||
led_state.set(Some(state));
|
||||
return;
|
||||
}
|
||||
set_busy.set(true);
|
||||
|
|
@ -19,6 +70,7 @@ fn LedPanel(
|
|||
spawn_local(async move {
|
||||
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
|
||||
Ok(state) => {
|
||||
remember_static_led_colors(static_color_memory, &state);
|
||||
led_state.set(Some(state));
|
||||
set_status.set("LED settings loaded.".to_string());
|
||||
}
|
||||
|
|
@ -39,12 +91,15 @@ fn LedPanel(
|
|||
let Some(state) = led_state.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let source_name = selected_region.get_untracked();
|
||||
let Some(source_region) = state
|
||||
.regions
|
||||
.iter()
|
||||
.find(|region| region.region == selected_region.get_untracked())
|
||||
.find(|region| region.region == source_name)
|
||||
.or_else(|| state.regions.first())
|
||||
.cloned()
|
||||
else {
|
||||
set_status.set("No LED regions are available.".to_string());
|
||||
return;
|
||||
};
|
||||
if demo_mode {
|
||||
|
|
@ -56,6 +111,7 @@ fn LedPanel(
|
|||
region.colors = source_region.colors.clone();
|
||||
region.brightness = source_region.brightness;
|
||||
}
|
||||
remember_static_led_colors(static_color_memory, &next_state);
|
||||
led_state.set(Some(next_state));
|
||||
set_status.set("Updated LED demo state.".to_string());
|
||||
}
|
||||
|
|
@ -75,6 +131,7 @@ fn LedPanel(
|
|||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
remember_static_led_colors(static_color_memory, &state);
|
||||
led_state.set(Some(state));
|
||||
set_status.set("LED settings applied to all regions.".to_string());
|
||||
}
|
||||
|
|
@ -86,210 +143,366 @@ fn LedPanel(
|
|||
|
||||
view! {
|
||||
<section class="panel-grid">
|
||||
<article class="panel wide">
|
||||
<h3>"LED"</h3>
|
||||
<article class="panel wide led-panel">
|
||||
{move || {
|
||||
let Some(state) = led_state.get() else {
|
||||
return view! { <p>"Loading LED settings..."</p> }.into_any();
|
||||
};
|
||||
|
||||
let led_profile_label = state.profile.clone();
|
||||
let active_region_name = selected_region.get();
|
||||
if state.regions.is_empty() {
|
||||
return view! {
|
||||
<div class="led-panel-header">
|
||||
<div>
|
||||
<h3>"LED"</h3>
|
||||
<p class="subtle">{format!("Profile {}", state.profile)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtle">"No LED regions reported by this device."</p>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
let selected_region_name = selected_region.get();
|
||||
let active_region = state
|
||||
.regions
|
||||
.iter()
|
||||
.find(|region| region.region == active_region_name)
|
||||
.find(|region| region.region == selected_region_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.regions[0].clone());
|
||||
|
||||
let region_id = active_region.region.clone();
|
||||
let active_region_id = active_region.region.clone();
|
||||
let effect = active_region.effect.clone();
|
||||
let mode = active_region.mode;
|
||||
let speed = active_region.speed;
|
||||
let colors = active_region.colors.clone();
|
||||
let brightness = active_region.brightness;
|
||||
let static_hex = colors
|
||||
.first()
|
||||
.map(color_to_hex)
|
||||
.unwrap_or_else(|| "#ffffff".to_string());
|
||||
let effect_key = led_effect_key(&effect, mode);
|
||||
let static_color = led_static_color(&active_region_id, &colors, static_color_memory);
|
||||
let static_hex = color_to_hex(&static_color);
|
||||
let control_speed = led_control_speed(speed);
|
||||
let show_color = effect == "static";
|
||||
let show_speed = led_effect_has_speed(&effect);
|
||||
let show_brightness = effect != "off" && effect != "disabled";
|
||||
|
||||
view! {
|
||||
<p class="subtle">{format!("Editing LED settings for profile {led_profile_label}")}</p>
|
||||
<div class="led-region-tabs">
|
||||
<div class="led-panel-header">
|
||||
<div>
|
||||
<h3>"LED"</h3>
|
||||
<p class="subtle">{format!("Profile {}", state.profile)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-action"
|
||||
disabled=state.regions.len() < 2
|
||||
on:click=apply_to_all
|
||||
>
|
||||
"Apply to all"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="led-region-tabs" role="tablist" aria-label="LED regions">
|
||||
{state.regions.iter().map(|region| {
|
||||
let region_name = region.region.clone();
|
||||
let region_name_active = region_name.clone();
|
||||
let region_name_click = region_name.clone();
|
||||
let region_click = region_name.clone();
|
||||
let region_is_active = region_name == active_region_id;
|
||||
let region_label = led_region_label(®ion.region);
|
||||
let swatch = led_region_swatch(region);
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class:active=move || selected_region.get() == region_name_active
|
||||
on:click=move |_| selected_region.set(region_name_click.clone())
|
||||
role="tab"
|
||||
class:active=region_is_active
|
||||
aria-selected=region_is_active.to_string()
|
||||
on:click=move |_| selected_region.set(region_click.clone())
|
||||
>
|
||||
{region.region.clone()}
|
||||
<span class="led-region-swatch" style=format!("background: {swatch};")></span>
|
||||
<strong>{region_label}</strong>
|
||||
</button>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
|
||||
<div class="led-options">
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "off" || effect == "disabled"
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "off".to_string(), 0, 0, vec![])
|
||||
<div class="led-section">
|
||||
<h4>"Effect"</h4>
|
||||
<div class="led-effect-grid" role="group" aria-label="LED effect">
|
||||
{LED_EFFECT_CHOICES.into_iter().map(|choice| {
|
||||
let is_active = effect_key == choice.key;
|
||||
let region = active_region_id.clone();
|
||||
let current_speed = control_speed;
|
||||
let color = static_color;
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class:active=is_active
|
||||
on:click=move |_| {
|
||||
let colors = if choice.effect == "static" {
|
||||
vec![color]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let speed = if led_effect_has_speed(choice.effect) {
|
||||
current_speed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
apply_led_effect_change(
|
||||
demo_mode,
|
||||
led_state,
|
||||
selected_profile,
|
||||
set_status,
|
||||
set_busy,
|
||||
region.clone(),
|
||||
choice.effect.to_string(),
|
||||
choice.mode,
|
||||
speed,
|
||||
colors,
|
||||
);
|
||||
}
|
||||
/>
|
||||
<span>"Disabled"</span>
|
||||
</label>
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
}
|
||||
}).collect_view()}
|
||||
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "static"
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![[255, 255, 255]])
|
||||
}
|
||||
/>
|
||||
<span>"Static"</span>
|
||||
{if effect == "custom" {
|
||||
view! {
|
||||
<button type="button" class="active" disabled=true>"Custom"</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="led-controls">
|
||||
{if show_color {
|
||||
view! {
|
||||
<label class="led-color-control">
|
||||
<span>"Color"</span>
|
||||
<input
|
||||
type="color"
|
||||
prop:value=static_hex
|
||||
disabled=effect != "static"
|
||||
prop:value=static_hex.clone()
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
let region = active_region_id.clone();
|
||||
move |ev| {
|
||||
let color = hex_to_rgb(&event_target_value(&ev));
|
||||
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![color]);
|
||||
static_color_memory.update(|items| {
|
||||
items.insert(region.clone(), color);
|
||||
});
|
||||
apply_led_effect_change(
|
||||
demo_mode,
|
||||
led_state,
|
||||
selected_profile,
|
||||
set_status,
|
||||
set_busy,
|
||||
region.clone(),
|
||||
"static".to_string(),
|
||||
0,
|
||||
0,
|
||||
vec![color],
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<strong>{static_hex.to_uppercase()}</strong>
|
||||
</label>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "spectrum"
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "spectrum".to_string(), 1, 180, vec![])
|
||||
}
|
||||
/>
|
||||
<span>"Spectrum"</span>
|
||||
</label>
|
||||
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "wave" && mode == 0
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 0, 180, vec![])
|
||||
}
|
||||
/>
|
||||
<span>"Wave (Static)"</span>
|
||||
</label>
|
||||
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "wave" && mode == 1
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 1, 180, vec![])
|
||||
}
|
||||
/>
|
||||
<span>"Wave (Clockwise)"</span>
|
||||
</label>
|
||||
|
||||
<label class="led-option">
|
||||
<input
|
||||
type="radio"
|
||||
prop:checked=effect == "wave" && mode == 2
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 2, 180, vec![])
|
||||
}
|
||||
/>
|
||||
<span>"Wave (Counter-clockwise)"</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="slider-row">
|
||||
{if show_speed {
|
||||
view! {
|
||||
<div class="led-slider-row">
|
||||
<div class="led-slider-label">
|
||||
<span>"Speed"</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=speed
|
||||
disabled=effect != "spectrum" && effect != "wave"
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
let effect_name = effect.clone();
|
||||
let effect_colors = colors.clone();
|
||||
move |ev| {
|
||||
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed);
|
||||
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone());
|
||||
}
|
||||
}
|
||||
/>
|
||||
<strong>{control_speed}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=speed
|
||||
disabled=effect != "spectrum" && effect != "wave"
|
||||
prop:value=control_speed
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
let region = active_region_id.clone();
|
||||
let effect_name = effect.clone();
|
||||
let effect_colors = colors.clone();
|
||||
move |ev| {
|
||||
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed);
|
||||
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone());
|
||||
let next_speed = parse_led_u8(&event_target_value(&ev), control_speed);
|
||||
apply_led_effect_change(
|
||||
demo_mode,
|
||||
led_state,
|
||||
selected_profile,
|
||||
set_status,
|
||||
set_busy,
|
||||
region.clone(),
|
||||
effect_name.clone(),
|
||||
mode,
|
||||
next_speed,
|
||||
effect_colors.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=control_speed
|
||||
on:change={
|
||||
let region = active_region_id.clone();
|
||||
let effect_name = effect.clone();
|
||||
let effect_colors = colors.clone();
|
||||
move |ev| {
|
||||
let next_speed = parse_led_u8(&event_target_value(&ev), control_speed);
|
||||
apply_led_effect_change(
|
||||
demo_mode,
|
||||
led_state,
|
||||
selected_profile,
|
||||
set_status,
|
||||
set_busy,
|
||||
region.clone(),
|
||||
effect_name.clone(),
|
||||
mode,
|
||||
next_speed,
|
||||
effect_colors.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
<div class="slider-row">
|
||||
{if show_brightness {
|
||||
view! {
|
||||
<div class="led-slider-row">
|
||||
<div class="led-slider-label">
|
||||
<span>"Brightness"</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=brightness
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
move |ev| {
|
||||
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
||||
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<strong>{brightness}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=brightness
|
||||
on:change={
|
||||
let region = region_id.clone();
|
||||
let region = active_region_id.clone();
|
||||
move |ev| {
|
||||
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness);
|
||||
let next_brightness = parse_led_u8(&event_target_value(&ev), brightness);
|
||||
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
prop:value=brightness
|
||||
on:change={
|
||||
let region = active_region_id.clone();
|
||||
move |ev| {
|
||||
let next_brightness = parse_led_u8(&event_target_value(&ev), brightness);
|
||||
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="button" class="secondary-action" on:click=apply_to_all>
|
||||
"Apply To All Regions"
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</article>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_static_led_colors(
|
||||
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
|
||||
state: &LedState,
|
||||
) {
|
||||
memory.update(|items| {
|
||||
for region in &state.regions {
|
||||
if region.effect == "static" {
|
||||
if let Some(color) = region.colors.first() {
|
||||
items.insert(region.region.clone(), *color);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn led_static_color(
|
||||
region: &str,
|
||||
colors: &[[u8; 3]],
|
||||
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
|
||||
) -> [u8; 3] {
|
||||
colors
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| memory.get_untracked().get(region).copied())
|
||||
.unwrap_or([105, 230, 162])
|
||||
}
|
||||
|
||||
fn led_region_label(region: &str) -> String {
|
||||
match region {
|
||||
"wheel" => "Wheel".to_string(),
|
||||
"logo" => "Logo".to_string(),
|
||||
"strip" => "Underglow".to_string(),
|
||||
other => other.replace('_', " "),
|
||||
}
|
||||
}
|
||||
|
||||
fn led_effect_key(effect: &str, mode: u8) -> String {
|
||||
match (effect, mode) {
|
||||
("off", _) | ("disabled", _) => "off".to_string(),
|
||||
("static", _) => "static".to_string(),
|
||||
("spectrum", _) => "spectrum".to_string(),
|
||||
("wave", 0) => "wave_fixed".to_string(),
|
||||
("wave", 1) => "wave_clockwise".to_string(),
|
||||
("wave", 2) => "wave_counter".to_string(),
|
||||
("custom", _) => "custom".to_string(),
|
||||
_ => effect.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn led_effect_has_speed(effect: &str) -> bool {
|
||||
effect == "spectrum" || effect == "wave"
|
||||
}
|
||||
|
||||
fn led_control_speed(speed: u8) -> u8 {
|
||||
if speed == 0 {
|
||||
180
|
||||
} else {
|
||||
speed
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_led_u8(value: &str, fallback: u8) -> u8 {
|
||||
value
|
||||
.parse::<u16>()
|
||||
.map(|value| value.min(u16::from(u8::MAX)) as u8)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn led_region_swatch(region: &LedRegionState) -> String {
|
||||
if region.effect == "static" {
|
||||
return region
|
||||
.colors
|
||||
.first()
|
||||
.map(color_to_hex)
|
||||
.unwrap_or_else(|| "#69e6a2".to_string());
|
||||
}
|
||||
|
||||
match region.effect.as_str() {
|
||||
"off" | "disabled" => "var(--color-border-subtle)".to_string(),
|
||||
"spectrum" => "linear-gradient(90deg, #ff4d6d, #ffd166, #69e6a2, #4cc9f0, #b983ff)"
|
||||
.to_string(),
|
||||
"wave" => "linear-gradient(90deg, #69e6a2, #4cc9f0)".to_string(),
|
||||
_ => "var(--color-metric)".to_string(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,22 +315,19 @@ fn MacroPanel(
|
|||
<h3>"Macros"</h3>
|
||||
<h4>"Edit Individual Macros"</h4>
|
||||
<div class="macro-toolbar">
|
||||
<label class="field inline">
|
||||
<span>"Select existing"</span>
|
||||
<select
|
||||
prop:value=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
|
||||
on:change=move |ev| {
|
||||
let value = event_target_value(&ev);
|
||||
selected_macro_id.set(value.parse::<u16>().ok());
|
||||
<AppDropdown
|
||||
label="Select existing"
|
||||
selected=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
|
||||
disabled=move || false
|
||||
options=move || {
|
||||
let mut options = vec![SelectChoice::new("", "(none)")];
|
||||
options.extend(macro_list.get().into_iter().map(|macro_id| {
|
||||
SelectChoice::new(macro_id.to_string(), format!("0x{macro_id:04x}"))
|
||||
}));
|
||||
options
|
||||
}
|
||||
>
|
||||
<option value="">"(none)"</option>
|
||||
{move || macro_list.get().into_iter().map(|macro_id| {
|
||||
let label = format!("0x{macro_id:04x}");
|
||||
view! { <option value=macro_id.to_string()>{label}</option> }
|
||||
}).collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
on_select=move |value| selected_macro_id.set(value.parse::<u16>().ok())
|
||||
/>
|
||||
<button type="button" class="secondary-action" on:click=load_selected>"Load"</button>
|
||||
<button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,13 +90,6 @@ struct PollingRateArgs {
|
|||
polling_rate_ms: u8,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DpiArgs {
|
||||
profile: String,
|
||||
x: u16,
|
||||
y: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DpiStagesArgs {
|
||||
|
|
|
|||
232
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]
|
||||
pub fn App() -> impl IntoView {
|
||||
let devices = RwSignal::new(Vec::<DeviceSummary>::new());
|
||||
|
|
@ -5,9 +163,36 @@ pub fn App() -> impl IntoView {
|
|||
let selected_profile = RwSignal::new("direct".to_string());
|
||||
let device_state = RwSignal::new(None::<DeviceState>);
|
||||
let active_tab = RwSignal::new("basic".to_string());
|
||||
let status = RwSignal::new("Scanning for a supported Basilisk mouse.".to_string());
|
||||
let status = RwSignal::new(String::new());
|
||||
let toasts = RwSignal::new(Vec::<ToastMessage>::new());
|
||||
let next_toast_id = RwSignal::new(0_u64);
|
||||
let busy = RwSignal::new(false);
|
||||
|
||||
Effect::new(move |_| {
|
||||
let message = status.get();
|
||||
if message.trim().is_empty() || !should_show_toast(&message) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = next_toast_id.get_untracked();
|
||||
let busy_now = busy.get_untracked();
|
||||
next_toast_id.set(id + 1);
|
||||
toasts.update(|items| {
|
||||
items.push(ToastMessage {
|
||||
id,
|
||||
message,
|
||||
busy: busy_now,
|
||||
});
|
||||
});
|
||||
|
||||
set_timeout(
|
||||
move || {
|
||||
toasts.update(|items| items.retain(|item| item.id != id));
|
||||
},
|
||||
std::time::Duration::from_millis(if busy_now { 3000 } else { 4500 }),
|
||||
);
|
||||
});
|
||||
|
||||
let connect_path = move |path: String| {
|
||||
if path.is_empty() {
|
||||
status.set("Select a device path before connecting.".to_string());
|
||||
|
|
@ -89,7 +274,7 @@ pub fn App() -> impl IntoView {
|
|||
<main class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="public/snakemouse.svg" alt="" />
|
||||
<img src="public/app-icon.png" alt="" />
|
||||
<div>
|
||||
<h1>"Razer Basilisk V3"</h1>
|
||||
<p>"Onboard memory tools"</p>
|
||||
|
|
@ -100,15 +285,13 @@ pub fn App() -> impl IntoView {
|
|||
"Scan"
|
||||
</button>
|
||||
|
||||
<label class="field">
|
||||
<span>"Device"</span>
|
||||
<select
|
||||
prop:value=move || selected_path.get()
|
||||
on:change=move |ev| selected_path.set(event_target_value(&ev))
|
||||
<AppDropdown
|
||||
label="Device"
|
||||
selected=move || selected_path.get()
|
||||
disabled=move || busy.get()
|
||||
>
|
||||
<option value="">"No supported device selected"</option>
|
||||
{move || devices.get().into_iter().map(|device| {
|
||||
options=move || {
|
||||
let mut options = vec![SelectChoice::new("", "No supported device selected")];
|
||||
options.extend(devices.get().into_iter().map(|device| {
|
||||
let label = format!(
|
||||
"{} - {} ({:04x}:{:04x}){}",
|
||||
device.path,
|
||||
|
|
@ -121,12 +304,12 @@ pub fn App() -> impl IntoView {
|
|||
.map(|serial| format!(" - {serial}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
view! {
|
||||
<option value=device.path.clone()>{label}</option>
|
||||
SelectChoice::new(device.path, label)
|
||||
}));
|
||||
options
|
||||
}
|
||||
}).collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
on_select=move |value| selected_path.set(value)
|
||||
/>
|
||||
|
||||
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
|
||||
"Connect"
|
||||
|
|
@ -148,9 +331,26 @@ pub fn App() -> impl IntoView {
|
|||
<p class="eyebrow">"Linux desktop configuration"</p>
|
||||
<h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2>
|
||||
</div>
|
||||
<div class="status" class:busy=move || busy.get()>{move || status.get()}</div>
|
||||
</header>
|
||||
|
||||
<div class="toast-region" aria-live="polite" aria-atomic="false">
|
||||
{move || toasts.get().into_iter().map(|toast| {
|
||||
let toast_id = toast.id;
|
||||
view! {
|
||||
<div class="toast" class:busy=toast.busy>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
on:click=move |_| toasts.update(|items| items.retain(|item| item.id != toast_id))
|
||||
>
|
||||
"x"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
|
||||
{move || match device_state.get() {
|
||||
Some(snapshot) => view! {
|
||||
<ConnectedView
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.macro-toolbar .app-select-field {
|
||||
min-width: min(260px, 100%);
|
||||
}
|
||||
|
||||
.field.inline.compact {
|
||||
grid-template-columns: 72px 140px;
|
||||
}
|
||||
|
|
@ -27,26 +31,26 @@
|
|||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
margin: 16px 0 10px;
|
||||
background: #d8e0dc;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.flash-segment.used {
|
||||
background: #a64545;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.flash-segment.available {
|
||||
background: #60d394;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.flash-segment.recycled {
|
||||
background: #d4b25f;
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.flash-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #66736f;
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
|
|
@ -56,10 +60,10 @@
|
|||
min-height: 220px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e0dc;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #f4f7f5;
|
||||
background: var(--color-panel-subtle);
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
|
|
@ -90,10 +94,10 @@
|
|||
|
||||
.macro-help pre {
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e0dc;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #f4f7f5;
|
||||
background: var(--color-panel-subtle);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
|
@ -106,8 +110,8 @@
|
|||
height: 18px;
|
||||
margin-left: 6px;
|
||||
border-radius: 999px;
|
||||
color: #1f5c4c;
|
||||
background: #dfe7e2;
|
||||
color: var(--color-metric);
|
||||
background: var(--color-secondary-bg);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
|
|
@ -115,7 +119,7 @@
|
|||
|
||||
.field-error {
|
||||
margin: 0;
|
||||
color: #a64545;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,49 +2,185 @@
|
|||
|
||||
.metric {
|
||||
margin-top: 12px;
|
||||
color: #1f5c4c;
|
||||
color: var(--color-metric);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dpi-editor {
|
||||
.polling-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dpi-controls {
|
||||
.polling-options button,
|
||||
.led-effect-grid button {
|
||||
min-height: 42px;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--color-secondary-text);
|
||||
background: var(--color-secondary-bg);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.polling-options button:hover,
|
||||
.polling-options button.active,
|
||||
.led-effect-grid button:hover,
|
||||
.led-effect-grid button.active {
|
||||
color: var(--color-active-text);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.dpi-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dpi-card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dpi-controls .field.inline {
|
||||
grid-template-columns: 72px 110px;
|
||||
.dpi-card-header h3 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stage-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.stage-list div {
|
||||
.dpi-stage-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid #d8e0dc;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #f4f7f5;
|
||||
padding: 12px;
|
||||
background: var(--color-panel-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-list div.active {
|
||||
border-color: #226957;
|
||||
background: #e3f3ec;
|
||||
.dpi-add-stage {
|
||||
display: grid;
|
||||
min-height: 156px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
color: var(--color-secondary-text);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dpi-add-stage:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dpi-add-stage span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dpi-add-stage strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dpi-stage-card:hover,
|
||||
.dpi-stage-card:focus-visible,
|
||||
.dpi-add-stage:hover,
|
||||
.dpi-add-stage:focus-visible {
|
||||
border-color: var(--color-active);
|
||||
}
|
||||
|
||||
.dpi-stage-card.active {
|
||||
border-color: var(--color-active);
|
||||
background: var(--color-active-surface);
|
||||
}
|
||||
|
||||
.dpi-stage-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dpi-stage-card-header span {
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dpi-stage-card-header strong {
|
||||
color: var(--color-metric);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stage-delete {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: auto;
|
||||
border-radius: 6px;
|
||||
color: var(--color-danger);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dpi-stage-card:hover .stage-delete,
|
||||
.dpi-stage-card:focus-within .stage-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stage-delete:hover,
|
||||
.stage-delete:focus-visible {
|
||||
color: var(--color-danger-text);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.stage-delete svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.stage-advanced {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stage-advanced summary {
|
||||
color: var(--color-small-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage-advanced .inline-pair {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stage-list input {
|
||||
|
|
@ -62,19 +198,19 @@
|
|||
display: grid;
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
border: 1px solid #d8e0dc;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #f4f7f5;
|
||||
background: var(--color-panel-subtle);
|
||||
}
|
||||
|
||||
.profile-admin-list div.active {
|
||||
border-color: #226957;
|
||||
background: #e3f3ec;
|
||||
border-color: var(--color-active);
|
||||
background: var(--color-active-surface);
|
||||
}
|
||||
|
||||
.profile-admin-list span {
|
||||
color: #66736f;
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
|
|
@ -106,13 +242,13 @@
|
|||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #d8e0dc;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
text-align: left;
|
||||
background: #f4f7f5;
|
||||
background: var(--color-panel-subtle);
|
||||
}
|
||||
|
||||
.button-tile span {
|
||||
color: #66736f;
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
|
|
@ -125,8 +261,8 @@
|
|||
}
|
||||
|
||||
.button-tile.active {
|
||||
border-color: #226957;
|
||||
background: #e3f3ec;
|
||||
border-color: var(--color-active);
|
||||
background: var(--color-active-surface);
|
||||
}
|
||||
|
||||
.button-hypershift-toggle {
|
||||
|
|
@ -140,13 +276,13 @@
|
|||
|
||||
.category-grid button {
|
||||
padding: 0 12px;
|
||||
color: #25322f;
|
||||
background: #dfe7e2;
|
||||
color: var(--color-secondary-text);
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.category-grid button.active {
|
||||
color: #f8fbf9;
|
||||
background: #226957;
|
||||
color: var(--color-active-text);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.button-editor-grid {
|
||||
|
|
@ -192,71 +328,150 @@
|
|||
min-height: 18px;
|
||||
}
|
||||
|
||||
.led-region-tabs {
|
||||
.led-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.led-panel-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.led-panel-header h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.led-region-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.led-region-tabs button {
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
color: #25322f;
|
||||
background: #dfe7e2;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--color-secondary-text);
|
||||
background: var(--color-panel-subtle);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.led-region-tabs button.active {
|
||||
color: #f8fbf9;
|
||||
background: #226957;
|
||||
border-color: var(--color-active);
|
||||
background: var(--color-active-surface);
|
||||
}
|
||||
|
||||
.led-options {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.led-option {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, max-content) max-content;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.led-option span {
|
||||
.led-region-tabs strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.led-option input[type="color"] {
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
.led-region-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.led-section h4,
|
||||
.led-slider-label span,
|
||||
.led-color-control span {
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.led-slider-label strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.led-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.led-section h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.led-effect-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.led-effect-grid button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.led-effect-grid button:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.led-controls {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.led-color-control {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 72px minmax(92px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.led-color-control input[type="color"] {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 110px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
.led-color-control strong {
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.slider-row input[type="range"] {
|
||||
.led-slider-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(180px, 1fr) 92px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.led-slider-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.led-slider-row input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.led-slider-row input[type="number"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stage-list span,
|
||||
.info-list dt {
|
||||
color: #66736f;
|
||||
color: var(--color-small-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -285,14 +500,16 @@
|
|||
@media (max-width: 760px) {
|
||||
.app-shell,
|
||||
.panel-grid,
|
||||
.dpi-editor,
|
||||
.field.inline,
|
||||
.info-list,
|
||||
.inline-pair {
|
||||
.inline-pair,
|
||||
.led-color-control,
|
||||
.led-slider-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.led-panel-header,
|
||||
.profile-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,39 @@
|
|||
:root {
|
||||
color: #18201f;
|
||||
background: #eef1ee;
|
||||
color-scheme: light;
|
||||
--color-text: #18201f;
|
||||
--color-page: #eef1ee;
|
||||
--color-sidebar-text: #f4f7f5;
|
||||
--color-sidebar-bg: #12201d;
|
||||
--color-muted: #6f7f7a;
|
||||
--color-sidebar-muted: #9baca6;
|
||||
--color-primary-text: #09201a;
|
||||
--color-primary: #60d394;
|
||||
--color-secondary-text: #25322f;
|
||||
--color-secondary-bg: #dfe7e2;
|
||||
--color-danger-text: #fff8f8;
|
||||
--color-danger: #a64545;
|
||||
--color-nav-text: #dfe9e4;
|
||||
--color-nav-hover: #223b36;
|
||||
--color-status-border: #cbd5d0;
|
||||
--color-status-text: #33413d;
|
||||
--color-surface: #fbfdfb;
|
||||
--color-warning: #d4b25f;
|
||||
--color-warning-bg: #fff7de;
|
||||
--color-label: #52605c;
|
||||
--color-input-border: #bec9c4;
|
||||
--color-input-bg: #ffffff;
|
||||
--color-sidebar-input-text: #10201c;
|
||||
--color-active-text: #f8fbf9;
|
||||
--color-active: #226957;
|
||||
--color-panel-border: #d5ddd8;
|
||||
--color-border-subtle: #d8e0dc;
|
||||
--color-panel-subtle: #f4f7f5;
|
||||
--color-active-surface: #e3f3ec;
|
||||
--color-metric: #1f5c4c;
|
||||
--color-small-text: #66736f;
|
||||
color: var(--color-text);
|
||||
background: var(--color-page);
|
||||
accent-color: var(--color-primary);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-synthesis: none;
|
||||
|
|
@ -10,6 +43,43 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-text: #e4ece8;
|
||||
--color-page: #0f1514;
|
||||
--color-sidebar-text: #f3faf6;
|
||||
--color-sidebar-bg: #0b1715;
|
||||
--color-muted: #9fb2ab;
|
||||
--color-sidebar-muted: #9eb2ab;
|
||||
--color-primary-text: #06130f;
|
||||
--color-primary: #69e6a2;
|
||||
--color-secondary-text: #dce7e2;
|
||||
--color-secondary-bg: #22302d;
|
||||
--color-danger-text: #fff8f8;
|
||||
--color-danger: #d05a5a;
|
||||
--color-nav-text: #d8e7e1;
|
||||
--color-nav-hover: #1b332f;
|
||||
--color-status-border: #37504a;
|
||||
--color-status-text: #d8e7e1;
|
||||
--color-surface: #161f1d;
|
||||
--color-warning: #e1bd66;
|
||||
--color-warning-bg: #3a2f14;
|
||||
--color-label: #b3c2bd;
|
||||
--color-input-border: #435750;
|
||||
--color-input-bg: #101917;
|
||||
--color-sidebar-input-text: #e6f0ec;
|
||||
--color-active-text: #f8fbf9;
|
||||
--color-active: #2f967c;
|
||||
--color-panel-border: #2a3a36;
|
||||
--color-border-subtle: #344942;
|
||||
--color-panel-subtle: #1b2925;
|
||||
--color-active-surface: #17392f;
|
||||
--color-metric: #7ce3b4;
|
||||
--color-small-text: #9badb0;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -42,8 +112,8 @@ button {
|
|||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
color: #f4f7f5;
|
||||
background: #12201d;
|
||||
color: var(--color-sidebar-text);
|
||||
background: var(--color-sidebar-bg);
|
||||
}
|
||||
|
||||
.brand {
|
||||
|
|
@ -76,12 +146,12 @@ button {
|
|||
.brand p,
|
||||
.eyebrow,
|
||||
.subtle {
|
||||
color: #6f7f7a;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sidebar .brand p {
|
||||
color: #9baca6;
|
||||
color: var(--color-sidebar-muted);
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
|
|
@ -94,24 +164,25 @@ nav button,
|
|||
}
|
||||
|
||||
.primary-action {
|
||||
color: #09201a;
|
||||
background: #60d394;
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
padding: 0 14px;
|
||||
color: #25322f;
|
||||
background: #dfe7e2;
|
||||
color: var(--color-secondary-text);
|
||||
background: var(--color-secondary-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.secondary-action.danger {
|
||||
color: #fff8f8;
|
||||
background: #a64545;
|
||||
color: var(--color-danger-text);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.primary-action:disabled,
|
||||
.secondary-action:disabled,
|
||||
nav button:disabled,
|
||||
select:disabled {
|
||||
cursor: not-allowed;
|
||||
|
|
@ -125,7 +196,7 @@ nav {
|
|||
}
|
||||
|
||||
nav button {
|
||||
color: #dfe9e4;
|
||||
color: var(--color-nav-text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0 12px;
|
||||
|
|
@ -133,10 +204,11 @@ nav button {
|
|||
|
||||
nav button:hover,
|
||||
nav button.active {
|
||||
background: #223b36;
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
overflow-x: hidden;
|
||||
|
|
@ -159,19 +231,50 @@ nav button.active {
|
|||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.status {
|
||||
max-width: 420px;
|
||||
border: 1px solid #cbd5d0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: #33413d;
|
||||
background: #fbfdfb;
|
||||
overflow-wrap: anywhere;
|
||||
.toast-region {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(420px, calc(100vw - 48px));
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status.busy {
|
||||
border-color: #d4b25f;
|
||||
background: #fff7de;
|
||||
.toast {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 28px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--color-status-border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: var(--color-status-text);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 12px 32px rgb(0 0 0 / 18%);
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast.busy {
|
||||
border-color: var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.toast button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast button:hover {
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.field {
|
||||
|
|
@ -186,7 +289,7 @@ nav button.active {
|
|||
|
||||
.field span,
|
||||
.profile-row > span {
|
||||
color: #52605c;
|
||||
color: var(--color-label);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -196,11 +299,121 @@ textarea,
|
|||
select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid #bec9c4;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
color: #18201f;
|
||||
background: #ffffff;
|
||||
color: var(--color-text);
|
||||
background: var(--color-input-bg);
|
||||
}
|
||||
|
||||
.app-select-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-select-click-away {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 29;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.app-select-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-input-bg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-select.open .app-select-trigger {
|
||||
position: relative;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.sidebar .app-select-trigger {
|
||||
color: var(--color-sidebar-input-text);
|
||||
}
|
||||
|
||||
.app-select-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-select-trigger span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-select-trigger .app-select-arrow {
|
||||
color: var(--color-label);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-select-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
background: var(--color-input-bg);
|
||||
box-shadow: 0 16px 32px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.app-select-menu button {
|
||||
min-height: 36px;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-select-menu button:hover,
|
||||
.app-select-menu button.active {
|
||||
color: var(--color-active-text);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
select,
|
||||
option,
|
||||
optgroup {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
option,
|
||||
optgroup {
|
||||
color: var(--color-text);
|
||||
background: var(--color-input-bg);
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
|
@ -210,7 +423,15 @@ textarea {
|
|||
}
|
||||
|
||||
.sidebar select {
|
||||
color: #10201c;
|
||||
color: var(--color-sidebar-input-text);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select,
|
||||
option,
|
||||
optgroup {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-row {
|
||||
|
|
@ -228,13 +449,13 @@ textarea {
|
|||
|
||||
.segments button {
|
||||
padding: 0 14px;
|
||||
color: #25322f;
|
||||
background: #dfe7e2;
|
||||
color: var(--color-secondary-text);
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.segments button.active {
|
||||
color: #f8fbf9;
|
||||
background: #226957;
|
||||
color: var(--color-active-text);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
|
|
@ -245,10 +466,10 @@ textarea {
|
|||
|
||||
.panel,
|
||||
.empty-state {
|
||||
border: 1px solid #d5ddd8;
|
||||
border: 1px solid var(--color-panel-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fbfdfb;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.panel.wide,
|
||||
|
|
|
|||