fix(ui): polish launcher status and controls

This commit is contained in:
Brad Stein 2026-04-23 11:14:58 -03:00
parent 30ecf47cdc
commit 3f3fad7c50
21 changed files with 505 additions and 109 deletions

View File

@ -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.

View File

@ -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![

View File

@ -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]"));
}

View File

@ -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), "-");
}

View File

@ -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<Box<dyn futures::Stream<Item = Result<KeyboardReport, Status>> + Send>>;
type MouseStream = Pin<Box<dyn futures::Stream<Item = Result<MouseReport, Status>> + Send>>;
type VideoStream = Pin<Box<dyn futures::Stream<Item = Result<VideoPacket, Status>> + Send>>;
type AudioStream = Pin<Box<dyn futures::Stream<Item = Result<AudioPacket, Status>> + Send>>;
type EmptyStream = Pin<Box<dyn futures::Stream<Item = Result<Empty, Status>> + Send>>;
#[derive(Clone)]
struct UtilityRelay {
paste_count: Arc<Mutex<usize>>,
reset_count: Arc<Mutex<usize>>,
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<tonic::Streaming<KeyboardReport>>,
) -> Result<Response<Self::StreamKeyboardStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn stream_mouse(
&self,
_request: Request<tonic::Streaming<MouseReport>>,
) -> Result<Response<Self::StreamMouseStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn capture_video(
&self,
_request: Request<MonitorRequest>,
) -> Result<Response<Self::CaptureVideoStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn capture_audio(
&self,
_request: Request<MonitorRequest>,
) -> Result<Response<Self::CaptureAudioStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn stream_microphone(
&self,
_request: Request<tonic::Streaming<AudioPacket>>,
) -> Result<Response<Self::StreamMicrophoneStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn stream_camera(
&self,
_request: Request<tonic::Streaming<VideoPacket>>,
) -> Result<Response<Self::StreamCameraStream>, Status> {
Ok(Response::new(Box::pin(stream::empty())))
}
async fn paste_text(
&self,
_request: Request<PasteRequest>,
) -> Result<Response<PasteReply>, 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<Empty>) -> Result<Response<ResetUsbReply>, 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<Empty>,
) -> Result<Response<CapturePowerState>, Status> {
Ok(Response::new(CapturePowerState::default()))
}
async fn set_capture_power(
&self,
_request: Request<SetCapturePowerRequest>,
) -> Result<Response<CapturePowerState>, 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);
}

View File

@ -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");

View File

@ -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(
&microphone_combo,
&catalog.microphones,

View File

@ -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(&gtk::Separator::new(gtk::Orientation::Horizontal));
let power_heading = gtk::Label::new(Some("GPIO Power"));

View File

@ -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: &gtk::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: &gtk::ComboBoxText, selected: Option<&str>) {
if selected
.filter(|value| !value.trim().is_empty())

View File

@ -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: &gtk::Button, label: &str) {
button.set_label(label);
if let Some(child_label) = button
.child()
.and_then(|child| child.downcast::<gtk::Label>().ok())
{
child_label.set_text(label);
}
}
fn stabilize_button(button: &gtk::Button, width: i32) {
button.set_size_request(width, 36);
}

View File

@ -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: &gtk::Button, width: i32) {
button.set_size_request(width, 36);
}

View File

@ -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)

View File

@ -4,14 +4,37 @@ fn attach_scale_reset_gesture(scale: &gtk::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: &gtk::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: &gtk::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,

View File

@ -52,6 +52,9 @@ pub fn install_css(window: &gtk::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: &gtk::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;

View File

@ -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;

View File

@ -1,5 +1,6 @@
fn set_status_light(light: &gtk::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");

View File

@ -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();

View File

@ -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 {

View File

@ -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,

View File

@ -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);")
);
}

View File

@ -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);"));