diff --git a/Cargo.lock b/Cargo.lock index 94c2bb6..877b2e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.1" +version = "0.22.2" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.1" +version = "0.22.2" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.1" +version = "0.22.2" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8e6aa3a..3c0fd96 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.1" +version = "0.22.2" edition = "2024" [dependencies] diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 6f75060..015d26e 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -152,7 +152,7 @@ fn populated_launcher_runtime_widgets_stay_compact() { let (camera_min_h, camera_nat_h, _, _) = view .device_stage .camera_preview - .measure(gtk::Orientation::Vertical, 420); + .measure(gtk::Orientation::Vertical, 520); let (testing_panel_min_h, testing_panel_nat_h, _, _) = view .device_stage .preview_panel @@ -165,19 +165,28 @@ fn populated_launcher_runtime_widgets_stay_compact() { .measure(gtk::Orientation::Vertical, 720); let (server_min_w, server_nat_w, _, _) = view.server_entry.measure(gtk::Orientation::Horizontal, -1); + let (device_panel_min_w, device_panel_nat_w, _, _) = view + .device_stage + .devices_panel + .measure(gtk::Orientation::Horizontal, -1); assert!( - camera_min_w >= 400 && camera_nat_w >= 400, + camera_min_w >= 480 && camera_nat_w >= 480, "camera preview width regressed: min={camera_min_w}, natural={camera_nat_w}" ); assert!( - camera_min_h >= 225 && camera_nat_h >= 225, + camera_min_h >= 270 && camera_nat_h >= 270, "camera preview height regressed: min={camera_min_h}, natural={camera_nat_h}" ); assert!( - testing_panel_min_h <= 420 && testing_panel_nat_h <= 420, + testing_panel_min_h <= 465 && testing_panel_nat_h <= 465, "device testing panel height regressed: min={testing_panel_min_h}, natural={testing_panel_nat_h}" ); + let device_panel_width = view.device_stage.devices_panel.width(); + assert!( + device_panel_min_w <= 610 && device_panel_width <= 610, + "device staging should leave width for upstream media: min={device_panel_min_w}, natural={device_panel_nat_w}, allocated={device_panel_width}" + ); assert!( left_min_w <= 700 && left_nat_w <= 720, "eye pane width regressed: min={left_min_w}, natural={left_nat_w}" @@ -232,10 +241,16 @@ fn populated_launcher_runtime_widgets_stay_compact() { view.widgets.display_panes[0].preview_frame.width() ); assert!( - view.device_stage.camera_preview_frame.height() >= 225, + view.device_stage.camera_preview_frame.height() >= 270, "webcam preview should use the taller upstream media space: {}", view.device_stage.camera_preview_frame.height() ); + assert!( + view.device_stage.preview_panel.width() > view.device_stage.devices_panel.width(), + "upstream media should receive extra width: staging={}, upstream={}", + view.device_stage.devices_panel.width(), + view.device_stage.preview_panel.width() + ); } #[gtk::test] diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index 2955566..3b1532f 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -4,7 +4,8 @@ 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); + devices_panel.set_hexpand(false); + devices_panel.set_size_request(DEVICE_STAGING_PANEL_WIDTH, -1); devices_panel.set_vexpand(true); devices_panel.set_valign(gtk::Align::Fill); devices_body.set_vexpand(true); @@ -16,6 +17,7 @@ let camera_combo = gtk::ComboBoxText::new(); camera_combo.add_css_class("compact-combo"); + install_device_choice_combo_behavior(&camera_combo); sync_stage_device_combo( &camera_combo, &catalog.cameras, @@ -36,6 +38,7 @@ let speaker_combo = gtk::ComboBoxText::new(); speaker_combo.add_css_class("compact-combo"); + install_device_choice_combo_behavior(&speaker_combo); sync_stage_device_combo( &speaker_combo, &catalog.speakers, @@ -47,6 +50,7 @@ let keyboard_combo = gtk::ComboBoxText::new(); keyboard_combo.add_css_class("compact-combo"); + install_device_choice_combo_behavior(&keyboard_combo); keyboard_combo.append(Some("all"), "all keyboards"); for keyboard in &catalog.keyboards { append_input_choice(&keyboard_combo, keyboard); @@ -58,6 +62,7 @@ let mouse_combo = gtk::ComboBoxText::new(); mouse_combo.add_css_class("compact-combo"); + install_device_choice_combo_behavior(&mouse_combo); mouse_combo.append(Some("all"), "all mice"); for mouse in &catalog.mice { append_input_choice(&mouse_combo, mouse); @@ -92,7 +97,8 @@ microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay.")); microphone_channel_toggle.add_css_class("pill-toggle"); microphone_channel_toggle.add_css_class("media-toggle"); - stabilize_button(µphone_channel_toggle, 46); + microphone_channel_toggle.add_css_class("media-toggle-split"); + stabilize_button(µphone_channel_toggle, 44); let noise_suppression_toggle = gtk::ToggleButton::with_label("🧹"); noise_suppression_toggle.set_active(state.mic_noise_suppression); noise_suppression_toggle.set_tooltip_text(Some(if state.mic_noise_suppression { @@ -102,7 +108,8 @@ })); noise_suppression_toggle.add_css_class("pill-toggle"); noise_suppression_toggle.add_css_class("media-toggle"); - stabilize_button(&noise_suppression_toggle, 42); + noise_suppression_toggle.add_css_class("media-toggle-split"); + stabilize_button(&noise_suppression_toggle, 44); let audio_gain_adjustment = gtk::Adjustment::new( f64::from(state.audio_gain_percent), @@ -173,6 +180,7 @@ let microphone_combo = gtk::ComboBoxText::new(); microphone_combo.add_css_class("compact-combo"); + install_device_choice_combo_behavior(µphone_combo); sync_stage_device_combo( µphone_combo, &catalog.microphones, @@ -187,6 +195,8 @@ microphone_selectors.append(µphone_combo); microphone_selectors.append(&mic_gain_scale); let microphone_toggle_group = gtk::Box::new(gtk::Orientation::Horizontal, 4); + microphone_toggle_group.set_homogeneous(true); + microphone_toggle_group.set_size_request(92, -1); microphone_toggle_group.append(µphone_channel_toggle); microphone_toggle_group.append(&noise_suppression_toggle); attach_device_control_row( @@ -308,9 +318,9 @@ ); let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); camera_preview_frame.set_hexpand(true); - camera_preview_frame.set_vexpand(false); + camera_preview_frame.set_vexpand(true); camera_preview_frame.set_halign(gtk::Align::Fill); - camera_preview_frame.set_valign(gtk::Align::End); + camera_preview_frame.set_valign(gtk::Align::Fill); camera_preview_frame.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, @@ -345,9 +355,9 @@ camera_preview_overlay.add_controller(hover_controller); let camera_preview_idle_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); camera_preview_idle_frame.set_hexpand(true); - camera_preview_idle_frame.set_vexpand(false); + camera_preview_idle_frame.set_vexpand(true); camera_preview_idle_frame.set_halign(gtk::Align::Fill); - camera_preview_idle_frame.set_valign(gtk::Align::End); + camera_preview_idle_frame.set_valign(gtk::Align::Fill); camera_preview_idle_frame.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, diff --git a/client/src/launcher/ui_components/combo_helpers.rs b/client/src/launcher/ui_components/combo_helpers.rs index 476666d..a308fe4 100644 --- a/client/src/launcher/ui_components/combo_helpers.rs +++ b/client/src/launcher/ui_components/combo_helpers.rs @@ -71,6 +71,7 @@ pub fn sync_stage_device_combo( append_stage_choice(combo, value); } set_stage_combo_active_text(combo, selected); + refresh_device_choice_combo_child(combo); } pub fn sync_camera_quality_combo( @@ -108,6 +109,44 @@ pub fn sync_input_device_combo( append_input_choice(combo, value); } super::ui_runtime::set_combo_active_text(combo, selected); + refresh_device_choice_combo_child(combo); +} + +fn install_device_choice_combo_behavior(combo: >k::ComboBoxText) { + combo.add_css_class("device-choice-combo"); + combo.set_popup_fixed_width(false); + combo.set_tooltip_text(Some( + "Open the dropdown for full device names; hover clipped popup choices to scroll them.", + )); + + combo.connect_changed(move |combo| { + refresh_device_choice_combo_child(combo); + }); + refresh_device_choice_combo_child(combo); +} + +fn refresh_device_choice_combo_child(combo: >k::ComboBoxText) { + let full = combo_active_full_label(combo); + combo.set_tooltip_text(Some(&format!( + "Selected: {full}. Open the dropdown for compact choices; hover clipped popup choices to scroll them." + ))); +} + +fn combo_active_full_label(combo: >k::ComboBoxText) -> String { + let Some(active_id) = combo.active_id() else { + return "No device".to_string(); + }; + if active_id.as_str() == "all" { + return combo + .active_text() + .map(|text| text.to_string()) + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| "all devices".to_string()); + } + Device::open(active_id.as_str()) + .ok() + .and_then(|device| device.name().map(ToString::to_string)) + .unwrap_or_else(|| stage_choice_label(active_id.as_str())) } fn attach_device_control_row( @@ -159,11 +198,14 @@ fn append_input_choice(combo: >k::ComboBoxText, value: &str) { .ok() .and_then(|device| device.name().map(ToString::to_string)) .unwrap_or_else(|| short.to_string()); - combo.append(Some(value), &shorten_input_label(&label)); + combo.append(Some(value), &shorten_label_with_limit(&label, DEVICE_COMBO_BUTTON_LABEL_LIMIT)); } fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { - combo.append(Some(value), &compact_stage_label(value)); + combo.append( + Some(value), + &shorten_label_with_limit(&stage_choice_label(value), DEVICE_COMBO_BUTTON_LABEL_LIMIT), + ); } /// Keeps eye capture labels short so GTK does not widen the whole launcher. @@ -195,7 +237,7 @@ fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str> combo.set_active(Some(0)); } -fn compact_stage_label(value: &str) -> String { +fn stage_choice_label(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return "No device".to_string(); @@ -207,29 +249,29 @@ fn compact_stage_label(value: &str) -> String { .next() .unwrap_or(trimmed); if camera != trimmed { - return shorten_label(camera); + return camera.to_string(); } if let Some(rest) = trimmed .strip_prefix("alsa_input.") .or_else(|| trimmed.strip_prefix("alsa_output.")) { - return shorten_label(&human_audio_node_label(rest)); + return human_audio_node_label(rest); } if let Some(rest) = trimmed .strip_prefix("bluez_input.") .or_else(|| trimmed.strip_prefix("bluez_output.")) { - return shorten_label(&format!( + return format!( "Bluetooth {}", rest.split('.').next().unwrap_or(rest).replace('_', ":") - )); + ); } if let Some(short) = trimmed.rsplit('/').next() && short != trimmed { - return shorten_label(short); + return short.to_string(); } - shorten_label(trimmed) + trimmed.to_string() } fn human_audio_node_label(value: &str) -> String { @@ -252,13 +294,7 @@ fn human_audio_node_label(value: &str) -> String { } } -fn shorten_label(value: &str) -> String { - shorten_label_with_limit(value, 34) -} - -fn shorten_input_label(value: &str) -> String { - shorten_label_with_limit(value, 34) -} +const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 24; fn shorten_label_with_limit(value: &str, max: usize) -> String { let compact = value.replace('_', " "); diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index 64a763f..514fb2a 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -182,6 +182,17 @@ pub fn install_css(window: >k::ApplicationWindow) { button.media-toggle:disabled { opacity: 0.7; } + button.media-toggle-split { + padding-left: 7px; + padding-right: 7px; + } + combobox.device-choice-combo popover label { + transition: margin-left 160ms ease-out; + } + combobox.device-choice-combo popover row:hover label { + margin-left: -120px; + transition: margin-left 3200ms linear; + } button.pill-toggle-active { background: rgba(91, 179, 162, 0.2); border-color: rgba(91, 179, 162, 0.45); diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index d0dede2..ec4df96 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -217,8 +217,9 @@ const LAUNCHER_DEFAULT_WIDTH: i32 = 1540; const LAUNCHER_DEFAULT_HEIGHT: i32 = 880; const OPERATIONS_RAIL_WIDTH: i32 = 276; const RELAY_SUBGROUP_LABEL_WIDTH: i32 = 11; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 225; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400; +const DEVICE_STAGING_PANEL_WIDTH: i32 = 560; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 270; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 480; const EYE_PREVIEW_MIN_HEIGHT: i32 = 299; const EYE_PREVIEW_MIN_WIDTH: i32 = 532; const SIDE_LOG_MIN_HEIGHT: i32 = 124; diff --git a/common/Cargo.toml b/common/Cargo.toml index 02ddbc6..8e66d60 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.1" +version = "0.22.2" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index c86fb20..4661d25 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.1" +version = "0.22.2" edition = "2024" autobins = false diff --git a/tests/ui/client/launcher/client_launcher_layout_contract.rs b/tests/ui/client/launcher/client_launcher_layout_contract.rs index 39962a5..22bb953 100644 --- a/tests/ui/client/launcher/client_launcher_layout_contract.rs +++ b/tests/ui/client/launcher/client_launcher_layout_contract.rs @@ -153,8 +153,20 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() { assert!(UI_LAYOUT_SRC.contains("fn compact_capture_mode_label(")); assert!(UI_LAYOUT_SRC.contains("format!(\"{size}@{fps}\")")); assert!(UI_LAYOUT_SRC.contains("format!(\"Source {}\", compact_size_label(option.height))")); - assert!(UI_LAYOUT_SRC.contains("fn shorten_input_label(")); - assert!(UI_LAYOUT_SRC.contains("shorten_label_with_limit(value, 34)")); + assert!(UI_LAYOUT_SRC.contains("fn install_device_choice_combo_behavior(")); + assert!(UI_LAYOUT_SRC.contains("combo.set_popup_fixed_width(false);")); + assert!(UI_LAYOUT_SRC.contains("fn combo_active_full_label(")); + assert!(UI_LAYOUT_SRC.contains("Selected: {full}. Open the dropdown for compact choices")); + assert!(UI_LAYOUT_SRC.contains("const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 24;")); + assert!(UI_LAYOUT_SRC.contains("append_input_choice(combo, value);")); + assert!( + UI_LAYOUT_SRC.contains("shorten_label_with_limit(&label, DEVICE_COMBO_BUTTON_LABEL_LIMIT)") + ); + assert!(UI_LAYOUT_SRC.contains( + "shorten_label_with_limit(&stage_choice_label(value), DEVICE_COMBO_BUTTON_LABEL_LIMIT)" + )); + assert!(UI_LAYOUT_SRC.contains("hover clipped popup choices to scroll")); + assert!(UI_LAYOUT_SRC.contains("combobox.device-choice-combo popover row:hover label")); assert!( !UI_LAYOUT_SRC.contains("@ {} fps (Device H.264)"), "long capture labels force a huge GTK combo natural width" @@ -162,6 +174,7 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() { assert!(!UI_LAYOUT_SRC.contains("(Source Size)")); assert!(!UI_LAYOUT_SRC.contains("(Display Size)")); assert!(UI_LAYOUT_SRC.contains("combobox.compact-combo")); + assert!(UI_LAYOUT_SRC.contains("combobox.device-choice-combo")); assert!( UI_LAYOUT_SRC .matches(".add_css_class(\"compact-combo\")") @@ -175,6 +188,11 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() { fn device_staging_and_testing_stay_independent_so_preview_does_not_fill_dead_height() { assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(false);")); assert!(UI_LAYOUT_SRC.contains("staging_row.set_vexpand(false);")); + assert_eq!(const_i32("DEVICE_STAGING_PANEL_WIDTH"), 560); + assert!(UI_LAYOUT_SRC.contains("devices_panel.set_hexpand(false);")); + assert!( + UI_LAYOUT_SRC.contains("devices_panel.set_size_request(DEVICE_STAGING_PANEL_WIDTH, -1);") + ); assert!(UI_LAYOUT_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("preview_panel.set_hexpand(true);")); @@ -207,8 +225,8 @@ fn eye_placeholders_use_overlay_art_without_reflowing_the_shell() { #[test] fn device_testing_keeps_webcam_and_mic_playback_compact() { - assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 400); - assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 225); + assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 480); + assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 270); assert!(UI_LAYOUT_SRC.contains("devices_panel.set_vexpand(true);")); assert!(UI_LAYOUT_SRC.contains("devices_body.set_vexpand(true);")); assert!(UI_LAYOUT_SRC.contains("media_group.set_vexpand(true);")); @@ -233,8 +251,10 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() { .contains("camera_preview_stack.add_named(&camera_preview_overlay, Some(\"live\"));") ); assert!(UI_LAYOUT_SRC.contains("camera_preview_stack.set_visible_child_name(\"idle\");")); - assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_vexpand(false);")); - assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_valign(gtk::Align::End);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview_frame.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview_idle_frame.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview_idle_frame.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("playback_group.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("playback_group.set_vexpand(true);")); assert!(UI_LAYOUT_SRC.contains("preview_body.set_vexpand(true);")); @@ -425,7 +445,18 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { assert!(UI_LAYOUT_SRC.contains("camera_channel_toggle.add_css_class(\"media-toggle\");")); assert!(UI_LAYOUT_SRC.contains("audio_channel_toggle.add_css_class(\"media-toggle\");")); assert!(UI_LAYOUT_SRC.contains("microphone_channel_toggle.add_css_class(\"media-toggle\");")); + assert!( + UI_LAYOUT_SRC.contains("microphone_channel_toggle.add_css_class(\"media-toggle-split\");") + ); + assert!(UI_LAYOUT_SRC.contains("stabilize_button(µphone_channel_toggle, 44);")); + assert!( + UI_LAYOUT_SRC.contains("noise_suppression_toggle.add_css_class(\"media-toggle-split\");") + ); + assert!(UI_LAYOUT_SRC.contains("stabilize_button(&noise_suppression_toggle, 44);")); + assert!(UI_LAYOUT_SRC.contains("microphone_toggle_group.set_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("microphone_toggle_group.set_size_request(92, -1);")); assert!(UI_LAYOUT_SRC.contains("button.media-toggle:checked")); + assert!(UI_LAYOUT_SRC.contains("button.media-toggle-split")); assert!(UI_LAYOUT_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();")); assert!(UI_LAYOUT_SRC.contains("sync_camera_quality_combo(")); assert!(UI_LAYOUT_SRC.contains("camera_quality_combo.set_size_request(88, -1);"));