diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 4f162f3..cf2a9ef 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -21,10 +21,9 @@ use hyperdeck_core::{clipsource, stateprobe}; use hyperdeck_os::injector; use hyperdeck_os::vlchttp::VlcController; -/// Handles the UI needs once the backend is running. `lock_manager`, `selection`, -/// `profile_ids`, and `active` are consumed by the tray profile submenu (added -/// next); `deck` powers Re-home today. -#[allow(dead_code)] +/// Handles the UI needs once the backend is running: `deck` powers Re-home, and +/// `lock_manager` / `selection` / `profile_ids` / `active` drive the tray Profile +/// submenu (pin a profile, persist it, move the checkmark). pub struct Backend { pub deck: Arc, pub lock_manager: Arc, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b03512e..c5016ce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,7 @@ mod backend; use std::sync::Arc; use std::time::Duration; -use tauri::menu::{Menu, MenuItem}; +use tauri::menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::TrayIconBuilder; use hyperdeck_core::domain::LockState; @@ -69,17 +69,65 @@ fn run_tray() { let backend = backend::start(presenter, BIND, POLL) .map_err(|e| -> Box { e.into() })?; - build_tray(app, backend.deck)?; + build_tray(app, backend)?; Ok(()) }) .run(tauri::generate_context!()) .expect("error while running HyperDeck Adapter"); } -/// Builds the system-tray menu (Re-home, Check for Updates…, Quit). -fn build_tray(app: &tauri::App, deck: Arc) -> tauri::Result<()> { +/// Builds the system-tray menu: a Profile submenu (Auto + one entry per profile), +/// Re-home, Check for Updates…, and Quit. +fn build_tray(app: &tauri::App, backend: backend::Backend) -> tauri::Result<()> { use hyperdeck_core::port::Transport; + let backend::Backend { + deck, + lock_manager, + selection, + profile_ids, + active, + } = backend; + + // Profile submenu: "Auto (match any)" plus one checkbox per profile id, with + // the checkmark on the persisted selection (Auto when none / unknown). + let checked = checked_profile(&profile_ids, &active); + let mut profile_items: Vec> = + Vec::with_capacity(profile_ids.len() + 1); + profile_items.push(CheckMenuItem::with_id( + app, + "profile:", + "Auto (match any)", + true, + checked.is_empty(), + None::<&str>, + )?); + for id in &profile_ids { + profile_items.push(CheckMenuItem::with_id( + app, + format!("profile:{id}"), + id, + true, + checked == *id, + None::<&str>, + )?); + } + let item_refs: Vec<&dyn IsMenuItem> = profile_items + .iter() + .map(|i| i as &dyn IsMenuItem) + .collect(); + let profile_menu = + Submenu::with_id_and_items(app, "profile_menu", "Profile", true, &item_refs)?; + + // (id, item) pairs for moving the checkmark on selection; "" is the Auto entry. + let mut checks: Vec<(String, CheckMenuItem)> = + Vec::with_capacity(profile_items.len()); + checks.push((String::new(), profile_items[0].clone())); + for (i, id) in profile_ids.iter().enumerate() { + checks.push((id.clone(), profile_items[i + 1].clone())); + } + + let sep = PredefinedMenuItem::separator(app)?; let rehome = MenuItem::with_id(app, "rehome", "Re-home", true, None::<&str>)?; let check = MenuItem::with_id( app, @@ -89,32 +137,66 @@ fn build_tray(app: &tauri::App, deck: Arc) -> None::<&str>, )?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&rehome, &check, &quit])?; + let menu = Menu::with_items(app, &[&profile_menu, &sep, &rehome, &check, &quit])?; let icon = app.default_window_icon().expect("bundle icon").clone(); TrayIconBuilder::with_id("main") .icon(icon) .tooltip("HyperDeck Adapter") .menu(&menu) - .on_menu_event(move |app, event| match event.id.as_ref() { - "rehome" => { - if let Err(e) = deck.rehome() { - log::warn!("re-home failed: {e}"); - } + .on_menu_event(move |app, event| { + let id = event.id.as_ref(); + if let Some(profile_id) = id.strip_prefix("profile:") { + select_profile(&lock_manager, &selection, &checks, profile_id); + return; } - "check_update" => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - check_for_updates(handle).await; - }); + match id { + "rehome" => { + if let Err(e) = deck.rehome() { + log::warn!("re-home failed: {e}"); + } + } + "check_update" => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + check_for_updates(handle).await; + }); + } + "quit" => app.exit(0), + _ => {} } - "quit" => app.exit(0), - _ => {} }) .build(app)?; Ok(()) } +/// Pins `id` (empty = Auto): persists the selection, re-points the lock manager +/// (which re-polls immediately), and moves the checkmark to the chosen entry. +fn select_profile( + lock_manager: &hyperdeck_core::app::LockManager, + selection: &hyperdeck_core::config::SelectionStore, + checks: &[(String, CheckMenuItem)], + id: &str, +) { + if let Err(e) = selection.save(id) { + log::warn!("persist profile selection failed: {e}"); + } + lock_manager.set_active(id); + for (key, item) in checks { + let _ = item.set_checked(key == id); + } +} + +/// Port of the Go `checkedProfile`: the id whose entry shows a checkmark — the +/// active id when it names a known profile, otherwise "" (the Auto entry). +fn checked_profile(profiles: &[String], active: &str) -> String { + if active.is_empty() || !profiles.iter().any(|p| p == active) { + String::new() + } else { + active.to_string() + } +} + /// Checks for, downloads, verifies, and installs an update, then relaunches. async fn check_for_updates(app: tauri::AppHandle) { use tauri_plugin_updater::UpdaterExt; @@ -168,3 +250,27 @@ fn status_text(lock: &LockState) -> String { _ => "Disconnected — no player".to_string(), } } + +#[cfg(test)] +mod tests { + use super::checked_profile; + + fn ids() -> Vec { + vec!["vlc".to_string(), "mitti".to_string()] + } + + #[test] + fn empty_active_is_auto() { + assert_eq!(checked_profile(&ids(), ""), ""); + } + + #[test] + fn known_active_is_checked() { + assert_eq!(checked_profile(&ids(), "mitti"), "mitti"); + } + + #[test] + fn unknown_active_falls_back_to_auto() { + assert_eq!(checked_profile(&ids(), "deleted"), ""); + } +}