From 09c877a20421297004370f3e40fe6b920a90d2be Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 23 Apr 2026 14:15:02 -0300 Subject: [PATCH] fix(ui): restore compact launcher layout --- client/src/launcher/tests/ui_runtime.rs | 138 +++++++++++++++++- client/src/launcher/ui.rs | 16 +- client/src/launcher/ui/activation_context.rs | 1 + client/src/launcher/ui/activation_setup.rs | 1 + .../launcher/ui/session_preview_coverage.rs | 7 + .../src/launcher/ui/startup_window_guard.rs | 53 +++++++ client/src/launcher/ui_components.rs | 2 +- .../launcher/ui_components/assemble_view.rs | 2 +- .../launcher/ui_components/build_contexts.rs | 2 +- .../ui_components/build_device_controls.rs | 45 +++--- .../ui_components/build_operations_rail.rs | 14 +- .../src/launcher/ui_components/build_shell.rs | 22 +-- .../launcher/ui_components/combo_helpers.rs | 21 ++- .../launcher/ui_components/control_buttons.rs | 4 +- .../launcher/ui_components/display_pane.rs | 27 ++-- client/src/launcher/ui_components/types.rs | 12 +- .../tests/client_launcher_layout_contract.rs | 61 ++++---- 17 files changed, 318 insertions(+), 110 deletions(-) create mode 100644 client/src/launcher/ui/session_preview_coverage.rs create mode 100644 client/src/launcher/ui/startup_window_guard.rs diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 66bbe2f..89e3208 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -1,11 +1,13 @@ use super::*; use crate::launcher::{ - devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState, + devices::{CameraMode, DeviceCatalog}, + preview::PreviewBinding, + state::LauncherState, ui_components::build_launcher_view, }; use gtk::prelude::*; use serial_test::serial; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; #[test] fn local_test_detail_mentions_idle_and_running_modes() { @@ -59,19 +61,121 @@ fn launcher_shell_measures_inside_a_1080p_desktop_budget() { let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920); assert!( - min_width <= 1920 && natural_width <= 1920, + min_width <= 1280 && natural_width <= 1280, "launcher width budget regressed: min={min_width}, natural={natural_width}" ); assert!( - tall_min_width <= 1920 && tall_natural_width <= 1920, + tall_min_width <= 1280 && tall_natural_width <= 1280, "launcher 1080p-tall width regressed: min={tall_min_width}, natural={tall_natural_width}" ); assert!( - min_height <= 1080 && natural_height <= 1080, + min_height <= 860 && natural_height <= 1080, "launcher height budget regressed: min={min_height}, natural={natural_height}" ); } +#[gtk::test] +#[serial] +fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-layout-budget-populated") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let catalog = realistic_device_catalog(); + let mut state = LauncherState::new(); + state.apply_catalog_defaults(&catalog); + + let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state); + + let (min_width, natural_width, _, _) = view.window.measure(gtk::Orientation::Horizontal, -1); + let (tall_min_width, tall_natural_width, _, _) = + view.window.measure(gtk::Orientation::Horizontal, 1080); + let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920); + + assert!( + min_width <= 1280 && natural_width <= 1280, + "populated launcher width budget regressed: min={min_width}, natural={natural_width}" + ); + assert!( + tall_min_width <= 1280 && tall_natural_width <= 1280, + "populated launcher 1080p-tall width regressed: min={tall_min_width}, natural={tall_natural_width}" + ); + assert!( + min_height <= 860 && natural_height <= 1080, + "populated launcher height budget regressed: min={min_height}, natural={natural_height}" + ); +} + +#[gtk::test] +#[serial] +fn populated_launcher_runtime_widgets_stay_compact() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-layout-widget-budget") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let catalog = realistic_device_catalog(); + let mut state = LauncherState::new(); + state.apply_catalog_defaults(&catalog); + + let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state); + + let (camera_min_w, camera_nat_w, _, _) = view + .device_stage + .camera_preview + .measure(gtk::Orientation::Horizontal, -1); + let (camera_min_h, camera_nat_h, _, _) = view + .device_stage + .camera_preview + .measure(gtk::Orientation::Vertical, 160); + let (testing_panel_min_h, testing_panel_nat_h, _, _) = view + .device_stage + ._preview_panel + .measure(gtk::Orientation::Vertical, 320); + let (left_min_w, left_nat_w, _, _) = view.widgets.display_panes[0] + .root + .measure(gtk::Orientation::Horizontal, -1); + let (left_min_h, left_nat_h, _, _) = view.widgets.display_panes[0] + .root + .measure(gtk::Orientation::Vertical, 640); + let (server_min_w, server_nat_w, _, _) = + view.server_entry.measure(gtk::Orientation::Horizontal, -1); + + assert!( + camera_min_w <= 160 && camera_nat_w <= 160, + "camera preview width regressed: min={camera_min_w}, natural={camera_nat_w}" + ); + assert!( + camera_min_h <= 90 && camera_nat_h <= 90, + "camera preview height regressed: min={camera_min_h}, natural={camera_nat_h}" + ); + assert!( + testing_panel_min_h <= 260 && testing_panel_nat_h <= 260, + "device testing panel height regressed: min={testing_panel_min_h}, natural={testing_panel_nat_h}" + ); + assert!( + left_min_w <= 445 && left_nat_w <= 445, + "eye pane width regressed: min={left_min_w}, natural={left_nat_w}" + ); + assert!( + left_min_h <= 520 && left_nat_h <= 520, + "eye pane height regressed: min={left_min_h}, natural={left_nat_h}" + ); + assert!( + server_min_w <= 168 && server_nat_w <= 180, + "server entry width regressed: min={server_min_w}, natural={server_nat_w}" + ); +} + #[test] fn server_chip_state_tracks_connection_not_just_reachability() { let mut state = LauncherState::new(); @@ -292,6 +396,30 @@ fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() { assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); } +fn realistic_device_catalog() -> DeviceCatalog { + DeviceCatalog { + cameras: vec!["usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string()], + camera_modes: [( + "usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30), + ], + )] + .into_iter() + .collect::>(), + microphones: vec![ + "alsa_input.usb-Focusrite_Scarlett_2i2_USB_Y7ABC12345-00.analog-stereo".to_string(), + ], + speakers: vec![ + "alsa_output.pci-0000_00_1f.3.analog-stereo".to_string(), + "bluez_output.80_C3_BA_76_26_AB.1".to_string(), + ], + keyboards: vec!["usb-Corsair_K70_RGB_PRO_Mechanical_Gaming_Keyboard-event-kbd".to_string()], + mice: vec!["usb-Logitech_G502_X_LIGHTSPEED_Gaming_Mouse-event-mouse".to_string()], + } +} + #[gtk::test] #[serial] fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() { diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 05606d4..a24760c 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -37,6 +37,7 @@ use { gtk::prelude::*, lesavka_common::lesavka::CapturePowerCommand, lesavka_common::process_metrics::ProcessCpuSampler, + serde_json::json, std::cell::{Cell, RefCell}, std::collections::VecDeque, std::process::Command, @@ -53,6 +54,10 @@ include!("ui/diagnostic_sampling.rs"); include!("ui/preview_profiles.rs"); #[cfg(not(coverage))] include!("ui/activation_context.rs"); +#[cfg(not(coverage))] +include!("ui/startup_window_guard.rs"); +#[cfg(coverage)] +include!("ui/session_preview_coverage.rs"); #[cfg(not(coverage))] pub fn run_gui_launcher(server_addr: String) -> Result<()> { @@ -109,6 +114,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { app.connect_activate(move |app| { let ActivationContext { window, + launcher_size, server_entry, camera_combo, camera_quality_combo, @@ -154,6 +160,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let _: () = include!("ui/runtime_poll.rs"); window.present(); + schedule_launcher_window_guard(app, &window, launcher_size); }); } @@ -166,15 +173,6 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> { Ok(()) } -/// Keep the coverage stub aligned with the real preview activation rule. -#[cfg(coverage)] -fn session_preview_active( - state: &crate::launcher::state::LauncherState, - child_running: bool, -) -> bool { - (child_running || state.remote_active) && state.capture_power.mode != "forced-off" -} - #[cfg(all(test, not(coverage)))] #[path = "tests/ui_preview_profiles.rs"] mod tests; diff --git a/client/src/launcher/ui/activation_context.rs b/client/src/launcher/ui/activation_context.rs index 0d122f3..63b1012 100644 --- a/client/src/launcher/ui/activation_context.rs +++ b/client/src/launcher/ui/activation_context.rs @@ -1,6 +1,7 @@ #[cfg(not(coverage))] struct ActivationContext { window: gtk::ApplicationWindow, + launcher_size: (i32, i32), server_entry: gtk::Entry, camera_combo: gtk::ComboBoxText, camera_quality_combo: gtk::ComboBoxText, diff --git a/client/src/launcher/ui/activation_setup.rs b/client/src/launcher/ui/activation_setup.rs index 34a8e97..5b9bcec 100644 --- a/client/src/launcher/ui/activation_setup.rs +++ b/client/src/launcher/ui/activation_setup.rs @@ -132,6 +132,7 @@ ActivationContext { window, + launcher_size: (launcher_width, launcher_height), server_entry, camera_combo, camera_quality_combo, diff --git a/client/src/launcher/ui/session_preview_coverage.rs b/client/src/launcher/ui/session_preview_coverage.rs new file mode 100644 index 0000000..1ff8363 --- /dev/null +++ b/client/src/launcher/ui/session_preview_coverage.rs @@ -0,0 +1,7 @@ +/// Keep the coverage stub aligned with the real preview activation rule. +fn session_preview_active( + state: &crate::launcher::state::LauncherState, + child_running: bool, +) -> bool { + (child_running || state.remote_active) && state.capture_power.mode != "forced-off" +} diff --git a/client/src/launcher/ui/startup_window_guard.rs b/client/src/launcher/ui/startup_window_guard.rs new file mode 100644 index 0000000..7bf452e --- /dev/null +++ b/client/src/launcher/ui/startup_window_guard.rs @@ -0,0 +1,53 @@ +/// Re-applies the compact launcher size after `present()` so persisted window-manager +/// geometry cannot silently reopen the launcher too large for a 1080p desktop. +fn schedule_launcher_window_guard( + app: >k::Application, + window: >k::ApplicationWindow, + launcher_size: (i32, i32), +) { + let guard_window = window.clone(); + glib::timeout_add_local_once(Duration::from_millis(120), move || { + if guard_window.is_maximized() { + guard_window.unmaximize(); + } + guard_window.set_default_size(launcher_size.0, launcher_size.1); + guard_window.queue_allocate(); + }); + + let Ok(path) = std::env::var("LESAVKA_LAUNCHER_MEASURE_PATH") else { + return; + }; + let measure_window = window.clone(); + let app = app.clone(); + glib::timeout_add_local_once(Duration::from_millis(320), move || { + write_launcher_measurement(&measure_window, launcher_size, &path); + if std::env::var("LESAVKA_LAUNCHER_MEASURE_EXIT").ok().as_deref() == Some("1") { + app.quit(); + } + }); +} + +/// Emits a one-shot launcher size snapshot for local verification runs. +fn write_launcher_measurement( + window: >k::ApplicationWindow, + launcher_size: (i32, i32), + path: &str, +) { + let (min_width, natural_width, _, _) = window.measure(gtk::Orientation::Horizontal, -1); + let (min_height, natural_height, _, _) = + window.measure(gtk::Orientation::Vertical, launcher_size.0); + let payload = json!({ + "requested_width": launcher_size.0, + "requested_height": launcher_size.1, + "window_width": window.width(), + "window_height": window.height(), + "min_width": min_width, + "natural_width": natural_width, + "min_height": min_height, + "natural_height": natural_height, + }); + let _ = std::fs::write( + path, + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()), + ); +} diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 6543318..8f3b9a5 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -61,7 +61,7 @@ pub fn build_launcher_view( mic_gain_value, audio_check_detail, audio_check_meter, - device_body_height_group, + preview_panel, camera_preview, camera_status, camera_test_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 7abb048..f6e1b36 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -148,7 +148,6 @@ 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])); let diagnostics_popout = Rc::new(RefCell::new(None)); @@ -168,6 +167,7 @@ keyboard_combo, mouse_combo, device_stage: DeviceStageWidgets { + _preview_panel: preview_panel, camera_preview, camera_status, }, diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 3b76b5a..889883e 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -31,7 +31,7 @@ struct DeviceControlsContext { mic_gain_value: gtk::Label, audio_check_detail: gtk::Label, audio_check_meter: gtk::ProgressBar, - device_body_height_group: gtk::SizeGroup, + preview_panel: gtk::Box, camera_preview: gtk::Picture, camera_status: gtk::Label, camera_test_button: gtk::Button, diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index a7ae900..403e574 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -185,24 +185,21 @@ staging_row.append(&devices_panel); let (preview_panel, preview_body) = build_panel("Device Testing"); - preview_panel.set_hexpand(true); + preview_panel.set_hexpand(false); preview_panel.set_vexpand(false); preview_panel.set_valign(gtk::Align::Fill); preview_body.set_vexpand(false); preview_body.set_spacing(8); let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - testing_row.set_hexpand(true); - testing_row.set_vexpand(true); - testing_row.set_valign(gtk::Align::Fill); - let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical); - device_body_height_group.add_widget(&devices_body); - device_body_height_group.add_widget(&testing_row); + testing_row.set_hexpand(false); + testing_row.set_vexpand(false); + testing_row.set_valign(gtk::Align::Start); let camera_preview = gtk::Picture::new(); - camera_preview.set_can_shrink(false); - camera_preview.set_hexpand(true); - camera_preview.set_vexpand(true); + camera_preview.set_can_shrink(true); + camera_preview.set_hexpand(false); + camera_preview.set_vexpand(false); camera_preview.set_halign(gtk::Align::Fill); - camera_preview.set_valign(gtk::Align::Fill); + camera_preview.set_valign(gtk::Align::Start); camera_preview.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, @@ -216,19 +213,19 @@ camera_status.set_xalign(0.0); camera_status.set_visible(false); let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); - camera_preview_shell.set_hexpand(true); - camera_preview_shell.set_vexpand(true); + camera_preview_shell.set_hexpand(false); + camera_preview_shell.set_vexpand(false); camera_preview_shell.set_halign(gtk::Align::Fill); - camera_preview_shell.set_valign(gtk::Align::Fill); + camera_preview_shell.set_valign(gtk::Align::Start); camera_preview_shell.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, ); 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(true); + camera_preview_frame.set_hexpand(false); + camera_preview_frame.set_vexpand(false); camera_preview_frame.set_halign(gtk::Align::Fill); - camera_preview_frame.set_valign(gtk::Align::Fill); + camera_preview_frame.set_valign(gtk::Align::Start); camera_preview_frame.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, @@ -236,27 +233,27 @@ camera_preview_frame.set_child(Some(&camera_preview)); camera_preview_shell.append(&camera_preview_frame); let webcam_group = build_subgroup("Webcam Preview"); - webcam_group.set_hexpand(true); - webcam_group.set_vexpand(true); - webcam_group.set_valign(gtk::Align::Fill); + webcam_group.set_hexpand(false); + webcam_group.set_vexpand(false); + webcam_group.set_valign(gtk::Align::Start); webcam_group.append(&camera_preview_shell); testing_row.append(&webcam_group); let playback_group = build_subgroup("Mic Playback"); playback_group.set_hexpand(false); - playback_group.set_vexpand(true); + playback_group.set_vexpand(false); playback_group.set_valign(gtk::Align::Fill); playback_group.set_size_request(72, -1); let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); playback_body.set_halign(gtk::Align::Center); - playback_body.set_vexpand(true); + playback_body.set_vexpand(false); playback_body.set_valign(gtk::Align::Fill); let microphone_replay_button = gtk::Button::with_label("Replay"); stabilize_button(µphone_replay_button, 70); audio_check_meter.set_orientation(gtk::Orientation::Vertical); audio_check_meter.set_inverted(true); audio_check_meter.set_hexpand(false); - audio_check_meter.set_vexpand(true); + audio_check_meter.set_vexpand(false); audio_check_meter.set_halign(gtk::Align::Center); audio_check_meter.set_size_request(20, 0); audio_check_meter.set_show_text(false); @@ -285,7 +282,7 @@ mic_gain_value, audio_check_detail, audio_check_meter, - device_body_height_group, + preview_panel, camera_preview, camera_status, camera_test_button, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index fa583e7..b0ced7f 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -3,12 +3,12 @@ let server_entry = gtk::Entry::new(); server_entry.add_css_class("server-entry"); server_entry.set_hexpand(true); - server_entry.set_width_chars(18); + server_entry.set_width_chars(14); server_entry.set_text(server_addr); server_entry.set_tooltip_text(Some("Relay host address.")); let relay_grid = gtk::Grid::new(); relay_grid.set_column_homogeneous(true); - relay_grid.set_column_spacing(8); + relay_grid.set_column_spacing(6); relay_grid.set_hexpand(true); relay_grid.set_row_spacing(8); relay_grid.attach(&server_entry, 0, 0, 2, 1); @@ -17,8 +17,8 @@ start_button.add_css_class("suggested-action"); relay_grid.attach(&start_button, 2, 0, 1, 1); - let clipboard_button = rail_button("Send Clipboard", "Type clipboard remotely."); - let probe_button = rail_button("Copy Gate Probe", "Copy quality probe."); + let clipboard_button = rail_button("Clipboard", "Type clipboard remotely."); + let probe_button = rail_button("Gate Probe", "Copy quality probe."); let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB."); relay_grid.attach(&clipboard_button, 0, 1, 1, 1); relay_grid.attach(&probe_button, 1, 1, 1, 1); @@ -104,10 +104,12 @@ diagnostics_label.set_selectable(true); diagnostics_label.set_xalign(0.0); diagnostics_label.set_yalign(0.0); - diagnostics_label.set_wrap(false); + diagnostics_label.set_wrap(true); + diagnostics_label.set_wrap_mode(pango::WrapMode::WordChar); diagnostics_label.set_halign(gtk::Align::Start); diagnostics_label.set_valign(gtk::Align::Start); diagnostics_label.set_hexpand(true); + diagnostics_label.set_width_chars(1); let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); diagnostics_shell.set_hexpand(true); diagnostics_shell.set_vexpand(false); @@ -118,6 +120,7 @@ .min_content_height(SIDE_LOG_MIN_HEIGHT) .child(&diagnostics_shell) .build(); + diagnostics_scroll.set_propagate_natural_width(false); diagnostics_body.append(&diagnostics_toolbar); diagnostics_body.append(&diagnostics_scroll); operations.append(&diagnostics_panel); @@ -173,6 +176,7 @@ .min_content_height(SIDE_LOG_MIN_HEIGHT) .child(&session_log_view) .build(); + log_scroll.set_propagate_natural_width(false); console_body.append(&console_toolbar); console_body.append(&log_scroll); operations.append(&console_panel); diff --git a/client/src/launcher/ui_components/build_shell.rs b/client/src/launcher/ui_components/build_shell.rs index 46575e4..f81b3a2 100644 --- a/client/src/launcher/ui_components/build_shell.rs +++ b/client/src/launcher/ui_components/build_shell.rs @@ -9,12 +9,12 @@ install_css(&window); install_window_icon(&window); - let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + let root = gtk::Box::new(gtk::Orientation::Vertical, 6); root.add_css_class("launcher-root"); - root.set_margin_start(10); - root.set_margin_end(10); - root.set_margin_top(10); - root.set_margin_bottom(10); + root.set_margin_start(7); + root.set_margin_end(7); + root.set_margin_top(8); + root.set_margin_bottom(8); let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8); hero.set_hexpand(true); @@ -57,24 +57,24 @@ hero.append(&chips); root.append(&hero); - let content = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 5); content.set_hexpand(true); content.set_vexpand(true); root.append(&content); - let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8); + let workspace = gtk::Box::new(gtk::Orientation::Vertical, 6); workspace.set_hexpand(true); workspace.set_vexpand(true); content.append(&workspace); - let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); + let operations = gtk::Box::new(gtk::Orientation::Vertical, 6); operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1); operations.set_hexpand(false); operations.set_vexpand(true); operations.set_valign(gtk::Align::Fill); content.append(&operations); - let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); display_row.set_hexpand(true); display_row.set_vexpand(false); display_row.set_valign(gtk::Align::Start); @@ -85,11 +85,11 @@ display_row.append(&right_pane.root); workspace.append(&display_row); - let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); staging_row.set_hexpand(true); staging_row.set_vexpand(false); staging_row.set_valign(gtk::Align::Start); - staging_row.set_homogeneous(true); + staging_row.set_homogeneous(false); workspace.append(&staging_row); LauncherShellContext { diff --git a/client/src/launcher/ui_components/combo_helpers.rs b/client/src/launcher/ui_components/combo_helpers.rs index 0843963..8f1c487 100644 --- a/client/src/launcher/ui_components/combo_helpers.rs +++ b/client/src/launcher/ui_components/combo_helpers.rs @@ -128,7 +128,7 @@ fn build_inline_selector_row(label: &str, combo: >k::ComboBoxText) -> gtk::Box let block = gtk::Box::new(gtk::Orientation::Horizontal, 8); let label_widget = gtk::Label::new(Some(label)); label_widget.set_halign(gtk::Align::Start); - label_widget.set_width_chars(9); + label_widget.set_width_chars(7); label_widget.set_xalign(0.0); combo.set_hexpand(true); combo.set_size_request(0, -1); @@ -157,9 +157,9 @@ fn append_input_choice(combo: >k::ComboBoxText, value: &str) { let short = value.rsplit('/').next().unwrap_or(value); let label = Device::open(value) .ok() - .and_then(|device| device.name().map(|name| format!("{name} • {short}"))) + .and_then(|device| device.name().map(ToString::to_string)) .unwrap_or_else(|| short.to_string()); - combo.append(Some(value), &label); + combo.append(Some(value), &shorten_input_label(&label)); } fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { @@ -243,9 +243,9 @@ fn human_audio_node_label(value: &str) -> String { .replace(['-', '_'], " "); if compact.starts_with("pci ") || compact.starts_with("pci-") { if compact.contains("analog stereo") { - "Built-in analog stereo".to_string() + "Analog stereo".to_string() } else { - "Built-in audio".to_string() + "System audio".to_string() } } else { compact @@ -253,10 +253,17 @@ fn human_audio_node_label(value: &str) -> String { } fn shorten_label(value: &str) -> String { - const MAX: usize = 44; + shorten_label_with_limit(value, 20) +} + +fn shorten_input_label(value: &str) -> String { + shorten_label_with_limit(value, 22) +} + +fn shorten_label_with_limit(value: &str, max: usize) -> String { let compact = value.replace('_', " "); let mut chars = compact.chars(); - let preview: String = chars.by_ref().take(MAX).collect(); + let preview: String = chars.by_ref().take(max).collect(); if chars.next().is_some() { format!("{preview}…") } else { diff --git a/client/src/launcher/ui_components/control_buttons.rs b/client/src/launcher/ui_components/control_buttons.rs index 7d7a8aa..e089512 100644 --- a/client/src/launcher/ui_components/control_buttons.rs +++ b/client/src/launcher/ui_components/control_buttons.rs @@ -1,5 +1,5 @@ -const RAIL_BUTTON_WIDTH: i32 = 92; -const RAIL_BUTTON_LABEL_CHARS: i32 = 14; +const RAIL_BUTTON_WIDTH: i32 = 86; +const RAIL_BUTTON_LABEL_CHARS: i32 = 11; /// Build a rail button that can shrink without forcing the operations column wider. fn rail_button(label: &str, tooltip: &str) -> gtk::Button { diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index 4360f2a..b76ff6d 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -2,7 +2,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { let root = gtk::Box::new(gtk::Orientation::Vertical, 10); root.add_css_class("display-card"); root.set_hexpand(true); - root.set_vexpand(true); + root.set_vexpand(false); let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); header.set_hexpand(true); @@ -20,18 +20,18 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { let picture = gtk::Picture::new(); picture.set_hexpand(true); - picture.set_vexpand(true); + picture.set_vexpand(false); picture.set_halign(gtk::Align::Fill); - picture.set_valign(gtk::Align::Fill); + picture.set_valign(gtk::Align::Start); picture.set_can_shrink(true); picture.set_keep_aspect_ratio(true); picture.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); preview_box.set_hexpand(true); - preview_box.set_vexpand(true); + preview_box.set_vexpand(false); preview_box.set_halign(gtk::Align::Fill); - preview_box.set_valign(gtk::Align::Fill); + preview_box.set_valign(gtk::Align::Start); preview_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); let preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); preview_frame.set_hexpand(false); @@ -53,13 +53,13 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); placeholder_box.add_css_class("display-placeholder"); placeholder_box.set_hexpand(true); - placeholder_box.set_vexpand(true); + placeholder_box.set_vexpand(false); placeholder_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); placeholder_box.append(&placeholder); let stack = gtk::Stack::new(); stack.set_hexpand(true); - stack.set_vexpand(true); + stack.set_vexpand(false); stack.add_named(&preview_box, Some("preview")); stack.add_named(&placeholder_box, Some("placeholder")); stack.set_visible_child_name("preview"); @@ -81,7 +81,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { 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); + stabilize_button(&action_button, 96); action_button.set_halign(gtk::Align::End); let stream_status = gtk::Label::new(Some("Preview pending")); stream_status.add_css_class("status-line"); @@ -91,17 +91,18 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { 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_width_chars(10); + stream_status.set_max_width_chars(16); stream_status.set_tooltip_text(Some("Eye stream status.")); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); + footer_shell.set_vexpand(false); let controls_grid = gtk::Grid::new(); controls_grid.set_column_spacing(8); controls_grid.set_row_spacing(8); controls_grid.set_hexpand(true); - let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7); - let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7); - let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7); + let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 6); + let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 6); + let breakout_row = build_inline_combo_row("Display", &breakout_combo, 6); feed_row.set_hexpand(true); capture_row.set_hexpand(true); breakout_row.set_hexpand(true); diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index 6199d94..02b56a3 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -153,11 +153,11 @@ pub struct LauncherWidgets { pub console_popout_button: gtk::Button, pub console_level_combo: gtk::ComboBoxText, pub session_log_level: Rc>, - pub _device_body_height_group: gtk::SizeGroup, } #[derive(Clone)] pub struct DeviceStageWidgets { + pub _preview_panel: gtk::Box, pub camera_preview: gtk::Picture, pub camera_status: gtk::Label, } @@ -183,9 +183,9 @@ pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons"); const LAUNCHER_DEFAULT_WIDTH: i32 = 1280; const LAUNCHER_DEFAULT_HEIGHT: i32 = 780; -const OPERATIONS_RAIL_WIDTH: i32 = 304; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 146; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; -const EYE_PREVIEW_MIN_HEIGHT: i32 = 258; -const EYE_PREVIEW_MIN_WIDTH: i32 = 460; +const OPERATIONS_RAIL_WIDTH: i32 = 288; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 90; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 160; +const EYE_PREVIEW_MIN_HEIGHT: i32 = 203; +const EYE_PREVIEW_MIN_WIDTH: i32 = 360; const SIDE_LOG_MIN_HEIGHT: i32 = 124; diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index bf3c97c..6336b46 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -7,6 +7,8 @@ //! regressions can hide diagnostics or make eye/device previews unusable. const UI_LAYOUT_SRC: &str = concat!( + include_str!("../../client/src/launcher/ui.rs"), + include_str!("../../client/src/launcher/ui/startup_window_guard.rs"), include_str!("../../client/src/launcher/ui_components/types.rs"), include_str!("../../client/src/launcher/ui_components/build_shell.rs"), include_str!("../../client/src/launcher/ui/preview_profiles.rs"), @@ -52,22 +54,22 @@ fn launcher_default_size_stays_inside_1080p() { .contains("window.set_size_request(LAUNCHER_DEFAULT_WIDTH, LAUNCHER_DEFAULT_HEIGHT);"), "the top-level window should not pin a larger minimum than the startup budget" ); - assert!( - UI_LAYOUT_SRC.contains("let max_width = width.saturating_sub(72).max(640) as i32;") - ); - assert!( - UI_LAYOUT_SRC.contains("let max_height = height.saturating_sub(120).max(520) as i32;") - ); + assert!(UI_LAYOUT_SRC.contains("let max_width = width.saturating_sub(72).max(640) as i32;")); + assert!(UI_LAYOUT_SRC.contains("let max_height = height.saturating_sub(120).max(520) as i32;")); assert!( UI_LAYOUT_SRC.contains("(1280.min(max_width), 780.min(max_height))"), "the activation path must use the same compact startup budget as the shell" ); + assert!(UI_LAYOUT_SRC.contains("schedule_launcher_window_guard(app, &window, launcher_size);")); + assert!( + UI_LAYOUT_SRC.contains("guard_window.set_default_size(launcher_size.0, launcher_size.1);") + ); } #[test] fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() { - assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460); - assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 258); + assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 360); + assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 203); assert!(UI_LAYOUT_SRC.contains("display_row.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("display_row.set_valign(gtk::Align::Start);")); assert!( @@ -96,6 +98,8 @@ 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, 22)")); assert!( !UI_LAYOUT_SRC.contains("@ {} fps (Device H.264)"), "long capture labels force a huge GTK combo natural width" @@ -113,27 +117,33 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() { } #[test] -fn device_staging_and_testing_bottoms_stay_locked_together() { - assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(true);")); +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!(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( - "let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);" - )); - assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&devices_body);")); - assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&testing_row);")); + assert!(UI_LAYOUT_SRC.contains("preview_panel.set_hexpand(false);")); + assert!( + !UI_LAYOUT_SRC.contains("gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical)"), + "the webcam testing column must not inherit the full height of the staging controls" + ); } #[test] -fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() { - assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 280); - assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 146); - assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);")); +fn device_testing_keeps_webcam_and_mic_playback_compact() { + assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 160); + assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 90); + assert!(UI_LAYOUT_SRC.contains("testing_row.set_vexpand(false);")); + assert!(UI_LAYOUT_SRC.contains("testing_row.set_valign(gtk::Align::Start);")); + assert!(UI_LAYOUT_SRC.contains("webcam_group.set_vexpand(false);")); + assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Start);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview.set_can_shrink(true);")); + assert!(UI_LAYOUT_SRC.contains("camera_preview.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("playback_group.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("playback_group.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("preview_body.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("playback_body.set_valign(gtk::Align::Fill);")); - assert!(UI_LAYOUT_SRC.contains("audio_check_meter.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("audio_check_meter.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("playback_body.append(&audio_check_meter);")); assert!(UI_LAYOUT_SRC.contains("playback_body.append(µphone_replay_button);")); } @@ -195,17 +205,17 @@ fn status_chip_text_is_centered_inside_each_pill() { #[test] fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")")); - assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 304); - assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 92); - assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 14); + assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 288); + assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86); + assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11); assert!(UI_LAYOUT_SRC.contains("let relay_grid = gtk::Grid::new();")); assert!(UI_LAYOUT_SRC.contains("relay_grid.set_column_homogeneous(true);")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&server_entry, 0, 0, 2, 1);")); assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\"")); assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label(")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);")); - assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Send Clipboard\"")); - assert!(UI_LAYOUT_SRC.contains("let probe_button = rail_button(\"Copy Gate Probe\"")); + assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); + assert!(UI_LAYOUT_SRC.contains("let probe_button = rail_button(\"Gate Probe\"")); assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"Recover USB\"")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&clipboard_button, 0, 1, 1, 1);")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&probe_button, 1, 1, 1, 1);")); @@ -246,6 +256,7 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { ); assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&speaker_combo);")); assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&audio_gain_scale);")); + assert!(UI_LAYOUT_SRC.contains("label_widget.set_width_chars(7);")); assert!( UI_LAYOUT_SRC .contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")