diff --git a/client/Cargo.toml b/client/Cargo.toml index 5db3ff9..a325578 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.45" +version = "0.11.46" edition = "2024" [dependencies] diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index dd7d9fd..0323a05 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -166,6 +166,52 @@ fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { deviations[deviations.len() / 2] } +#[cfg(not(coverage))] +/// Apply a remote-audio gain slider update without unwinding through GTK callbacks. +fn apply_audio_gain_change( + scale: >k::Scale, + state: &Rc>, + widgets: &super::ui_components::LauncherWidgets, + child_proc: &Rc>>, +) -> bool { + let percent = scale + .value() + .round() + .clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) as u32; + let label = { + let Ok(mut state) = state.try_borrow_mut() else { + return false; + }; + if state.audio_gain_percent == percent { + widgets.audio_gain_value.set_text(&state.audio_gain_label()); + return true; + } + state.set_audio_gain_percent(percent); + state.audio_gain_label() + }; + widgets.audio_gain_value.set_text(&label); + let relay_live = child_proc + .try_borrow() + .map(|child| child.is_some()) + .unwrap_or(false); + if relay_live { + let path = audio_gain_control_path(); + match write_audio_gain_request(&path, percent) { + Ok(()) => widgets + .status_label + .set_text(&format!("Remote audio gain set to {label}.")), + Err(err) => widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}" + )), + } + } else { + widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch." + )); + } + true +} + #[cfg(not(coverage))] fn request_capture_power_refresh( power_tx: std::sync::mpsc::Sender, @@ -1077,34 +1123,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let child_proc = Rc::clone(&child_proc); let audio_gain_scale = widgets.audio_gain_scale.clone(); audio_gain_scale.connect_value_changed(move |scale| { - let percent = scale - .value() - .round() - .clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) - as u32; - let label = { - let mut state = state.borrow_mut(); - if state.audio_gain_percent == percent { - return; - } - state.set_audio_gain_percent(percent); - state.audio_gain_label() - }; - widgets.audio_gain_value.set_text(&label); - if child_proc.borrow().is_some() { - let path = audio_gain_control_path(); - match write_audio_gain_request(&path, percent) { - Ok(()) => widgets - .status_label - .set_text(&format!("Remote audio gain set to {label}.")), - Err(err) => widgets.status_label.set_text(&format!( - "Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}" - )), - } - } else { - widgets.status_label.set_text(&format!( - "Remote audio gain set to {label} for the next relay launch." - )); + if !apply_audio_gain_change(scale, &state, &widgets, &child_proc) { + let scale = scale.clone(); + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + glib::idle_add_local_once(move || { + let _ = apply_audio_gain_change(&scale, &state, &widgets, &child_proc); + }); } }); } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 85d61aa..1609193 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -119,7 +119,7 @@ const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; const EYE_PREVIEW_MIN_HEIGHT: i32 = 258; const EYE_PREVIEW_MIN_WIDTH: i32 = 460; -const SIDE_LOG_HEIGHT: i32 = 124; +const SIDE_LOG_MIN_HEIGHT: i32 = 124; pub fn build_launcher_view( app: >k::Application, @@ -427,7 +427,8 @@ pub fn build_launcher_view( relay_row.append(&server_entry); let start_button = gtk::Button::with_label("Connect"); start_button.add_css_class("suggested-action"); - stabilize_button(&start_button, 92); + start_button.set_hexpand(false); + stabilize_button(&start_button, 108); relay_row.append(&start_button); connection_body.append(&relay_row); @@ -457,7 +458,7 @@ pub fn build_launcher_view( connection_body.append(&live_actions_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); - let power_heading = gtk::Label::new(Some("Power")); + let power_heading = gtk::Label::new(Some("GPIO Power")); power_heading.add_css_class("subgroup-title"); power_heading.set_halign(gtk::Align::Start); @@ -465,31 +466,38 @@ pub fn build_launcher_view( power_shell.set_halign(gtk::Align::Fill); let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); power_row.set_hexpand(true); - power_heading.set_width_chars(5); + power_heading.set_width_chars(10); power_row.append(&power_heading); + let power_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + power_buttons.set_hexpand(true); + power_buttons.set_homogeneous(true); let power_on_button = gtk::Button::with_label("On"); + power_on_button.set_hexpand(true); stabilize_button(&power_on_button, 52); power_on_button.add_css_class("pill-toggle"); let power_auto_button = gtk::Button::with_label("Auto"); + power_auto_button.set_hexpand(true); stabilize_button(&power_auto_button, 52); power_auto_button.add_css_class("pill-toggle"); let power_off_button = gtk::Button::with_label("Off"); + power_off_button.set_hexpand(true); stabilize_button(&power_off_button, 52); power_off_button.add_css_class("pill-toggle"); let power_detail = gtk::Label::new(Some("Capture power status is loading...")); power_detail.add_css_class("dim-label"); power_detail.set_wrap(true); power_detail.set_xalign(0.0); - power_row.append(&power_on_button); - power_row.append(&power_auto_button); - power_row.append(&power_off_button); + power_buttons.append(&power_on_button); + power_buttons.append(&power_auto_button); + power_buttons.append(&power_off_button); + power_row.append(&power_buttons); let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); audio_gain_row.set_size_request(220, -1); audio_gain_row.set_hexpand(true); let audio_gain_label = gtk::Label::new(Some("Audio")); audio_gain_label.add_css_class("dim-label"); audio_gain_label.set_halign(gtk::Align::Start); - audio_gain_label.set_width_chars(5); + audio_gain_label.set_width_chars(10); let audio_gain_adjustment = gtk::Adjustment::new( state.audio_gain_percent as f64, 0.0, @@ -515,15 +523,18 @@ pub fn build_launcher_view( power_shell.append(&power_row); power_shell.append(&audio_gain_row); connection_body.append(&power_shell); - let routing_heading = gtk::Label::new(Some("Input")); + let routing_heading = gtk::Label::new(Some("Inputs")); routing_heading.add_css_class("subgroup-title"); routing_heading.set_halign(gtk::Align::Start); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); routing_row.set_hexpand(true); - routing_heading.set_width_chars(5); + routing_heading.set_width_chars(10); routing_row.append(&routing_heading); + let routing_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + routing_buttons.set_hexpand(true); + routing_buttons.set_homogeneous(true); let input_toggle_button = gtk::Button::with_label("Route"); input_toggle_button.set_hexpand(true); stabilize_button(&input_toggle_button, 106); @@ -531,13 +542,18 @@ pub fn build_launcher_view( "Change live keyboard and mouse ownership between this machine and the remote target.", )); let swap_key_button = gtk::Button::with_label("Set Swap Key"); + swap_key_button.set_hexpand(true); stabilize_button(&swap_key_button, 106); - routing_row.append(&input_toggle_button); - routing_row.append(&swap_key_button); + routing_buttons.append(&input_toggle_button); + routing_buttons.append(&swap_key_button); + routing_row.append(&routing_buttons); connection_body.append(&routing_row); operations.append(&connection_panel); let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics"); + diagnostics_panel.set_vexpand(true); + diagnostics_panel.set_valign(gtk::Align::Fill); + diagnostics_body.set_vexpand(true); let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); diagnostics_toolbar.set_homogeneous(true); let diagnostics_copy_button = gtk::Button::with_label("Copy Report"); @@ -562,9 +578,8 @@ pub fn build_launcher_view( diagnostics_shell.append(&diagnostics_label); let diagnostics_scroll = gtk::ScrolledWindow::builder() .hexpand(true) - .vexpand(false) - .min_content_height(SIDE_LOG_HEIGHT) - .max_content_height(SIDE_LOG_HEIGHT) + .vexpand(true) + .min_content_height(SIDE_LOG_MIN_HEIGHT) .child(&diagnostics_shell) .build(); diagnostics_body.append(&diagnostics_toolbar); @@ -572,7 +587,9 @@ pub fn build_launcher_view( operations.append(&diagnostics_panel); let (console_panel, console_body) = build_panel("Session Console"); - console_panel.set_vexpand(false); + console_panel.set_vexpand(true); + 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"); @@ -602,9 +619,8 @@ pub fn build_launcher_view( session_log_view.set_wrap_mode(gtk::WrapMode::WordChar); let log_scroll = gtk::ScrolledWindow::builder() .hexpand(true) - .vexpand(false) - .min_content_height(SIDE_LOG_HEIGHT) - .max_content_height(SIDE_LOG_HEIGHT) + .vexpand(true) + .min_content_height(SIDE_LOG_MIN_HEIGHT) .child(&session_log_view) .build(); console_body.append(&console_toolbar); @@ -884,6 +900,10 @@ pub fn install_css(window: >k::ApplicationWindow) { label.status-line { opacity: 0.9; } + label.eye-inline-status { + font-size: 0.86rem; + opacity: 0.82; + } textview.status-log, label.status-log { font-family: monospace; @@ -1280,15 +1300,6 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stack.set_visible_child_name("preview"); root.append(&stack); - let stream_status = gtk::Label::new(Some("Connect relay to preview.")); - stream_status.add_css_class("status-line"); - stream_status.set_halign(gtk::Align::Start); - stream_status.set_hexpand(true); - stream_status.set_ellipsize(pango::EllipsizeMode::End); - stream_status.set_single_line_mode(true); - stream_status.set_max_width_chars(24); - stream_status.set_tooltip_text(Some("Connect relay to preview.")); - root.append(&stream_status); 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.", @@ -1310,6 +1321,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { 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.")); + stream_status.add_css_class("status-line"); + stream_status.add_css_class("eye-inline-status"); + stream_status.set_halign(gtk::Align::Fill); + stream_status.set_valign(gtk::Align::Center); + stream_status.set_hexpand(true); + stream_status.set_ellipsize(pango::EllipsizeMode::End); + 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.")); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); let controls_grid = gtk::Grid::new(); controls_grid.set_column_spacing(8); @@ -1322,9 +1344,10 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { capture_row.set_hexpand(true); breakout_row.set_hexpand(true); controls_grid.attach(&feed_row, 0, 0, 1, 1); - controls_grid.attach(&capture_row, 1, 0, 1, 1); + controls_grid.attach(&capture_row, 1, 0, 2, 1); controls_grid.attach(&breakout_row, 0, 1, 1, 1); - controls_grid.attach(&action_button, 1, 1, 1, 1); + controls_grid.attach(&stream_status, 1, 1, 1, 1); + controls_grid.attach(&action_button, 2, 1, 1, 1); footer_shell.append(&controls_grid); root.append(&footer_shell); diff --git a/common/Cargo.toml b/common/Cargo.toml index cb17435..19398b7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.45" +version = "0.11.46" edition = "2024" build = "build.rs" diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index ae59d6c..d00fc16 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -98,12 +98,12 @@ "client/src/launcher/ui.rs": { "clippy_warnings": 62, "doc_debt": 23, - "loc": 2341 + "loc": 2367 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 16, "doc_debt": 15, - "loc": 1349 + "loc": 1372 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 62, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 772cd4b..cbe5286 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -62,7 +62,7 @@ }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 2341 + "loc": 2367 }, "client/src/layout.rs": { "line_percent": 97.73, diff --git a/server/Cargo.toml b/server/Cargo.toml index 35bd02c..78898dc 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.45" +version = "0.11.46" edition = "2024" autobins = false diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 37c0acb..749feb6 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -44,6 +44,16 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() { || UI_SRC.contains("capture_label.set_halign(gtk::Align::End)") ); assert!(UI_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);")); + assert!(!UI_SRC.contains("root.append(&stream_status);")); + assert!(UI_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");")); + assert!( + source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);") + < source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);") + ); + assert!( + source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);") + < source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);") + ); } #[test] @@ -74,20 +84,21 @@ fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() { #[test] fn operations_column_fills_height_and_splits_extra_space_between_logs() { - assert_eq!(const_i32("SIDE_LOG_HEIGHT"), 124); + assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124); assert!(UI_SRC.contains("operations.set_vexpand(true);")); assert!(UI_SRC.contains("operations.set_valign(gtk::Align::Fill);")); + assert!(UI_SRC.contains("diagnostics_panel.set_vexpand(true);")); + assert!(UI_SRC.contains("console_panel.set_vexpand(true);")); assert_eq!( UI_SRC - .matches(".min_content_height(SIDE_LOG_HEIGHT)") + .matches(".min_content_height(SIDE_LOG_MIN_HEIGHT)") .count(), 2 ); assert_eq!( - UI_SRC - .matches(".max_content_height(SIDE_LOG_HEIGHT)") - .count(), - 2 + UI_SRC.matches(".max_content_height(SIDE_LOG").count(), + 0, + "the docked logs must be allowed to split extra right-rail height" ); } @@ -97,6 +108,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); assert!(UI_SRC.contains("relay_row.append(&server_entry);")); assert!(UI_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");")); + assert!(UI_SRC.contains("stabilize_button(&start_button, 108);")); assert!(UI_SRC.contains("relay_row.append(&start_button);")); assert!( source_index("relay_row.append(&server_entry);") @@ -108,8 +120,9 @@ fn relay_controls_keep_connect_inline_with_server_entry() { fn remote_audio_gain_control_stays_in_the_operations_rail() { assert!(!UI_SRC.contains("Remote Audio")); assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);")); - assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"Power\"));")); + assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")); assert!(UI_SRC.contains("power_row.append(&power_heading);")); + assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);")); assert!(UI_SRC.contains("let audio_gain_scale =")); assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);")); assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);")); @@ -123,11 +136,12 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() { "the operations rail should not gain extra vertical sections that stretch the lower layout" ); assert!( - source_index("let power_heading = gtk::Label::new(Some(\"Power\"));") + source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") < source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") ); assert!( source_index("power_shell.append(&audio_gain_row);") - < source_index("let routing_heading = gtk::Label::new(Some(\"Input\"));") + < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); + assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);")); } diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 8b80d54..f35479e 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -6,6 +6,7 @@ //! video, and input streams must not keep running as leaked child processes. const UI_RUNTIME_SRC: &str = include_str!("../../client/src/launcher/ui_runtime.rs"); +const UI_SRC: &str = include_str!("../../client/src/launcher/ui.rs"); const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs"); const MAIN_SRC: &str = include_str!("../../client/src/main.rs"); @@ -32,3 +33,12 @@ fn relay_address_entry_is_locked_while_relay_is_live() { assert!(UI_RUNTIME_SRC.contains("\"Connect\"")); assert!(UI_RUNTIME_SRC.contains("\"Disconnect\"")); } + +#[test] +fn audio_gain_slider_callback_never_panics_on_refresh_reentry() { + assert!(UI_SRC.contains("fn apply_audio_gain_change(")); + assert!(UI_SRC.contains("state.try_borrow_mut()")); + assert!(UI_SRC.contains("return false;")); + assert!(UI_SRC.contains("glib::idle_add_local_once")); + assert!(!UI_SRC.contains("let mut state = state.borrow_mut();\n if state.audio_gain_percent == percent")); +}