razer-linux-desktop/src/app/root.rs
2026-05-17 21:33:41 +02:00

262 lines
11 KiB
Rust

#[component]
pub fn App() -> impl IntoView {
let devices = RwSignal::new(Vec::<DeviceSummary>::new());
let selected_path = RwSignal::new(String::new());
let selected_profile = RwSignal::new("direct".to_string());
let device_state = RwSignal::new(None::<DeviceState>);
let active_tab = RwSignal::new("basic".to_string());
let status = RwSignal::new("Scanning for a supported Basilisk mouse.".to_string());
let busy = RwSignal::new(false);
let connect_path = move |path: String| {
if path.is_empty() {
status.set("Select a device path before connecting.".to_string());
return;
}
busy.set(true);
status.set(format!("Connecting to {path}..."));
spawn_local(async move {
let args = ConnectArgs { path };
match invoke::<DeviceState, _>("connect_device", &args).await {
Ok(snapshot) => {
selected_profile.set(snapshot.basic.profile.clone());
status.set(format!("Connected to {}.", snapshot.device.supported_name));
device_state.set(Some(snapshot));
}
Err(error) => status.set(error),
}
busy.set(false);
});
};
let scan = move || {
busy.set(true);
status.set("Scanning /sys/class/hidraw for Basilisk V3 devices...".to_string());
spawn_local(async move {
match invoke_no_args::<Vec<DeviceSummary>>("list_supported_devices").await {
Ok(found) => {
if let Some(first) = found.first() {
let path = first.path.clone();
selected_path.set(path.clone());
devices.set(found);
status.set(format!("Found supported device. Connecting to {path}..."));
match invoke::<DeviceState, _>("connect_device", &ConnectArgs { path }).await {
Ok(snapshot) => {
selected_profile.set(snapshot.basic.profile.clone());
status.set(format!("Connected to {}.", snapshot.device.supported_name));
device_state.set(Some(snapshot));
}
Err(error) => status.set(error),
}
} else {
selected_path.set(String::new());
devices.set(found);
status.set("No supported Razer hidraw devices found.".to_string());
}
}
Err(error) => status.set(error),
}
busy.set(false);
});
};
Effect::new(move |_| scan());
let connect = move |_| {
let path = selected_path.get_untracked();
connect_path(path);
};
let refresh_profile = move |profile: String| {
selected_profile.set(profile.clone());
busy.set(true);
status.set(format!("Loading {profile} profile..."));
spawn_local(async move {
let args = ProfileArgs { profile };
match invoke::<DeviceState, _>("refresh_device_state", &args).await {
Ok(snapshot) => {
status.set("Profile settings loaded.".to_string());
device_state.set(Some(snapshot));
}
Err(error) => status.set(error),
}
busy.set(false);
});
};
view! {
<main class="app-shell">
<aside class="sidebar">
<div class="brand">
<img src="public/snakemouse.svg" alt="" />
<div>
<h1>"Razer Basilisk V3"</h1>
<p>"Onboard memory tools"</p>
</div>
</div>
<button class="primary-action" type="button" on:click=move |_| scan() disabled=move || busy.get()>
"Scan"
</button>
<label class="field">
<span>"Device"</span>
<select
prop:value=move || selected_path.get()
on:change=move |ev| selected_path.set(event_target_value(&ev))
disabled=move || busy.get()
>
<option value="">"No supported device selected"</option>
{move || devices.get().into_iter().map(|device| {
let label = format!(
"{} - {} ({:04x}:{:04x}){}",
device.path,
device.supported_name,
0x1532,
device.product_id,
device
.serial
.as_ref()
.map(|serial| format!(" - {serial}"))
.unwrap_or_default()
);
view! {
<option value=device.path.clone()>{label}</option>
}
}).collect_view()}
</select>
</label>
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
"Connect"
</button>
<nav>
<button class:active=move || active_tab.get() == "basic" on:click=move |_| active_tab.set("basic".to_string())>"Basic"</button>
<button class:active=move || active_tab.get() == "led" on:click=move |_| active_tab.set("led".to_string())>"LED"</button>
<button class:active=move || active_tab.get() == "button" on:click=move |_| active_tab.set("button".to_string())>"Button"</button>
<button class:active=move || active_tab.get() == "profile" on:click=move |_| active_tab.set("profile".to_string())>"Profiles"</button>
<button class:active=move || active_tab.get() == "macro" on:click=move |_| active_tab.set("macro".to_string())>"Macros"</button>
<button class:active=move || active_tab.get() == "sensor" on:click=move |_| active_tab.set("sensor".to_string())>"Sensor"</button>
<button class:active=move || active_tab.get() == "info" on:click=move |_| active_tab.set("info".to_string())>"Info"</button>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">"Linux desktop configuration"</p>
<h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2>
</div>
<div class="status" class:busy=move || busy.get()>{move || status.get()}</div>
</header>
{move || match device_state.get() {
Some(snapshot) => view! {
<ConnectedView
snapshot=snapshot
active_tab=active_tab
selected_profile=selected_profile
refresh_profile=refresh_profile
set_snapshot=device_state.write_only()
set_status=status.write_only()
set_busy=busy.write_only()
/>
}.into_any(),
None => view! {
<section class="empty-state">
<h3>"Connect to a Basilisk V3 or V3 Pro"</h3>
<p>"This app uses Linux hidraw feature reports through the Tauri backend instead of WebHID and Pyodide."</p>
<p>"If your mouse is connected but does not appear, check that the current user can read and write the matching /dev/hidraw node."</p>
</section>
}.into_any()
}}
</section>
</main>
}
}
#[component]
fn ConnectedView(
snapshot: DeviceState,
active_tab: RwSignal<String>,
selected_profile: RwSignal<String>,
refresh_profile: impl Fn(String) + Copy + 'static,
set_snapshot: WriteSignal<Option<DeviceState>>,
set_status: WriteSignal<String>,
set_busy: WriteSignal<bool>,
) -> impl IntoView {
view! {
<div class="profile-row">
<span>"Profile"</span>
<div class="segments">
{snapshot.profiles.iter().map(|profile| {
let profile_name = profile.clone();
let profile_for_click = profile.clone();
view! {
<button
type="button"
class:active=move || selected_profile.get() == profile_name
on:click=move |_| refresh_profile(profile_for_click.clone())
>
{profile.clone()}
</button>
}
}).collect_view()}
</div>
</div>
{move || match active_tab.get().as_str() {
"basic" => view! {
<BasicPanel
snapshot=snapshot.clone()
selected_profile=selected_profile
set_snapshot=set_snapshot
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
"info" => view! { <InfoPanel snapshot=snapshot.clone() /> }.into_any(),
"profile" => view! {
<ProfilePanel
snapshot=snapshot.clone()
selected_profile=selected_profile
set_snapshot=set_snapshot
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
"led" => view! {
<LedPanel
snapshot=snapshot.clone()
selected_profile=selected_profile
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
"button" => view! {
<ButtonPanel
snapshot=snapshot.clone()
selected_profile=selected_profile
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
"macro" => view! {
<MacroPanel
snapshot=snapshot.clone()
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
"sensor" => view! {
<SensorPanel
snapshot=snapshot.clone()
set_status=set_status
set_busy=set_busy
/>
}.into_any(),
_ => view! { <BacklogPanel title="Unknown Section" body="Select a section from the sidebar." /> }.into_any(),
}}
}
}