diff --git a/client/Cargo.toml b/client/Cargo.toml index 2f90aa0..75751ff 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.12.0" +version = "0.12.1" edition = "2024" [dependencies] diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 8434e0d..01ad680 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -2,7 +2,10 @@ use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::fmt::Write as _; -use super::state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode}; +use super::{ + devices::CameraMode, + state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode}, +}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PerformanceSample { @@ -88,6 +91,13 @@ impl DiagnosticsLog { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MediaChannelState { + pub camera: bool, + pub microphone: bool, + pub audio: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotReport { pub client_version: String, @@ -138,9 +148,12 @@ pub struct SnapshotReport { pub right_decoded_caps_label: String, pub right_rendered_caps_label: String, pub selected_camera: Option, + pub camera_quality_label: String, pub selected_microphone: Option, pub selected_speaker: Option, + pub media_channels: MediaChannelState, pub audio_gain_label: String, + pub mic_gain_label: String, pub selected_keyboard: Option, pub selected_mouse: Option, pub status: String, @@ -353,9 +366,19 @@ impl SnapshotReport { }) .unwrap_or_else(|| "pending".to_string()), selected_camera: state.devices.camera.clone(), + camera_quality_label: state + .camera_quality + .map(CameraMode::short_label) + .unwrap_or_else(|| "default".to_string()), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), + media_channels: MediaChannelState { + camera: state.channels.camera, + microphone: state.channels.microphone, + audio: state.channels.audio, + }, audio_gain_label: state.audio_gain_label(), + mic_gain_label: state.mic_gain_label(), selected_keyboard: state.devices.keyboard.clone(), selected_mouse: state.devices.mouse.clone(), status: state.status_line(), @@ -383,16 +406,19 @@ impl SnapshotReport { let _ = writeln!(text, "server: {server_version} ({server_state})"); let _ = writeln!( text, - "session: routing={:?} view={:?} relay_active={} power={}", - self.routing, self.view_mode, self.remote_active, self.power_state + "session: routing={:?} view={:?} relay={} capture_power={}", + self.routing, + self.view_mode, + if self.remote_active { "active" } else { "idle" }, + self.power_state ); let _ = writeln!( text, - "runtime pressure: client={:.1}% server={:.1}%", + "runtime: client CPU {:.1}% | server CPU {:.1}%", self.client_process_cpu_pct, self.server_process_cpu_pct ); let _ = writeln!(text, "source feed: {}", self.preview_source); - let _ = writeln!(text, "client display limit: {}", self.client_display_limit); + let _ = writeln!(text, "display limit: {}", self.client_display_limit); let _ = writeln!(text); let _ = writeln!(text, "left eye"); let _ = writeln!(text, " surface: {}", self.left_surface); @@ -451,23 +477,28 @@ impl SnapshotReport { self.right_server_queue_peak ); let _ = writeln!(text); - let _ = writeln!(text, "device staging"); + let _ = writeln!(text, "media staging"); let _ = writeln!( text, - " camera: {}", - self.selected_camera.as_deref().unwrap_or("auto") + " camera: {} | quality={} | enabled={}", + self.selected_camera.as_deref().unwrap_or("auto"), + self.camera_quality_label, + self.media_channels.camera ); let _ = writeln!( text, - " microphone: {}", - self.selected_microphone.as_deref().unwrap_or("auto") + " speaker: {} | volume={} | enabled={}", + self.selected_speaker.as_deref().unwrap_or("auto"), + self.audio_gain_label, + self.media_channels.audio ); let _ = writeln!( text, - " speaker: {}", - self.selected_speaker.as_deref().unwrap_or("auto") + " microphone: {} | gain={} | enabled={}", + self.selected_microphone.as_deref().unwrap_or("auto"), + self.mic_gain_label, + self.media_channels.microphone ); - let _ = writeln!(text, " audio gain: {}", self.audio_gain_label); let _ = writeln!( text, " keyboard: {}", @@ -479,7 +510,7 @@ impl SnapshotReport { self.selected_mouse.as_deref().unwrap_or("all") ); let _ = writeln!(text); - let _ = writeln!(text, "launcher status"); + let _ = writeln!(text, "current UI state"); let _ = writeln!(text, " {}", self.status); let _ = writeln!(text); let _ = writeln!(text, "recent samples"); @@ -931,9 +962,38 @@ mod tests { assert!(text.contains("stream caps:")); assert!(text.contains("decoded caps:")); assert!(text.contains("rendered caps:")); + assert!(text.contains("media staging")); + assert!(text.contains("current UI state")); assert!(text.contains("recommendations")); } + #[test] + #[doc = "Verifies diagnostics text follows live media settings."] + fn snapshot_text_reflects_live_media_control_changes() { + let mut state = LauncherState::new(); + state.select_camera(Some("/dev/video9".to_string())); + state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new( + 1920, 1080, 30, + ))); + state.select_microphone(Some("alsa_input.usb".to_string())); + state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_audio_gain_percent(250); + state.set_mic_gain_percent(125); + state.set_camera_channel_enabled(false); + state.set_microphone_channel_enabled(true); + + let report = SnapshotReport::from_state( + &state, + &DiagnosticsLog::new(1), + quality_probe_command().to_string(), + ); + let text = report.to_pretty_text(); + + assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false")); + assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true")); + assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true")); + } + #[test] fn snapshot_report_uses_effective_mirrored_capture_profile() { let mut state = LauncherState::new(); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 9847fb9..2605a7b 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -15,21 +15,21 @@ use { MAX_MIC_GAIN_PERCENT, }, super::ui_components::{ - build_launcher_view, sync_camera_quality_combo, sync_input_device_combo, + ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo, sync_stage_device_combo, }, super::ui_runtime::{ - RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams, - audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log, - dock_all_displays_to_preview, dock_display_to_preview, input_control_path, - input_state_path, input_toggle_control_path, mic_gain_control_path, next_input_routing, - open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker, - present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui, - refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr, - shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label, - update_test_action_result, uplink_camera_preview_path, uplink_mic_level_path, - write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request, - write_mic_gain_request, + RelayChild, append_session_log_for_level, apply_popout_window_size, + attach_child_log_streams, audio_gain_control_path, capture_swap_key, copy_plain_text, + copy_session_log, dock_all_displays_to_preview, dock_display_to_preview, + input_control_path, input_state_path, input_toggle_control_path, mic_gain_control_path, + next_input_routing, open_diagnostics_popout, open_popout_window, open_session_log_popout, + path_marker, present_popout_windows, read_input_routing_state, reap_exited_child, + refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value, + selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process, + toggle_key_label, update_test_action_result, uplink_camera_preview_path, + uplink_mic_level_path, write_audio_gain_request, write_input_routing_request, + write_input_toggle_key_request, write_mic_gain_request, }, crate::handshake::{HandshakeProbe, probe}, crate::output::display::enumerate_monitors, @@ -1790,6 +1790,22 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let widgets = widgets.clone(); + widgets.console_level_combo.connect_changed(move |combo| { + let level = combo + .active_id() + .as_deref() + .and_then(ConsoleLogLevel::from_id) + .unwrap_or_default(); + *widgets.session_log_level.borrow_mut() = level; + widgets.status_label.set_text(&format!( + "Console now shows {} relay logs and higher.", + level.label() + )); + }); + } + { let widgets = widgets.clone(); widgets.console_copy_button.connect_clicked(move |_| { @@ -2212,16 +2228,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let power_mode = state.borrow().capture_power.mode.clone(); let message = match power_mode.as_str() { "forced-off" => format!( - "Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.", - routing + "Relay connected with inputs routed to {routing}, but capture is forced off. Return capture to Auto or Force On when you want remote video." ), "forced-on" => format!( - "Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.", - routing + "Relay connected with inputs routed to {routing}. Capture is being held awake and the eye previews are coming online." ), _ => format!( - "Relay connected with inputs routed to {}. The eye previews will come up with the live session.", - routing + "Relay connected with inputs routed to {routing}. The eye previews will come up with the live session." ), }; widgets.status_label.set_text(&message); @@ -2252,11 +2265,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } while let Ok(line) = log_rx.try_recv() { - append_session_log(&widgets.session_log_buffer, &line); - let mut end = widgets.session_log_buffer.end_iter(); - widgets - .session_log_view - .scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); + let level = *widgets.session_log_level.borrow(); + if append_session_log_for_level(&widgets.session_log_buffer, &line, level) + { + let mut end = widgets.session_log_buffer.end_iter(); + widgets + .session_log_view + .scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); + } } while let Ok(message) = power_rx.try_recv() { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index e26901f..f6effc9 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -48,6 +48,76 @@ pub struct PopoutWindowHandle { pub binding: PreviewBinding, } +/// Minimum severity for relay log lines shown in the session console. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ConsoleLogLevel { + Error, + #[default] + Warn, + Info, + Debug, + Trace, +} + +impl ConsoleLogLevel { + pub const ALL: [Self; 5] = [ + Self::Error, + Self::Warn, + Self::Info, + Self::Debug, + Self::Trace, + ]; + + /// Stable value stored on the GTK combo row. + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } + + /// Short label displayed in the launcher dropdown. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Error => "Error", + Self::Warn => "Warn", + Self::Info => "Info", + Self::Debug => "Debug", + Self::Trace => "Trace", + } + } + + #[must_use] + #[doc = "Parses a GTK combo id back into a log level."] + pub fn from_id(raw: &str) -> Option { + match raw { + "error" => Some(Self::Error), + "warn" => Some(Self::Warn), + "info" => Some(Self::Info), + "debug" => Some(Self::Debug), + "trace" => Some(Self::Trace), + _ => None, + } + } + + /// Numeric ordering where lower values are more important. + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Error => 0, + Self::Warn => 1, + Self::Info => 2, + Self::Debug => 3, + Self::Trace => 4, + } + } +} + #[derive(Clone)] pub struct LauncherWidgets { pub status_label: gtk::Label, @@ -96,6 +166,8 @@ pub struct LauncherWidgets { pub diagnostics_popout_button: gtk::Button, pub console_copy_button: gtk::Button, pub console_popout_button: gtk::Button, + pub console_level_combo: gtk::ComboBoxText, + pub session_log_level: Rc>, pub _device_body_height_group: gtk::SizeGroup, } @@ -161,15 +233,23 @@ pub fn build_launcher_view( hero.set_hexpand(true); let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + brand_box.set_valign(gtk::Align::Center); let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); brand_row.set_halign(gtk::Align::Start); + brand_row.set_valign(gtk::Align::Center); + let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME); + brand_icon.add_css_class("app-logo"); + brand_icon.set_pixel_size(28); + brand_icon.set_valign(gtk::Align::Center); let heading = gtk::Label::new(Some("Lesavka")); heading.add_css_class("title-2"); heading.set_halign(gtk::Align::Start); + heading.set_valign(gtk::Align::Center); let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION))); version_tag.add_css_class("version-tag"); version_tag.set_halign(gtk::Align::Start); - version_tag.set_valign(gtk::Align::End); + version_tag.set_valign(gtk::Align::Center); + brand_row.append(&brand_icon); brand_row.append(&heading); brand_row.append(&version_tag); brand_box.append(&brand_row); @@ -226,9 +306,7 @@ pub fn build_launcher_view( let device_refresh_button = gtk::Button::with_label("Refresh Devices"); stabilize_button(&device_refresh_button, 132); - device_refresh_button.set_tooltip_text(Some( - "Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.", - )); + device_refresh_button.set_tooltip_text(Some("Re-scan connected devices.")); let (devices_panel, devices_body) = build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref())); devices_panel.set_hexpand(true); @@ -253,14 +331,10 @@ pub fn build_launcher_view( state.selected_camera_quality(catalog), ); camera_quality_combo.set_size_request(88, -1); - camera_quality_combo.set_tooltip_text(Some( - "Choose the webcam quality Lesavka should capture and send to the relay host.", - )); + camera_quality_combo.set_tooltip_text(Some("Webcam uplink quality.")); let camera_test_button = gtk::Button::with_label("Start Preview"); stabilize_button(&camera_test_button, 118); - camera_test_button.set_tooltip_text(Some( - "Open a local preview for the selected webcam so you can confirm the right source.", - )); + camera_test_button.set_tooltip_text(Some("Preview selected webcam locally.")); let speaker_combo = gtk::ComboBoxText::new(); sync_stage_device_combo( @@ -270,9 +344,7 @@ pub fn build_launcher_view( ); let speaker_test_button = gtk::Button::with_label("Play Tone"); stabilize_button(&speaker_test_button, 118); - speaker_test_button.set_tooltip_text(Some( - "Play a short continuous tone through the selected speaker until you stop the test.", - )); + speaker_test_button.set_tooltip_text(Some("Play a local test tone.")); let keyboard_combo = gtk::ComboBoxText::new(); keyboard_combo.append(Some("all"), "all keyboards"); @@ -280,9 +352,7 @@ pub fn build_launcher_view( append_input_choice(&keyboard_combo, keyboard); } super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref()); - keyboard_combo.set_tooltip_text(Some( - "Leave this on all keyboards to relay every keyboard, or pick one specific device.", - )); + keyboard_combo.set_tooltip_text(Some("Keyboard source for relay input.")); let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo); control_stack.append(&keyboard_row); @@ -292,9 +362,7 @@ pub fn build_launcher_view( append_input_choice(&mouse_combo, mouse); } super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref()); - mouse_combo.set_tooltip_text(Some( - "Leave this on all mice to relay every pointer, or pick one specific device.", - )); + mouse_combo.set_tooltip_text(Some("Pointer source for relay input.")); let mouse_row = build_inline_selector_row("Mouse", &mouse_combo); control_stack.append(&mouse_row); devices_body.append(&control_group); @@ -306,24 +374,18 @@ pub fn build_launcher_view( media_group.append(&media_grid); let camera_channel_toggle = gtk::CheckButton::with_label("Camera"); camera_channel_toggle.set_active(state.channels.camera); - camera_channel_toggle.set_tooltip_text(Some( - "Include the local webcam uplink in the next relay session.", - )); + camera_channel_toggle.set_tooltip_text(Some("Send webcam during relay.")); let audio_channel_toggle = gtk::CheckButton::with_label("Speaker"); audio_channel_toggle.set_active(state.channels.audio); - audio_channel_toggle.set_tooltip_text(Some( - "Play remote audio on this client during the next relay session.", - )); + audio_channel_toggle.set_tooltip_text(Some("Play remote audio here.")); let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); microphone_channel_toggle.set_active(state.channels.microphone); - microphone_channel_toggle.set_tooltip_text(Some( - "Include the local microphone uplink in the next relay session.", - )); + microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay.")); let audio_gain_adjustment = gtk::Adjustment::new( - state.audio_gain_percent as f64, + f64::from(state.audio_gain_percent), 0.0, - super::state::MAX_AUDIO_GAIN_PERCENT as f64, + f64::from(super::state::MAX_AUDIO_GAIN_PERCENT), 25.0, 100.0, 0.0, @@ -333,16 +395,18 @@ pub fn build_launcher_view( audio_gain_scale.set_draw_value(false); audio_gain_scale.set_hexpand(false); audio_gain_scale.set_size_request(96, -1); - audio_gain_scale.set_tooltip_text(Some( - "Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.", - )); + audio_gain_scale.set_tooltip_text(Some("Speaker volume. Double-click resets to 200%.")); + attach_scale_reset_gesture( + &audio_gain_scale, + f64::from(super::state::DEFAULT_AUDIO_GAIN_PERCENT), + ); let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); audio_gain_value.set_visible(false); let mic_gain_adjustment = gtk::Adjustment::new( - state.mic_gain_percent as f64, + f64::from(state.mic_gain_percent), 0.0, - super::state::MAX_MIC_GAIN_PERCENT as f64, + f64::from(super::state::MAX_MIC_GAIN_PERCENT), 25.0, 100.0, 0.0, @@ -351,9 +415,11 @@ pub fn build_launcher_view( mic_gain_scale.set_draw_value(false); mic_gain_scale.set_hexpand(false); mic_gain_scale.set_size_request(96, -1); - mic_gain_scale.set_tooltip_text(Some( - "Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.", - )); + mic_gain_scale.set_tooltip_text(Some("Mic gain. Double-click resets to 100%.")); + attach_scale_reset_gesture( + &mic_gain_scale, + f64::from(super::state::DEFAULT_MIC_GAIN_PERCENT), + ); let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); mic_gain_value.set_visible(false); @@ -391,9 +457,7 @@ pub fn build_launcher_view( ); let microphone_test_button = gtk::Button::with_label("Monitor Mic"); stabilize_button(µphone_test_button, 118); - microphone_test_button.set_tooltip_text(Some( - "Monitor the selected microphone through the selected speaker until you stop the test.", - )); + microphone_test_button.set_tooltip_text(Some("Monitor mic through speaker.")); microphone_combo.set_size_request(0, -1); let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); microphone_combo.set_hexpand(true); @@ -509,9 +573,7 @@ pub fn build_launcher_view( server_entry.set_hexpand(true); server_entry.set_width_chars(18); server_entry.set_text(server_addr); - server_entry.set_tooltip_text(Some( - "Relay host address for previews, power control, and the live session.", - )); + server_entry.set_tooltip_text(Some("Relay host address.")); let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); relay_row.set_halign(gtk::Align::Fill); relay_row.set_hexpand(true); @@ -528,21 +590,15 @@ pub fn build_launcher_view( let clipboard_button = gtk::Button::with_label("Send Clipboard"); clipboard_button.set_hexpand(true); stabilize_button(&clipboard_button, 108); - clipboard_button.set_tooltip_text(Some( - "Type the current local clipboard into the remote target. This stays launcher-only.", - )); + clipboard_button.set_tooltip_text(Some("Type clipboard remotely.")); let probe_button = gtk::Button::with_label("Copy Gate Probe"); probe_button.set_hexpand(true); stabilize_button(&probe_button, 108); - probe_button.set_tooltip_text(Some( - "Copy the hygiene/quality probe command into the local clipboard.", - )); + probe_button.set_tooltip_text(Some("Copy quality probe.")); let usb_recover_button = gtk::Button::with_label("Recover USB"); usb_recover_button.set_hexpand(true); stabilize_button(&usb_recover_button, 108); - usb_recover_button.set_tooltip_text(Some( - "Force the remote USB gadget to re-enumerate when keyboard, mouse, webcam, or audio stop showing up on the host.", - )); + usb_recover_button.set_tooltip_text(Some("Re-enumerate remote USB.")); live_actions_row.append(&clipboard_button); live_actions_row.append(&probe_button); live_actions_row.append(&usb_recover_button); @@ -599,9 +655,7 @@ pub fn build_launcher_view( let input_toggle_button = gtk::Button::with_label("Route"); input_toggle_button.set_hexpand(true); stabilize_button(&input_toggle_button, 106); - input_toggle_button.set_tooltip_text(Some( - "Change live keyboard and mouse ownership between this machine and the remote target.", - )); + input_toggle_button.set_tooltip_text(Some("Swap input ownership.")); let swap_key_button = gtk::Button::with_label("Set Swap Key"); swap_key_button.set_hexpand(true); stabilize_button(&swap_key_button, 106); @@ -652,11 +706,21 @@ pub fn build_launcher_view( console_panel.set_valign(gtk::Align::Fill); console_body.set_vexpand(true); let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); - console_toolbar.set_homogeneous(true); - let console_copy_button = gtk::Button::with_label("Copy Log"); - stabilize_button(&console_copy_button, 104); - let console_popout_button = gtk::Button::with_label("Break Out Log"); - stabilize_button(&console_popout_button, 104); + let session_log_level = Rc::new(RefCell::new(ConsoleLogLevel::default())); + let console_level_combo = gtk::ComboBoxText::new(); + for level in ConsoleLogLevel::ALL { + console_level_combo.append(Some(level.id()), level.label()); + } + console_level_combo.set_active_id(Some(ConsoleLogLevel::default().id())); + console_level_combo.set_size_request(78, 36); + console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher.")); + let console_copy_button = gtk::Button::with_label("Copy"); + stabilize_button(&console_copy_button, 66); + console_copy_button.set_tooltip_text(Some("Copy visible log.")); + let console_popout_button = gtk::Button::with_label("Pop Out"); + stabilize_button(&console_popout_button, 78); + console_popout_button.set_tooltip_text(Some("Open log window.")); + console_toolbar.append(&console_level_combo); console_toolbar.append(&console_copy_button); console_toolbar.append(&console_popout_button); let status_label = gtk::Label::new(Some("Session log ready.")); @@ -853,6 +917,8 @@ pub fn build_launcher_view( diagnostics_popout_button: diagnostics_popout_button.clone(), console_copy_button: console_copy_button.clone(), console_popout_button: console_popout_button.clone(), + console_level_combo: console_level_combo.clone(), + session_log_level: session_log_level.clone(), _device_body_height_group: device_body_height_group, }; let popouts = Rc::new(RefCell::new([None, None])); @@ -887,7 +953,7 @@ pub fn build_launcher_view( pub fn install_css(window: >k::ApplicationWindow) { let provider = gtk::CssProvider::new(); provider.load_from_data( - r#" + r" window.lesavka { background: #101319; color: #eef2f7; @@ -919,7 +985,9 @@ pub fn install_css(window: >k::ApplicationWindow) { label.version-tag { font-size: 0.76rem; opacity: 0.72; - margin-bottom: 3px; + } + image.app-logo { + opacity: 0.96; } box.status-chip { background: rgba(91, 179, 162, 0.12); @@ -975,7 +1043,12 @@ pub fn install_css(window: >k::ApplicationWindow) { } label.eye-inline-status { font-size: 0.86rem; - opacity: 0.82; + font-weight: 600; + background: rgba(91, 179, 162, 0.10); + border: 1px solid rgba(91, 179, 162, 0.22); + border-radius: 999px; + padding: 5px 8px; + opacity: 0.9; } textview.status-log, label.status-log { @@ -1009,7 +1082,7 @@ pub fn install_css(window: >k::ApplicationWindow) { border-color: rgba(91, 179, 162, 0.45); font-weight: 700; } - "#, + ", ); if let Some(display) = gtk::gdk::Display::default() { gtk::style_context_add_provider_for_display( @@ -1444,27 +1517,21 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { root.append(&stack); let feed_source_combo = gtk::ComboBoxText::new(); - feed_source_combo.set_tooltip_text(Some( - "Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.", - )); + feed_source_combo.set_tooltip_text(Some("Eye source for this pane.")); feed_source_combo.set_hexpand(true); feed_source_combo.set_size_request(0, -1); let capture_resolution_combo = gtk::ComboBoxText::new(); - capture_resolution_combo.set_tooltip_text(Some( - "Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.", - )); + capture_resolution_combo.set_tooltip_text(Some("Eye capture mode.")); capture_resolution_combo.set_size_request(0, -1); capture_resolution_combo.set_hexpand(true); let breakout_combo = gtk::ComboBoxText::new(); - breakout_combo.set_tooltip_text(Some( - "Choose the client-side breakout window size for this eye feed. Source Size preserves the feed's own dimensions; Display Size fills the effective monitor size.", - )); + breakout_combo.set_tooltip_text(Some("Breakout window size.")); breakout_combo.set_size_request(0, -1); breakout_combo.set_hexpand(true); let action_button = gtk::Button::with_label("Break Out"); stabilize_button(&action_button, 104); action_button.set_halign(gtk::Align::End); - let stream_status = gtk::Label::new(Some("Connect relay to preview.")); + let stream_status = gtk::Label::new(Some("Preview pending")); stream_status.add_css_class("status-line"); stream_status.add_css_class("eye-inline-status"); stream_status.set_halign(gtk::Align::Fill); @@ -1474,7 +1541,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stream_status.set_single_line_mode(true); stream_status.set_width_chars(12); stream_status.set_max_width_chars(18); - stream_status.set_tooltip_text(Some("Connect relay to preview.")); + stream_status.set_tooltip_text(Some("Eye stream status.")); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); let controls_grid = gtk::Grid::new(); controls_grid.set_column_spacing(8); @@ -1513,3 +1580,16 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { fn stabilize_button(button: >k::Button, width: i32) { button.set_size_request(width, 36); } + +/// Resets one slider to its default value on double-click. +fn attach_scale_reset_gesture(scale: >k::Scale, default_value: f64) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(0); + let scale_for_click = scale.clone(); + gesture.connect_released(move |_, n_press, _, _| { + if n_press == 2 && (scale_for_click.value() - default_value).abs() > f64::EPSILON { + scale_for_click.set_value(default_value); + } + }); + scale.add_controller(gesture); +} diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index ee95c58..d22ea21 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -18,7 +18,7 @@ use super::{ preview::{LauncherPreview, PreviewSurface}, runtime_env_vars, state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, - ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, + ui_components::{ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, }; pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; @@ -117,9 +117,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets.start_button.set_sensitive(true); widgets.server_entry.set_sensitive(!relay_live); widgets.start_button.set_tooltip_text(Some(if relay_live { - "Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby." + "Disconnect the relay session." } else { - "Connect to the relay host, start the live session, and bring the eye previews online." + "Start relay and previews." })); widgets.clipboard_button.set_sensitive(relay_live); widgets.probe_button.set_sensitive(true); @@ -169,19 +169,17 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .input_toggle_button .set_tooltip_text(Some(match state.routing { - InputRouting::Remote => { - "Inputs are currently going to the remote session. Click to bring them back local." - } - InputRouting::Local => { - "Inputs are currently staying local. Click to hand them to the remote session." - } + InputRouting::Remote => "Route inputs back local.", + InputRouting::Local => "Route inputs to remote.", })); widgets.swap_key_button.set_label("Set Swap Key"); - widgets.swap_key_button.set_tooltip_text(Some(if state.swap_key_binding { - "Waiting for the next key press. The top chip still shows the current live shortcut." - } else { - "Capture the next key you press and make it the swap shortcut. The current shortcut is shown in the top chip." - })); + widgets + .swap_key_button + .set_tooltip_text(Some(if state.swap_key_binding { + "Press the new swap key." + } else { + "Set the swap shortcut." + })); let power_available = state.capture_power.available; widgets .power_auto_button @@ -1089,6 +1087,33 @@ pub fn append_session_log(buffer: >k::TextBuffer, message: &str) { if trimmed.is_empty() { return; } + append_clean_session_log(buffer, trimmed); +} + +/// Appends a session log line only when it passes the selected severity filter. +pub fn append_session_log_for_level( + buffer: >k::TextBuffer, + message: &str, + level: ConsoleLogLevel, +) -> bool { + let cleaned = strip_ansi_sequences(message); + let trimmed = cleaned.trim(); + if trimmed.is_empty() || !should_show_clean_session_log_line(trimmed, level) { + return false; + } + append_clean_session_log(buffer, trimmed); + true +} + +#[cfg(test)] +fn should_show_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { + let cleaned = strip_ansi_sequences(message); + let trimmed = cleaned.trim(); + !trimmed.is_empty() && should_show_clean_session_log_line(trimmed, level) +} + +/// Writes a cleaned line with the GTK text tags that match its source/severity. +fn append_clean_session_log(buffer: >k::TextBuffer, trimmed: &str) { let mut end = buffer.end_iter(); let tags = classify_log_tags(trimmed); if tags.is_empty() { @@ -1202,7 +1227,7 @@ pub fn open_session_log_popout( None, buffer, "Lesavka Log", - "Copy Log", + "Copy", gtk::WrapMode::WordChar, ); } @@ -1454,6 +1479,71 @@ fn set_status_light(light: >k::Box, state: StatusLightState) { light.add_css_class(state.css_class()); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionLogSeverity { + Error, + Warn, + Info, + Debug, + Trace, + Other, +} + +impl SessionLogSeverity { + const fn rank(self) -> Option { + match self { + Self::Error => Some(0), + Self::Warn => Some(1), + Self::Info => Some(2), + Self::Debug => Some(3), + Self::Trace => Some(4), + Self::Other => None, + } + } +} + +/// Returns whether a cleaned log line should be visible at the selected threshold. +fn should_show_clean_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { + let severity = session_log_severity(message); + if severity == SessionLogSeverity::Error { + return true; + } + if message.starts_with("[launcher]") || message.starts_with("[preview:") { + return true; + } + match severity.rank() { + Some(rank) => rank <= level.rank(), + None => true, + } +} + +/// Infers severity from the structured relay prefix and human-readable text. +fn session_log_severity(message: &str) -> SessionLogSeverity { + let uppercase = message.to_ascii_uppercase(); + if message.starts_with("[relay:stderr]") + || message.contains('❌') + || uppercase.contains(" ERROR ") + || uppercase.contains("FAILED") + || uppercase.contains("PANIC") + || uppercase.contains(" RPC FAILED") + { + SessionLogSeverity::Error + } else if uppercase.contains(" WARN ") + || uppercase.contains("UNAVAILABLE") + || uppercase.contains("WAITING FOR CAPTURE PIPELINE") + { + SessionLogSeverity::Warn + } else if uppercase.contains(" INFO ") { + SessionLogSeverity::Info + } else if uppercase.contains(" DEBUG ") { + SessionLogSeverity::Debug + } else if uppercase.contains(" TRACE ") { + SessionLogSeverity::Trace + } else { + SessionLogSeverity::Other + } +} + fn classify_log_tags(message: &str) -> Vec<&'static str> { let mut tags = Vec::new(); if message.starts_with("[launcher]") { @@ -1469,7 +1559,9 @@ fn classify_log_tags(message: &str) -> Vec<&'static str> { } let uppercase = message.to_ascii_uppercase(); - if uppercase.contains(" ERROR ") + if message.starts_with("[relay:stderr]") + || message.contains('❌') + || uppercase.contains(" ERROR ") || uppercase.contains("FAILED") || uppercase.contains("PANIC") || uppercase.contains(" RPC FAILED") @@ -1585,6 +1677,35 @@ mod tests { assert!(tags.contains(&"log-error") || tags.contains(&"log-warn")); } + #[test] + #[doc = "Verifies the default console filter hides relay INFO noise."] + fn session_log_filter_hides_noisy_info_by_default_but_keeps_errors() { + assert!(!should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ThreadId(01) audio packet received packet=3000", + ConsoleLogLevel::Warn + )); + assert!(!should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ThreadId(04) decoded audio level rms=-32", + ConsoleLogLevel::Warn + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z WARN pipeline is recovering", + ConsoleLogLevel::Warn + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ❌ connect failed", + ConsoleLogLevel::Error + )); + assert!(should_show_session_log_line( + "[launcher] Relay connected with inputs routed to remote.", + ConsoleLogLevel::Error + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO audio packet received", + ConsoleLogLevel::Info + )); + } + #[test] fn write_audio_gain_request_formats_live_control_file() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/common/Cargo.toml b/common/Cargo.toml index 8ae3ffb..bf1adba 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.12.0" +version = "0.12.1" edition = "2024" build = "build.rs" diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 7d7c8e0..2f5ddcf 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -73,7 +73,7 @@ "client/src/launcher/diagnostics.rs": { "clippy_warnings": 92, "doc_debt": 12, - "loc": 1021 + "loc": 1081 }, "client/src/launcher/mod.rs": { "clippy_warnings": 8, @@ -96,19 +96,19 @@ "loc": 1562 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 70, + "clippy_warnings": 64, "doc_debt": 23, - "loc": 2619 + "loc": 2635 }, "client/src/launcher/ui_components.rs": { - "clippy_warnings": 22, + "clippy_warnings": 12, "doc_debt": 18, - "loc": 1515 + "loc": 1595 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 74, "doc_debt": 44, - "loc": 1802 + "loc": 1923 }, "client/src/layout.rs": { "clippy_warnings": 6, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 7bb70ff..888ec7d 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -49,8 +49,8 @@ "loc": 564 }, "client/src/launcher/diagnostics.rs": { - "line_percent": 84.3, - "loc": 1021 + "line_percent": 84.98, + "loc": 1081 }, "client/src/launcher/mod.rs": { "line_percent": 84.85, @@ -62,7 +62,7 @@ }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 2619 + "loc": 2635 }, "client/src/layout.rs": { "line_percent": 97.73, diff --git a/server/Cargo.toml b/server/Cargo.toml index 0563f56..13daba3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.12.0" +version = "0.12.1" edition = "2024" autobins = false