From 3f3fad7c50c1f8e1466b3708a0e55ee43afeaba1 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 23 Apr 2026 11:14:58 -0300 Subject: [PATCH] fix(ui): polish launcher status and controls --- README.md | 6 +- client/src/launcher/tests/mod.rs | 3 + .../src/launcher/tests/ui_components_scale.rs | 10 + client/src/launcher/tests/ui_runtime.rs | 10 +- client/src/launcher/tests/utility_actions.rs | 180 ++++++++++++++++++ client/src/launcher/ui_components.rs | 3 +- .../ui_components/build_device_controls.rs | 6 + .../ui_components/build_operations_rail.rs | 45 ++--- .../launcher/ui_components/combo_helpers.rs | 50 +++-- .../launcher/ui_components/control_buttons.rs | 33 ++++ .../launcher/ui_components/display_pane.rs | 7 +- .../src/launcher/ui_components/panel_chips.rs | 13 +- .../src/launcher/ui_components/scale_reset.rs | 27 ++- client/src/launcher/ui_components/style.rs | 11 ++ client/src/launcher/ui_components/types.rs | 8 +- .../src/launcher/ui_runtime/log_filtering.rs | 1 + .../src/launcher/ui_runtime/status_details.rs | 39 +++- .../src/launcher/ui_runtime/status_refresh.rs | 19 +- scripts/ci/hygiene_gate_baseline.json | 27 +-- .../tests/client_launcher_layout_contract.rs | 87 +++++++-- .../tests/client_launcher_runtime_contract.rs | 29 ++- 21 files changed, 505 insertions(+), 109 deletions(-) create mode 100644 client/src/launcher/tests/utility_actions.rs create mode 100644 client/src/launcher/ui_components/control_buttons.rs diff --git a/README.md b/README.md index 1d0a97f..c5bc2d2 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ Lesavka is a control surface for lab equipment that needs to feel close at hand even when the actual machine is all the way down a flight of stairs. It pulls the important pieces into one place: the two eye feeds, input ownership, staged camera/audio devices, capture power, diagnostics, and the session log. -The point is simple: sit down at the operator station, confirm the equipment side is awake, pick the devices you want, and work without guessing what the rig is doing. +The point is simple: sit down at the desk, confirm the equipment side is awake, pick the devices you want, and work without guessing what the lab machine is doing. ## What It Does - Shows the left and right eye feeds in the launcher, with breakout windows when you want more room. -- Lets you stage camera, speaker, microphone, keyboard, and mouse choices before a session starts. +- Lets you stage input and output choices before a session starts. - Moves keyboard and pointer ownership between the operator station and the equipment side on purpose, not by accident. -- Keeps capture power and GPIO state visible enough that you can tell whether the bench is actually awake. +- Keeps capture power and GPIO state visible to tell whether the capture devices are actually awake. - Keeps diagnostics and logs close by so a weird media/device state is something we can prove, not hand-wave. - Installs through repeatable client and server scripts so a reboot or reinstall does not leave mystery settings floating around. diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs index 036366b..6f97e60 100644 --- a/client/src/launcher/tests/mod.rs +++ b/client/src/launcher/tests/mod.rs @@ -1,6 +1,9 @@ use super::*; use serial_test::serial; +#[cfg(not(coverage))] +mod utility_actions; + #[test] fn resolve_server_addr_prefers_explicit_server_flag() { let args = vec![ diff --git a/client/src/launcher/tests/ui_components_scale.rs b/client/src/launcher/tests/ui_components_scale.rs index f73b98a..32812ea 100644 --- a/client/src/launcher/tests/ui_components_scale.rs +++ b/client/src/launcher/tests/ui_components_scale.rs @@ -7,3 +7,13 @@ fn scale_reset_helper_requires_a_true_double_click_and_a_real_change() { assert!(should_reset_scale_on_double_click(2, 350.0, 200.0)); assert!(should_reset_scale_on_double_click(2, 75.0, 100.0)); } + +#[test] +fn scale_reset_handler_claims_double_click_and_reapplies_after_pointer_click() { + let source = include_str!("../ui_components/scale_reset.rs"); + assert!(source.contains("gtk::PropagationPhase::Capture")); + assert!(source.contains("gtk::EventSequenceState::Claimed")); + assert!(source.contains("glib::idle_add_local_once")); + assert!(source.contains("glib::timeout_add_local_once")); + assert!(source.contains("[20_u64, 75]")); +} diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 9144be4..c3b0eb5 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -40,15 +40,21 @@ fn server_chip_state_tracks_connection_not_just_reachability() { state.set_server_available(true); state.set_server_version(Some("0.12.3".to_string())); - assert_eq!(server_light_state(&state, false), StatusLightState::Caution); + assert_eq!(server_light_state(&state, false), StatusLightState::Live); assert_eq!(server_version_label(&state), "v0.12.3"); - assert_eq!(server_light_state(&state, true), StatusLightState::Live); + assert_eq!( + server_light_state(&state, true), + StatusLightState::Connected + ); state.set_server_version(Some("v0.12.4".to_string())); + assert_eq!(server_light_state(&state, false), StatusLightState::Warning); + assert_eq!(server_light_state(&state, true), StatusLightState::Caution); assert_eq!(server_version_label(&state), "v0.12.4"); state.set_server_version(Some(" ".to_string())); + assert_eq!(server_light_state(&state, false), StatusLightState::Idle); assert_eq!(server_version_label(&state), "-"); } diff --git a/client/src/launcher/tests/utility_actions.rs b/client/src/launcher/tests/utility_actions.rs new file mode 100644 index 0000000..c7bb11a --- /dev/null +++ b/client/src/launcher/tests/utility_actions.rs @@ -0,0 +1,180 @@ +use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget}; +use futures::stream; +use lesavka_common::lesavka::{ + AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, + PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, + relay_server::{Relay, RelayServer}, +}; +use serial_test::serial; +use std::{ + pin::Pin, + sync::{Arc, Mutex}, + time::Duration, +}; +use tonic::{Request, Response, Status}; + +type KeyboardStream = Pin> + Send>>; +type MouseStream = Pin> + Send>>; +type VideoStream = Pin> + Send>>; +type AudioStream = Pin> + Send>>; +type EmptyStream = Pin> + Send>>; + +#[derive(Clone)] +struct UtilityRelay { + paste_count: Arc>, + reset_count: Arc>, + reset_ok: bool, +} + +impl UtilityRelay { + fn new(reset_ok: bool) -> Self { + Self { + paste_count: Arc::new(Mutex::new(0)), + reset_count: Arc::new(Mutex::new(0)), + reset_ok, + } + } +} + +#[tonic::async_trait] +impl Relay for UtilityRelay { + type StreamKeyboardStream = KeyboardStream; + type StreamMouseStream = MouseStream; + type CaptureVideoStream = VideoStream; + type CaptureAudioStream = AudioStream; + type StreamMicrophoneStream = EmptyStream; + type StreamCameraStream = EmptyStream; + + async fn stream_keyboard( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_mouse( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn capture_video( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn capture_audio( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_microphone( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_camera( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn paste_text( + &self, + _request: Request, + ) -> Result, Status> { + *self.paste_count.lock().expect("paste count") += 1; + Ok(Response::new(PasteReply { + ok: true, + error: String::new(), + })) + } + + async fn reset_usb(&self, _request: Request) -> Result, Status> { + *self.reset_count.lock().expect("reset count") += 1; + Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) + } + + async fn get_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CapturePowerState::default())) + } + + async fn set_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CapturePowerState::default())) + } +} + +fn serve(relay: UtilityRelay) -> (tokio::runtime::Runtime, String) { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind relay"); + let addr = listener.local_addr().expect("relay addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + std::thread::sleep(Duration::from_millis(50)); + (rt, format!("http://{addr}")) +} + +#[test] +#[serial] +fn clipboard_action_delivers_text_over_rpc_when_key_is_available() { + temp_env::with_vars( + [ + ( + "LESAVKA_PASTE_KEY", + Some("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"), + ), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ("LESAVKA_CLIPBOARD_TIMEOUT_MS", Some("1500")), + ], + || { + let relay = UtilityRelay::new(true); + let paste_count = Arc::clone(&relay.paste_count); + let (_rt, addr) = serve(relay); + + let result = + send_clipboard_text_to_remote(&addr, "hello from launcher").expect("clipboard RPC"); + + assert_eq!(result, "Clipboard delivered to remote"); + assert_eq!(*paste_count.lock().expect("paste count"), 1); + }, + ); +} + +#[test] +#[serial] +fn recover_usb_action_reports_relay_success_and_failure() { + let relay = UtilityRelay::new(true); + let reset_count = Arc::clone(&relay.reset_count); + let (_rt, addr) = serve(relay); + reset_usb_gadget(&addr).expect("successful reset"); + assert_eq!(*reset_count.lock().expect("reset count"), 1); + + let relay = UtilityRelay::new(false); + let reset_count = Arc::clone(&relay.reset_count); + let (_rt, addr) = serve(relay); + let err = reset_usb_gadget(&addr).expect_err("reset failure"); + assert!(format!("{err:#}").contains("relay reported USB reset failure")); + assert_eq!(*reset_count.lock().expect("reset count"), 1); +} diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 10283cc..6543318 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; use evdev::Device; -use gtk::{pango, prelude::*}; +use gtk::{glib, pango, prelude::*}; use super::{ devices::{CameraMode, DeviceCatalog}, @@ -18,6 +18,7 @@ include!("ui_components/build_contexts.rs"); include!("ui_components/style.rs"); include!("ui_components/panel_chips.rs"); include!("ui_components/combo_helpers.rs"); +include!("ui_components/control_buttons.rs"); include!("ui_components/display_pane.rs"); include!("ui_components/scale_reset.rs"); diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index 61acb05..a7ae900 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -14,12 +14,14 @@ control_group.append(&control_stack); let camera_combo = gtk::ComboBoxText::new(); + camera_combo.add_css_class("compact-combo"); sync_stage_device_combo( &camera_combo, &catalog.cameras, state.devices.camera.as_deref(), ); let camera_quality_combo = gtk::ComboBoxText::new(); + camera_quality_combo.add_css_class("compact-combo"); sync_camera_quality_combo( &camera_quality_combo, &state.camera_quality_options(catalog), @@ -32,6 +34,7 @@ camera_test_button.set_tooltip_text(Some("Preview selected webcam locally.")); let speaker_combo = gtk::ComboBoxText::new(); + speaker_combo.add_css_class("compact-combo"); sync_stage_device_combo( &speaker_combo, &catalog.speakers, @@ -42,6 +45,7 @@ speaker_test_button.set_tooltip_text(Some("Play a local test tone.")); let keyboard_combo = gtk::ComboBoxText::new(); + keyboard_combo.add_css_class("compact-combo"); keyboard_combo.append(Some("all"), "all keyboards"); for keyboard in &catalog.keyboards { append_input_choice(&keyboard_combo, keyboard); @@ -52,6 +56,7 @@ control_stack.append(&keyboard_row); let mouse_combo = gtk::ComboBoxText::new(); + mouse_combo.add_css_class("compact-combo"); mouse_combo.append(Some("all"), "all mice"); for mouse in &catalog.mice { append_input_choice(&mouse_combo, mouse); @@ -145,6 +150,7 @@ ); let microphone_combo = gtk::ComboBoxText::new(); + microphone_combo.add_css_class("compact-combo"); sync_stage_device_combo( µphone_combo, &catalog.microphones, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 2137536..fa583e7 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -6,35 +6,24 @@ server_entry.set_width_chars(18); server_entry.set_text(server_addr); 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); - relay_row.append(&server_entry); - let start_button = gtk::Button::with_label("Connect"); - start_button.add_css_class("suggested-action"); - start_button.set_hexpand(false); - stabilize_button(&start_button, 108); - relay_row.append(&start_button); - connection_body.append(&relay_row); + let relay_grid = gtk::Grid::new(); + relay_grid.set_column_homogeneous(true); + relay_grid.set_column_spacing(8); + relay_grid.set_hexpand(true); + relay_grid.set_row_spacing(8); + relay_grid.attach(&server_entry, 0, 0, 2, 1); - let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - live_actions_row.set_homogeneous(true); - 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 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 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("Re-enumerate remote USB.")); - live_actions_row.append(&clipboard_button); - live_actions_row.append(&probe_button); - live_actions_row.append(&usb_recover_button); - connection_body.append(&live_actions_row); + let start_button = rail_button("Connect", "Start or stop relay."); + 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 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); + relay_grid.attach(&usb_recover_button, 2, 1, 1, 1); + connection_body.append(&relay_grid); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let power_heading = gtk::Label::new(Some("GPIO Power")); diff --git a/client/src/launcher/ui_components/combo_helpers.rs b/client/src/launcher/ui_components/combo_helpers.rs index 1504985..0843963 100644 --- a/client/src/launcher/ui_components/combo_helpers.rs +++ b/client/src/launcher/ui_components/combo_helpers.rs @@ -19,13 +19,7 @@ pub fn sync_capture_resolution_combo( combo.remove_all(); let option_count = options.len(); for option in options { - let label = format!( - "{} • {}x{} @ {} fps (Device H.264)", - option.preset.label(), - option.width, - option.height, - option.fps, - ); + let label = compact_capture_mode_label(option.width, option.height, option.fps); combo.append(Some(option.preset.as_id()), &label); } combo.set_active_id(Some(selected.as_id())); @@ -56,28 +50,11 @@ pub fn sync_breakout_size_combo( combo.remove_all(); for option in options { let label = match option.preset { - BreakoutSizePreset::Source => { - format!( - "{} • {}x{} (Source Size)", - option.preset.label(), - option.width, - option.height - ) - } + BreakoutSizePreset::Source => format!("Source {}", compact_size_label(option.height)), BreakoutSizePreset::FillDisplay => { - format!( - "{} • {}x{} (Display Size)", - option.preset.label(), - option.width, - option.height - ) + format!("Display {}", compact_size_label(option.height)) } - _ => format!( - "{} • {}x{}", - option.preset.label(), - option.width, - option.height - ), + _ => format!("{} {}x{}", option.preset.label(), option.width, option.height), }; combo.append(Some(option.preset.as_id()), &label); } @@ -189,6 +166,25 @@ fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { combo.append(Some(value), &compact_stage_label(value)); } +/// Keeps eye capture labels short so GTK does not widen the whole launcher. +fn compact_capture_mode_label(width: i32, height: i32, fps: u32) -> String { + let size = compact_size_label(height); + if width >= 1280 { + format!("{size}@{fps}") + } else { + format!("{width}x{height}@{fps}") + } +} + +/// Formats common video heights in the same terse style used by meeting apps. +fn compact_size_label(height: i32) -> String { + match height { + 2160 => "4K".to_string(), + 1080 | 720 | 576 | 480 => format!("{height}p"), + other => format!("{other}p"), + } +} + fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) { if selected .filter(|value| !value.trim().is_empty()) diff --git a/client/src/launcher/ui_components/control_buttons.rs b/client/src/launcher/ui_components/control_buttons.rs new file mode 100644 index 0000000..7d7a8aa --- /dev/null +++ b/client/src/launcher/ui_components/control_buttons.rs @@ -0,0 +1,33 @@ +const RAIL_BUTTON_WIDTH: i32 = 92; +const RAIL_BUTTON_LABEL_CHARS: i32 = 14; + +/// Build a rail button that can shrink without forcing the operations column wider. +fn rail_button(label: &str, tooltip: &str) -> gtk::Button { + let button = gtk::Button::new(); + button.set_hexpand(true); + button.set_tooltip_text(Some(tooltip)); + stabilize_button(&button, RAIL_BUTTON_WIDTH); + + let text = gtk::Label::new(Some(label)); + text.set_ellipsize(pango::EllipsizeMode::End); + text.set_halign(gtk::Align::Center); + text.set_max_width_chars(RAIL_BUTTON_LABEL_CHARS); + text.set_xalign(0.5); + button.set_child(Some(&text)); + button +} + +/// Updates both the button property and the custom ellipsized label child. +pub(crate) fn set_rail_button_label(button: >k::Button, label: &str) { + button.set_label(label); + if let Some(child_label) = button + .child() + .and_then(|child| child.downcast::().ok()) + { + child_label.set_text(label); + } +} + +fn stabilize_button(button: >k::Button, width: i32) { + button.set_size_request(width, 36); +} diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index aba55eb..323c51a 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -66,14 +66,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { root.append(&stack); let feed_source_combo = gtk::ComboBoxText::new(); + feed_source_combo.add_css_class("compact-combo"); 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.add_css_class("compact-combo"); 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.add_css_class("compact-combo"); breakout_combo.set_tooltip_text(Some("Breakout window size.")); breakout_combo.set_size_request(0, -1); breakout_combo.set_hexpand(true); @@ -125,7 +128,3 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { title: title.to_string(), } } - -fn stabilize_button(button: >k::Button, width: i32) { - button.set_size_request(width, 36); -} diff --git a/client/src/launcher/ui_components/panel_chips.rs b/client/src/launcher/ui_components/panel_chips.rs index 292d63c..4110037 100644 --- a/client/src/launcher/ui_components/panel_chips.rs +++ b/client/src/launcher/ui_components/panel_chips.rs @@ -41,10 +41,12 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); - label_widget.set_halign(gtk::Align::Start); + label_widget.set_halign(gtk::Align::Center); + label_widget.set_xalign(0.5); let value_widget = gtk::Label::new(Some(value)); value_widget.add_css_class("status-chip-value"); - value_widget.set_halign(gtk::Align::Start); + value_widget.set_halign(gtk::Align::Center); + value_widget.set_xalign(0.5); chip.append(&label_widget); chip.append(&value_widget); (chip, value_widget) @@ -57,17 +59,20 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta.add_css_class("status-chip-meta"); + meta.set_halign(gtk::Align::Center); let light = gtk::Box::new(gtk::Orientation::Horizontal, 0); light.add_css_class("status-light"); light.add_css_class("status-light-idle"); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); - label_widget.set_halign(gtk::Align::Start); + label_widget.set_halign(gtk::Align::Center); + label_widget.set_xalign(0.5); meta.append(&light); meta.append(&label_widget); let value_widget = gtk::Label::new(Some(value)); value_widget.add_css_class("status-chip-value"); - value_widget.set_halign(gtk::Align::Start); + value_widget.set_halign(gtk::Align::Center); + value_widget.set_xalign(0.5); chip.append(&meta); chip.append(&value_widget); (chip, light, value_widget) diff --git a/client/src/launcher/ui_components/scale_reset.rs b/client/src/launcher/ui_components/scale_reset.rs index c630278..effa549 100644 --- a/client/src/launcher/ui_components/scale_reset.rs +++ b/client/src/launcher/ui_components/scale_reset.rs @@ -4,14 +4,37 @@ fn attach_scale_reset_gesture(scale: >k::Scale, default_value: f64) { gesture.set_button(1); gesture.set_propagation_phase(gtk::PropagationPhase::Capture); let scale_for_click = scale.clone(); - gesture.connect_pressed(move |_, n_press, _, _| { + gesture.connect_pressed(move |gesture, n_press, _, _| { if should_reset_scale_on_double_click(n_press, scale_for_click.value(), default_value) { - scale_for_click.set_value(default_value); + gesture.set_state(gtk::EventSequenceState::Claimed); + reset_scale_after_click_settles(&scale_for_click, default_value); } }); scale.add_controller(gesture); } +/// Reset immediately, then again after GTK's scale click handler settles. +fn reset_scale_after_click_settles(scale: >k::Scale, default_value: f64) { + scale.set_value(default_value); + let scale_for_idle = scale.clone(); + glib::idle_add_local_once(move || { + restore_scale_default_if_needed(&scale_for_idle, default_value); + }); + for delay_ms in [20_u64, 75] { + let scale_for_timeout = scale.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || { + restore_scale_default_if_needed(&scale_for_timeout, default_value); + }); + } +} + +/// Writes only when the pointer event moved the slider away from the reset value. +fn restore_scale_default_if_needed(scale: >k::Scale, default_value: f64) { + if (scale.value() - default_value).abs() > f64::EPSILON { + scale.set_value(default_value); + } +} + fn should_reset_scale_on_double_click( n_press: i32, current_value: f64, diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index 8aaf10d..63696e9 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -52,6 +52,9 @@ pub fn install_css(window: >k::ApplicationWindow) { box.status-light-live { background: rgba(96, 214, 126, 0.95); } + box.status-light-connected { + background: rgba(76, 154, 255, 0.95); + } box.status-light-idle { background: rgba(214, 81, 81, 0.92); } @@ -121,6 +124,14 @@ pub fn install_css(window: >k::ApplicationWindow) { entry.server-entry { min-height: 38px; } + combobox.compact-combo { + min-width: 0; + } + combobox.compact-combo button { + min-width: 0; + padding-left: 8px; + padding-right: 8px; + } button.pill-toggle { min-height: 36px; padding: 0 14px; diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index 928f4b5..ae895a2 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -182,10 +182,10 @@ pub struct LauncherView { 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 = 1360; -const LAUNCHER_DEFAULT_HEIGHT: i32 = 940; -const OPERATIONS_RAIL_WIDTH: i32 = 288; +const LAUNCHER_DEFAULT_HEIGHT: i32 = 900; +const OPERATIONS_RAIL_WIDTH: i32 = 304; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; -const EYE_PREVIEW_MIN_HEIGHT: i32 = 320; -const EYE_PREVIEW_MIN_WIDTH: i32 = 568; +const EYE_PREVIEW_MIN_HEIGHT: i32 = 300; +const EYE_PREVIEW_MIN_WIDTH: i32 = 460; const SIDE_LOG_MIN_HEIGHT: i32 = 124; diff --git a/client/src/launcher/ui_runtime/log_filtering.rs b/client/src/launcher/ui_runtime/log_filtering.rs index f001cdb..c002937 100644 --- a/client/src/launcher/ui_runtime/log_filtering.rs +++ b/client/src/launcher/ui_runtime/log_filtering.rs @@ -1,5 +1,6 @@ fn set_status_light(light: >k::Box, state: StatusLightState) { light.remove_css_class("status-light-live"); + light.remove_css_class("status-light-connected"); light.remove_css_class("status-light-idle"); light.remove_css_class("status-light-warning"); light.remove_css_class("status-light-caution"); diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 3dd4179..940e25f 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -41,6 +41,7 @@ pub fn capture_power_detail(power: &CapturePowerStatus) -> String { enum StatusLightState { Idle, Live, + Connected, Warning, Caution, } @@ -54,6 +55,7 @@ impl StatusLightState { match self { Self::Idle => "status-light-idle", Self::Live => "status-light-live", + Self::Connected => "status-light-connected", Self::Warning => "status-light-warning", Self::Caution => "status-light-caution", } @@ -61,15 +63,44 @@ impl StatusLightState { } fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState { - if relay_live { - StatusLightState::Live - } else if state.server_available { + if !state.server_available || !server_version_known(state) { + StatusLightState::Idle + } else if server_versions_match(state) { + if relay_live { + StatusLightState::Connected + } else { + StatusLightState::Live + } + } else if relay_live { StatusLightState::Caution } else { - StatusLightState::Idle + StatusLightState::Warning } } +/// Confirms the server reported a usable version before claiming compatibility. +fn server_version_known(state: &LauncherState) -> bool { + state + .server_version + .as_deref() + .map(normalize_version) + .is_some_and(|version| !version.is_empty()) +} + +/// Compares the reachable server version against this client build. +fn server_versions_match(state: &LauncherState) -> bool { + state + .server_version + .as_deref() + .map(normalize_version) + .is_some_and(|server_version| server_version == normalize_version(crate::VERSION)) +} + +/// Compares versions independent of whether the source included a leading `v`. +fn normalize_version(version: &str) -> &str { + version.trim().trim_start_matches('v') +} + fn server_version_label(state: &LauncherState) -> String { if !state.server_available { return "-".to_string(); diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index d3e3a01..e111019 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -18,7 +18,10 @@ use super::{ preview::{LauncherPreview, PreviewSurface}, runtime_env_vars, state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, - ui_components::{ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, + ui_components::{ + ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle, + set_rail_button_label, + }, }; pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; @@ -99,9 +102,17 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .audio_channel_toggle .set_active(state.channels.audio); } - widgets - .start_button - .set_label(if relay_live { "Disconnect" } else { "Connect" }); + set_rail_button_label( + &widgets.start_button, + if relay_live { "Disconnect" } else { "Connect" }, + ); + if relay_live { + widgets.start_button.remove_css_class("suggested-action"); + widgets.start_button.add_css_class("destructive-action"); + } else { + widgets.start_button.remove_css_class("destructive-action"); + widgets.start_button.add_css_class("suggested-action"); + } widgets.start_button.set_sensitive(true); widgets.server_entry.set_sensitive(!relay_live); widgets.start_button.set_tooltip_text(Some(if relay_live { diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index b46ce0a..169e1c5 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -343,7 +343,7 @@ "client/src/launcher/ui_components.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 104 + "loc": 105 }, "client/src/launcher/ui_components/assemble_view.rs": { "clippy_warnings": 0, @@ -358,12 +358,12 @@ "client/src/launcher/ui_components/build_device_controls.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 290 + "loc": 296 }, "client/src/launcher/ui_components/build_operations_rail.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 235 + "loc": 224 }, "client/src/launcher/ui_components/build_shell.rs": { "clippy_warnings": 0, @@ -373,27 +373,32 @@ "client/src/launcher/ui_components/combo_helpers.rs": { "clippy_warnings": 0, "doc_debt": 11, - "loc": 269 + "loc": 265 + }, + "client/src/launcher/ui_components/control_buttons.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 33 }, "client/src/launcher/ui_components/display_pane.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 131 + "loc": 130 }, "client/src/launcher/ui_components/panel_chips.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 74 + "loc": 79 }, "client/src/launcher/ui_components/scale_reset.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 21 + "loc": 44 }, "client/src/launcher/ui_components/style.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 152 + "loc": 163 }, "client/src/launcher/ui_components/types.rs": { "clippy_warnings": 0, @@ -418,7 +423,7 @@ "client/src/launcher/ui_runtime/log_filtering.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 139 + "loc": 140 }, "client/src/launcher/ui_runtime/process_logs.rs": { "clippy_warnings": 0, @@ -433,12 +438,12 @@ "client/src/launcher/ui_runtime/status_details.rs": { "clippy_warnings": 0, "doc_debt": 13, - "loc": 253 + "loc": 284 }, "client/src/launcher/ui_runtime/status_refresh.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 261 + "loc": 272 }, "client/src/layout.rs": { "clippy_warnings": 0, diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index ed02768..0d0fdea 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -12,6 +12,10 @@ const UI_LAYOUT_SRC: &str = concat!( include_str!("../../client/src/launcher/ui_components/display_pane.rs"), include_str!("../../client/src/launcher/ui_components/build_device_controls.rs"), include_str!("../../client/src/launcher/ui_components/build_operations_rail.rs"), + include_str!("../../client/src/launcher/ui_components/combo_helpers.rs"), + include_str!("../../client/src/launcher/ui_components/control_buttons.rs"), + include_str!("../../client/src/launcher/ui_components/panel_chips.rs"), + include_str!("../../client/src/launcher/ui_components/style.rs"), ); fn const_i32(name: &str) -> i32 { @@ -36,15 +40,27 @@ fn source_index(needle: &str) -> usize { #[test] fn launcher_default_size_stays_inside_1080p() { assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360); - assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 940); + assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 900); assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920); - assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080); + assert!( + const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 900, + "leave room for desktop panels and window chrome on a 1080p monitor" + ); } #[test] fn eye_panes_keep_the_locked_larger_preview_footprint() { - assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568); - assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320); + assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460); + assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 300); + let estimated_min_width = 20 + + 8 + + const_i32("OPERATIONS_RAIL_WIDTH") + + 8 + + 2 * (const_i32("EYE_PREVIEW_MIN_WIDTH") + 32); + assert!( + estimated_min_width <= const_i32("LAUNCHER_DEFAULT_WIDTH"), + "the eye panes must not push the operations rail off a 1360px launcher" + ); assert!( UI_LAYOUT_SRC.contains("caption_label.set_halign(gtk::Align::End)") || UI_LAYOUT_SRC.contains("capture_label.set_halign(gtk::Align::End)") @@ -62,6 +78,27 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() { ); } +#[test] +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("@ {} fps (Device H.264)"), + "long capture labels force a huge GTK combo natural width" + ); + 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 + .matches(".add_css_class(\"compact-combo\")") + .count() + >= 9, + "display, media, and input combos should be allowed to shrink" + ); +} + #[test] fn device_staging_and_testing_bottoms_stay_locked_together() { assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(true);")); @@ -126,19 +163,43 @@ fn session_console_buttons_share_the_remaining_toolbar_width() { ); } +#[test] +fn status_chip_text_is_centered_inside_each_pill() { + assert!(UI_LAYOUT_SRC.contains("meta.set_halign(gtk::Align::Center);")); + assert!( + UI_LAYOUT_SRC + .matches("set_halign(gtk::Align::Center);") + .count() + >= 5, + "chip labels and values should be horizontally centered" + ); + assert!( + UI_LAYOUT_SRC.matches("set_xalign(0.5);").count() >= 4, + "chip text lines should center their own text, not only their widgets" + ); +} + #[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!(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 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);")); + assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&usb_recover_button, 2, 1, 1, 1);")); + assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);")); assert!( - UI_LAYOUT_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") - ); - assert!(UI_LAYOUT_SRC.contains("relay_row.append(&server_entry);")); - assert!(UI_LAYOUT_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");")); - assert!(UI_LAYOUT_SRC.contains("stabilize_button(&start_button, 108);")); - assert!(UI_LAYOUT_SRC.contains("relay_row.append(&start_button);")); - assert!( - source_index("relay_row.append(&server_entry);") - < source_index("relay_row.append(&start_button);") + source_index("relay_grid.attach(&server_entry, 0, 0, 2, 1);") + < source_index("relay_grid.attach(&start_button, 2, 0, 1, 1);") ); } diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 7f628a1..33b1e36 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -22,6 +22,7 @@ const UI_SRC: &str = concat!( include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"), include_str!("../../client/src/launcher/ui/runtime_poll.rs"), include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"), + include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"), ); const DEVICE_TEST_SRC: &str = concat!( include_str!("../../client/src/launcher/device_test.rs"), @@ -160,16 +161,40 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\"")); } +#[test] +fn launcher_utility_buttons_still_bind_to_live_actions() { + assert!(UI_SRC.contains("widgets.clipboard_button.connect_clicked")); + assert!(UI_SRC.contains("send_clipboard_text_to_remote(&server_addr, &text)")); + assert!(UI_SRC.contains("Start the relay before sending clipboard text.")); + assert!(UI_SRC.contains("widgets.probe_button.connect_clicked")); + assert!(UI_SRC.contains("clipboard.set_text(quality_probe_command())")); + assert!(UI_SRC.contains("Quality probe command copied to the local clipboard.")); + assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked")); + assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)")); + assert!(UI_SRC.contains("USB gadget recovery requested.")); +} + #[test] fn server_chip_distinguishes_reachable_from_connected() { assert!(UI_RUNTIME_SRC.contains("fn server_light_state(")); - assert!(UI_RUNTIME_SRC.contains("if relay_live")); - assert!(UI_RUNTIME_SRC.contains("} else if state.server_available {")); + assert!(UI_RUNTIME_SRC.contains("StatusLightState::Connected")); + assert!(UI_RUNTIME_SRC.contains("server_versions_match(state)")); + assert!(UI_RUNTIME_SRC.contains("} else if relay_live {")); assert!(UI_RUNTIME_SRC.contains("StatusLightState::Caution")); assert!(UI_RUNTIME_SRC.contains("fn server_version_label(")); assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();")); } +#[test] +fn relay_action_button_marks_disconnect_as_destructive() { + assert!(UI_RUNTIME_SRC.contains("set_rail_button_label(")); + assert!(UI_RUNTIME_SRC.contains("widgets.start_button.add_css_class(\"destructive-action\")")); + assert!( + UI_RUNTIME_SRC.contains("widgets.start_button.remove_css_class(\"destructive-action\")") + ); + assert!(UI_RUNTIME_SRC.contains("widgets.start_button.add_css_class(\"suggested-action\")")); +} + #[test] fn launcher_brand_uses_readable_icon_size() { assert!(UI_COMPONENTS_SRC.contains("brand_icon.set_pixel_size(44);"));