diff --git a/src/gui/mod.rs b/src/gui/mod.rs index bdc7ec2a..35e0ab4f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2416,6 +2416,24 @@ impl LauncherApp { self.mouse_gestures_dialog.open_binding_editor(); } else if a.action == "mg:dialog:settings" { self.open_mouse_gesture_settings_dialog(); + } else if a.action == "mg:practice" { + let enabled = crate::plugins::mouse_gestures::toggle_practice_mode(); + if self.enable_toasts { + let label = if enabled { + "Mouse gesture practice mode enabled" + } else { + "Mouse gesture practice mode disabled" + }; + push_toast( + &mut self.toasts, + Toast { + text: label.into(), + kind: ToastKind::Info, + options: ToastOptions::default() + .duration_in_seconds(self.toast_duration as f64), + }, + ); + } } else if let Some(label) = a.action.strip_prefix("fav:dialog:") { if label.is_empty() { self.fav_dialog.open(); diff --git a/src/mouse_gestures/service.rs b/src/mouse_gestures/service.rs index dfa84add..190c9afb 100644 --- a/src/mouse_gestures/service.rs +++ b/src/mouse_gestures/service.rs @@ -1,5 +1,6 @@ use crate::mouse_gestures::db::{ - load_gestures, GestureCandidate, GestureMatchType, SharedGestureDb, GESTURES_FILE, + format_gesture_label, load_gestures, GestureCandidate, GestureMatchType, SharedGestureDb, + GESTURES_FILE, }; use crate::mouse_gestures::engine::{DirMode, GestureTracker}; use crate::mouse_gestures::overlay::{ @@ -36,6 +37,7 @@ pub struct MouseGestureConfig { pub cancel_behavior: CancelBehavior, pub no_match_behavior: NoMatchBehavior, pub wheel_cycle_gate: WheelCycleGate, + pub practice_mode: bool, } impl Default for MouseGestureConfig { @@ -59,6 +61,7 @@ impl Default for MouseGestureConfig { cancel_behavior: CancelBehavior::DoNothing, no_match_behavior: NoMatchBehavior::DoNothing, wheel_cycle_gate: WheelCycleGate::Deadzone, + practice_mode: false, } } } @@ -360,8 +363,11 @@ fn worker_loop( let mut last_trail = Instant::now(); let mut last_recognition = Instant::now(); let mut start_time = Instant::now(); + let mut cheat_sheet_visible = false; + let mut pending_selection_idx: Option = None; let mut selected_binding_idx: usize = 0; let mut cached_tokens = String::new(); + let mut cached_actions_tokens = String::new(); let mut cached_actions: Vec = Vec::new(); let mut cached_candidates: Vec = Vec::new(); let mut selection_state = load_selection_state(GESTURES_STATE_FILE); @@ -395,11 +401,14 @@ fn worker_loop( #[cfg(windows)] hook_dispatch().set_tracking(false); selected_binding_idx = 0; + pending_selection_idx = None; cached_tokens.clear(); + cached_actions_tokens.clear(); cached_actions.clear(); cached_candidates.clear(); exact_selection_key = None; exact_binding_count = 0; + cheat_sheet_visible = false; start_time = Instant::now(); let pos = cursor_provider.cursor_position().unwrap_or(start_pos); start_pos = pos; @@ -424,38 +433,49 @@ fn worker_loop( let tokens = tracker.tokens_string(); - println!( - "MG release tokens='{tokens}' mode={:?} db_present={}", - config.dir_mode, - db.is_some() - ); - if let Some(db) = &db { - if let Ok(guard) = db.lock() { - for g in &guard.gestures { - println!( - "DB: label='{}' tokens='{}' mode={:?} enabled={} bindings={}", - g.label, g.tokens, g.dir_mode, g.enabled, g.bindings.len() - ); - for b in &g.bindings { - println!(" - binding: label='{}' action='{}' args={:?} enabled={}",b.label, b.action, b.args, b.enabled); - } - } - } - } - // If we produced any tokens, treat it as a gesture (swallow right click). if !tokens.is_empty() { // Execute the currently selected binding (wheel-cycled) if there are multiple. - if let Some((_gesture_label, actions)) = + if let Some((gesture_label, actions)) = match_binding_actions(&db, &tokens, config.dir_mode) { if !actions.is_empty() { let idx = selected_binding_idx % actions.len(); + let key = selection_key(&gesture_label, &tokens); + if selection_state + .selections + .get(&key) + .copied() + .unwrap_or(usize::MAX) + != idx + { + selection_state.selections.insert(key, idx); + save_selection_state(GESTURES_STATE_FILE, &selection_state); + } if let Some(action) = actions.get(idx).cloned() { - send_event(WatchEvent::ExecuteAction(action)); + if config.practice_mode { + tracing::info!( + tokens = %tokens, + action = %action.action, + "mouse gesture practice match" + ); + } else { + send_event(WatchEvent::ExecuteAction(action)); + } } } } else { + if config.practice_mode { + let suggestion = cached_candidates + .first() + .map(|candidate| candidate.gesture_label.as_str()) + .unwrap_or("none"); + tracing::info!( + tokens = %tokens, + suggestion = suggestion, + "mouse gesture practice miss" + ); + } match config.no_match_behavior { NoMatchBehavior::DoNothing => {} NoMatchBehavior::PassThroughClick => { @@ -480,17 +500,58 @@ fn worker_loop( exceeded_deadzone = false; tracker.reset(); selected_binding_idx = 0; + pending_selection_idx = None; cached_tokens.clear(); + cached_actions_tokens.clear(); cached_actions.clear(); cached_candidates.clear(); exact_selection_key = None; exact_binding_count = 0; + cheat_sheet_visible = false; #[cfg(windows)] hook_dispatch().set_active(false); } } - HookEvent::CycleNext | HookEvent::CyclePrev | HookEvent::SelectBinding(_) => { + HookEvent::SelectBinding(idx) => { + if active { + pending_selection_idx = Some(idx); + if !cached_actions.is_empty() { + let len = cached_actions.len(); + selected_binding_idx = idx.min(len.saturating_sub(1)); + pending_selection_idx = None; + + if let Some(key) = exact_selection_key.as_ref() { + if exact_binding_count > 0 { + let stored_idx = selected_binding_idx % exact_binding_count; + if selection_state + .selections + .get(key) + .copied() + .unwrap_or(usize::MAX) + != stored_idx + { + selection_state.selections.insert(key.clone(), stored_idx); + save_selection_state(GESTURES_STATE_FILE, &selection_state); + } + } + } + + if let Some(pos) = cursor_provider.cursor_position() { + if let Some(text) = format_hint_text( + &cached_tokens, + &cached_candidates, + selected_binding_idx, + config.no_match_behavior, + config.wheel_cycle_gate, + ) { + hint_overlay.update(&text, pos); + } + } + } + } + } + HookEvent::CycleNext | HookEvent::CyclePrev => { let allow_cycle = match config.wheel_cycle_gate { WheelCycleGate::Deadzone => exceeded_deadzone, WheelCycleGate::Shift => true, @@ -504,11 +565,6 @@ fn worker_loop( HookEvent::CyclePrev if len > 1 => { selected_binding_idx = (selected_binding_idx + len - 1) % len; } - HookEvent::SelectBinding(idx) => { - if idx < len { - selected_binding_idx = idx; - } - } _ => {} } @@ -553,11 +609,14 @@ fn worker_loop( exceeded_deadzone = false; tracker.reset(); selected_binding_idx = 0; + pending_selection_idx = None; cached_tokens.clear(); + cached_actions_tokens.clear(); cached_actions.clear(); cached_candidates.clear(); exact_selection_key = None; exact_binding_count = 0; + cheat_sheet_visible = false; #[cfg(windows)] { hook_dispatch().set_tracking(false); @@ -579,10 +638,25 @@ fn worker_loop( exceeded_deadzone = true; #[cfg(windows)] hook_dispatch().set_tracking(true); + if cheat_sheet_visible { + hint_overlay.update("", pos); + cheat_sheet_visible = false; + } } trail_overlay.update_position(pos); + if !exceeded_deadzone + && cached_tokens.is_empty() + && !cheat_sheet_visible + && start_time.elapsed() >= CHEATSHEET_DELAY + { + if let Some(text) = format_cheatsheet_text(&db, CHEATSHEET_MAX_GESTURES) { + hint_overlay.update(&text, pos); + cheat_sheet_visible = true; + } + } + if last_recognition.elapsed() >= recognition_interval { let ms = start_time.elapsed().as_millis() as u64; let _ = tracker.feed_point(pos, ms); @@ -590,15 +664,29 @@ fn worker_loop( if tokens != cached_tokens { cached_tokens = tokens.to_string(); selected_binding_idx = 0; - if let Some((_gesture_label, actions)) = - match_binding_actions(&db, &tokens, config.dir_mode) + pending_selection_idx = None; + cached_candidates = + candidate_matches(&db, &tokens, config.dir_mode, MAX_HINT_CANDIDATES); + if let Some(candidate) = cached_candidates + .iter() + .find(|candidate| candidate.match_type == GestureMatchType::Exact) { - cached_actions = actions; + cached_actions_tokens = candidate.tokens.clone(); + } else { + cached_actions_tokens.clear(); + } + if !cached_actions_tokens.is_empty() { + if let Some((_gesture_label, actions)) = + match_binding_actions(&db, &cached_actions_tokens, config.dir_mode) + { + cached_actions = actions; + } else { + cached_actions.clear(); + cached_actions_tokens.clear(); + } } else { cached_actions.clear(); } - cached_candidates = - candidate_matches(&db, &tokens, config.dir_mode, MAX_HINT_CANDIDATES); } if let Some(candidate) = cached_candidates @@ -620,6 +708,31 @@ fn worker_loop( exact_binding_count = 0; } + if let Some(pending_idx) = pending_selection_idx.take() { + if !cached_actions.is_empty() { + let len = cached_actions.len(); + selected_binding_idx = pending_idx.min(len.saturating_sub(1)); + if let Some(key) = exact_selection_key.as_ref() { + if exact_binding_count > 0 { + let stored_idx = selected_binding_idx % exact_binding_count; + if selection_state + .selections + .get(key) + .copied() + .unwrap_or(usize::MAX) + != stored_idx + { + selection_state.selections.insert(key.clone(), stored_idx); + save_selection_state( + GESTURES_STATE_FILE, + &selection_state, + ); + } + } + } + } + } + if let Some(text) = format_hint_text( &tokens, &cached_candidates, @@ -628,7 +741,8 @@ fn worker_loop( config.wheel_cycle_gate, ) { hint_overlay.update(&text, pos); - } else { + cheat_sheet_visible = false; + } else if !cheat_sheet_visible { hint_overlay.update("", pos); } last_recognition = Instant::now(); @@ -668,6 +782,8 @@ fn match_binding_actions( } const MAX_HINT_CANDIDATES: usize = 5; +const CHEATSHEET_MAX_GESTURES: usize = 5; +const CHEATSHEET_DELAY: Duration = Duration::from_millis(250); #[allow(dead_code)] fn best_match_name( @@ -713,10 +829,10 @@ fn format_hint_text( } let mut lines = Vec::new(); - if let Some(candidate) = candidates + let exact_candidate = candidates .iter() - .find(|candidate| candidate.match_type == GestureMatchType::Exact) - { + .find(|candidate| candidate.match_type == GestureMatchType::Exact); + if let Some(candidate) = exact_candidate { let bindings = &candidate.bindings; let binding_count = bindings.len(); let selected_idx = if binding_count == 0 { @@ -746,6 +862,16 @@ fn format_hint_text( lines.push(tokens.to_string()); } + if exact_candidate.is_none() { + if let Some(candidate) = candidates.first() { + lines.push(format!( + "Closest: {} [{}]", + candidate.gesture_label, + match_type_label(candidate.match_type) + )); + } + } + let cycle_hint = match wheel_cycle_gate { WheelCycleGate::Deadzone => "Wheel: cycle", WheelCycleGate::Shift => "Shift+Wheel: cycle", @@ -757,6 +883,36 @@ fn format_hint_text( Some(lines.join("\n")) } +fn match_type_label(match_type: GestureMatchType) -> &'static str { + match match_type { + GestureMatchType::Exact => "exact", + GestureMatchType::Prefix => "prefix", + GestureMatchType::Fuzzy => "fuzzy", + } +} + +fn format_cheatsheet_text( + db: &Option, + limit: usize, +) -> Option { + let db = db.as_ref()?; + let guard = db.lock().ok()?; + let mut lines = Vec::new(); + lines.push("Cheat sheet".to_string()); + let mut count = 0; + for gesture in guard.gestures.iter().filter(|gesture| gesture.enabled) { + lines.push(format!("• {}", format_gesture_label(gesture))); + count += 1; + if count >= limit { + break; + } + } + if count == 0 { + lines.push("No gestures configured".to_string()); + } + Some(lines.join("\n")) +} + const GESTURES_STATE_FILE: &str = "mouse_gestures_state.json"; #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] diff --git a/src/plugins/mouse_gestures.rs b/src/plugins/mouse_gestures.rs index ed14f0fc..e0b95c81 100644 --- a/src/plugins/mouse_gestures.rs +++ b/src/plugins/mouse_gestures.rs @@ -44,6 +44,8 @@ pub struct MouseGestureSettings { pub no_match_behavior: NoMatchBehavior, #[serde(default = "default_wheel_cycle_gate")] pub wheel_cycle_gate: WheelCycleGate, + #[serde(default = "default_practice_mode")] + pub practice_mode: bool, } impl Default for MouseGestureSettings { @@ -60,6 +62,7 @@ impl Default for MouseGestureSettings { cancel_behavior: default_cancel_behavior(), no_match_behavior: default_no_match_behavior(), wheel_cycle_gate: default_wheel_cycle_gate(), + practice_mode: default_practice_mode(), } } } @@ -108,6 +111,10 @@ fn default_wheel_cycle_gate() -> WheelCycleGate { WheelCycleGate::Deadzone } +fn default_practice_mode() -> bool { + false +} + #[derive(Debug)] struct MouseGestureRuntime { settings: MouseGestureSettings, @@ -163,6 +170,7 @@ impl MouseGestureRuntime { config.cancel_behavior = self.settings.cancel_behavior; config.no_match_behavior = self.settings.no_match_behavior; config.wheel_cycle_gate = self.settings.wheel_cycle_gate; + config.practice_mode = self.settings.practice_mode; with_gesture_service(|svc| { svc.update_config(config); svc.update_db(Some(self.db.clone())); @@ -170,6 +178,16 @@ impl MouseGestureRuntime { } } +pub fn toggle_practice_mode() -> bool { + let mut enabled = false; + with_service(|svc| { + svc.settings.practice_mode = !svc.settings.practice_mode; + enabled = svc.settings.practice_mode; + svc.apply(); + }); + enabled +} + static SERVICE: OnceCell> = OnceCell::new(); fn with_service(f: F) @@ -250,6 +268,12 @@ impl MouseGesturesPlugin { action: "query:mg conflicts".into(), args: None, }, + Action { + label: "mg practice".into(), + desc: "Toggle mouse gesture practice mode".into(), + action: "mg:practice".into(), + args: None, + }, ] } @@ -326,6 +350,14 @@ impl Plugin for MouseGesturesPlugin { args: None, }]; } + if strip_prefix_ci(trimmed, "mg practice").is_some() { + return vec![Action { + label: "Toggle mouse gesture practice mode".into(), + desc: "Mouse gestures".into(), + action: "mg:practice".into(), + args: None, + }]; + } if let Some(rest) = strip_prefix_ci(trimmed, "mg find") { let query = rest.trim(); let db = load_gestures(GESTURES_FILE).unwrap_or_default(); diff --git a/tests/mouse_gestures_plugin.rs b/tests/mouse_gestures_plugin.rs index 4b5dd7ba..620c6685 100644 --- a/tests/mouse_gestures_plugin.rs +++ b/tests/mouse_gestures_plugin.rs @@ -16,7 +16,8 @@ fn mouse_gestures_commands_match_expected_labels() { "mg list", "mg find", "mg where", - "mg conflicts" + "mg conflicts", + "mg practice" ] ); let action_strings: Vec<_> = actions.iter().map(|a| a.action.as_str()).collect(); @@ -31,6 +32,7 @@ fn mouse_gestures_commands_match_expected_labels() { "query:mg find ", "query:mg where ", "query:mg conflicts", + "mg:practice", ] ); } diff --git a/tests/mouse_gestures_service.rs b/tests/mouse_gestures_service.rs index a7449646..88652374 100644 --- a/tests/mouse_gestures_service.rs +++ b/tests/mouse_gestures_service.rs @@ -7,12 +7,16 @@ use multi_launcher::mouse_gestures::service::{ CancelBehavior, CursorPositionProvider, HookEvent, MockHookBackend, MouseGestureConfig, MouseGestureService, NoMatchBehavior, OverlayFactory, RightClickBackend, }; +use multi_launcher::gui::register_event_sender; +use once_cell::sync::Lazy; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::sleep; -use std::time::Duration; +use std::time::{Duration, Instant}; use tempfile::tempdir; +static TEST_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); + #[derive(Default)] struct TestOverlayState { trail_clears: AtomicUsize, @@ -149,6 +153,21 @@ impl CursorPositionProvider for TestCursorProvider { } } +fn wait_for_hint(state: &HintRecordingState, timeout: Duration) -> Option { + let start = Instant::now(); + loop { + if let Ok(guard) = state.hints.lock() { + if let Some(last) = guard.last() { + return Some(last.clone()); + } + } + if start.elapsed() >= timeout { + return None; + } + sleep(Duration::from_millis(5)); + } +} + #[test] fn start_stop_installs_and_uninstalls_once() { let (backend, handle) = MockHookBackend::new(); @@ -206,12 +225,12 @@ fn cancel_event_clears_overlays_and_does_not_click() { service.update_config(config); assert!(handle.emit(HookEvent::RButtonDown)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); let clears_before = overlay_state.trail_clears.load(Ordering::SeqCst); let hides_before = overlay_state.hint_hides.load(Ordering::SeqCst); assert!(handle.emit(HookEvent::Cancel)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); let clears_after = overlay_state.trail_clears.load(Ordering::SeqCst); let hides_after = overlay_state.hint_hides.load(Ordering::SeqCst); @@ -253,7 +272,7 @@ fn no_match_pass_through_click_sends_right_click() { sleep(Duration::from_millis(5)); cursor_provider.set_position((50.0, 0.0)); assert!(handle.emit(HookEvent::RButtonUp)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); assert_eq!(click_backend.clicks.load(Ordering::SeqCst), 1); @@ -291,7 +310,7 @@ fn no_match_noop_does_not_send_right_click() { sleep(Duration::from_millis(5)); cursor_provider.set_position((50.0, 0.0)); assert!(handle.emit(HookEvent::RButtonUp)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); assert_eq!(click_backend.clicks.load(Ordering::SeqCst), 0); @@ -347,20 +366,154 @@ fn hint_text_includes_best_guess_and_match_type() { assert!(handle.emit(HookEvent::RButtonDown)); sleep(Duration::from_millis(5)); cursor_provider.set_position((50.0, 0.0)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); let hints = hint_state.hints.lock().expect("lock hints"); let last = hints.last().expect("hint text"); assert_eq!( last, - "R\nWheel: cycle • 1-9: select • Release: run • Esc: cancel" + "R\nWheel: cycle • 1-9: select • Release: run • Esc: cancel\nClosest: Open Browser [prefix]" + ); + + service.stop(); +} + +#[test] +fn practice_mode_suppresses_execute_action() { + let (backend, handle) = MockHookBackend::new(); + let hint_state = Arc::new(HintRecordingState::default()); + let overlay_factory: Arc = Arc::new(HintRecordingFactory { + state: Arc::clone(&hint_state), + }); + let click_backend = Arc::new(TestRightClickBackend::default()); + let click_backend_trait: Arc = click_backend.clone(); + let cursor_provider = Arc::new(TestCursorProvider::new((0.0, 0.0))); + let cursor_provider_trait: Arc = cursor_provider.clone(); + + let mut service = MouseGestureService::new_with_backend_and_overlays( + Box::new(backend), + overlay_factory, + Arc::clone(&click_backend_trait), + Arc::clone(&cursor_provider_trait), + ); + + let db = GestureDb { + schema_version: SCHEMA_VERSION, + gestures: vec![GestureEntry { + label: "Open Browser".into(), + tokens: "R".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: vec![BindingEntry { + label: "Open Browser".into(), + kind: BindingKind::Execute, + action: "stopwatch:show:1".into(), + args: None, + enabled: true, + }], + }], + }; + service.update_db(Some(Arc::new(Mutex::new(db)))); + + let mut config = MouseGestureConfig::default(); + config.enabled = true; + config.practice_mode = true; + config.threshold_px = 1.0; + config.deadzone_px = 0.1; + config.trail_interval_ms = 1; + config.recognition_interval_ms = 1; + service.update_config(config); + + let (tx, rx) = std::sync::mpsc::channel(); + register_event_sender(tx); + + assert!(handle.emit(HookEvent::RButtonDown)); + sleep(Duration::from_millis(5)); + cursor_provider.set_position((50.0, 0.0)); + assert!(handle.emit(HookEvent::RButtonUp)); + sleep(Duration::from_millis(20)); + + assert!(rx.recv_timeout(Duration::from_millis(20)).is_err()); + + service.stop(); +} + +#[test] +fn cheat_sheet_overlay_shows_after_delay_without_tokens() { + let (backend, handle) = MockHookBackend::new(); + let hint_state = Arc::new(HintRecordingState::default()); + let overlay_factory: Arc = Arc::new(HintRecordingFactory { + state: Arc::clone(&hint_state), + }); + let click_backend = Arc::new(TestRightClickBackend::default()); + let click_backend_trait: Arc = click_backend.clone(); + let cursor_provider = Arc::new(TestCursorProvider::new((0.0, 0.0))); + let cursor_provider_trait: Arc = cursor_provider.clone(); + + let mut service = MouseGestureService::new_with_backend_and_overlays( + Box::new(backend), + overlay_factory, + Arc::clone(&click_backend_trait), + Arc::clone(&cursor_provider_trait), ); + let db = GestureDb { + schema_version: SCHEMA_VERSION, + gestures: vec![ + GestureEntry { + label: "Open Browser".into(), + tokens: "R".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: vec![BindingEntry { + label: "Open Browser".into(), + kind: BindingKind::Execute, + action: "stopwatch:show:1".into(), + args: None, + enabled: true, + }], + }, + GestureEntry { + label: "Close Window".into(), + tokens: "L".into(), + dir_mode: DirMode::Four, + stroke: Vec::new(), + enabled: true, + bindings: vec![BindingEntry { + label: "Close Window".into(), + kind: BindingKind::Execute, + action: "window:close".into(), + args: None, + enabled: true, + }], + }, + ], + }; + service.update_db(Some(Arc::new(Mutex::new(db)))); + + let mut config = MouseGestureConfig::default(); + config.enabled = true; + config.deadzone_px = 100.0; + config.trail_interval_ms = 10; + config.recognition_interval_ms = 10; + service.update_config(config); + + assert!(handle.emit(HookEvent::RButtonDown)); + sleep(Duration::from_millis(300)); + + let hints = hint_state.hints.lock().expect("lock hints"); + let last = hints.last().expect("hint text"); + assert!(last.contains("Cheat sheet")); + assert!(last.contains("Open Browser")); + service.stop(); } #[test] fn selection_persists_across_gesture_sessions() { + let _lock = TEST_MUTEX.lock().unwrap(); let dir = tempdir().expect("tempdir"); std::env::set_current_dir(dir.path()).expect("set current dir"); @@ -445,18 +598,18 @@ fn selection_persists_across_gesture_sessions() { assert!(handle.emit(HookEvent::RButtonDown)); sleep(Duration::from_millis(5)); cursor_provider.set_position((50.0, 0.0)); - sleep(Duration::from_millis(20)); + sleep(Duration::from_millis(50)); - let hints = hint_state.hints.lock().expect("lock hints"); - let last = hints.last().expect("hint text"); - let first_line = last.lines().next().expect("first line"); - assert!(first_line.contains("Secondary")); + let hint_text = + wait_for_hint(&hint_state, Duration::from_millis(500)).expect("hint text"); + assert!(hint_text.contains("Secondary")); service.stop(); } #[test] fn numeric_selection_updates_hint_text() { + let _lock = TEST_MUTEX.lock().unwrap(); let dir = tempdir().expect("tempdir"); std::env::set_current_dir(dir.path()).expect("set current dir");