ui: add eye health chips

This commit is contained in:
Brad Stein 2026-05-13 12:32:45 -03:00
parent 628b506b64
commit 5a5990d593
16 changed files with 246 additions and 53 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.23"
version = "0.22.24"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.23"
version = "0.22.24"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.23"
version = "0.22.24"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.23"
version = "0.22.24"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,7 @@
use super::*;
use crate::launcher::{
devices::{CameraMode, DeviceCatalog},
diagnostics::PerformanceSample,
preview::PreviewBinding,
state::{
BreakoutSizePreset, LauncherState, PreviewSourceSize, UpstreamAudioTransport,
@ -45,19 +46,101 @@ fn local_test_detail_mentions_idle_and_running_modes() {
#[test]
fn gpio_power_label_tracks_detected_devices() {
let mut power = CapturePowerStatus::default();
assert_eq!(gpio_power_label(&power), "Unavailable");
assert_eq!(gpio_power_label(&power), "Off");
power.available = true;
assert_eq!(gpio_power_label(&power), "Power Off");
assert_eq!(gpio_power_label(&power), "Off");
power.enabled = true;
assert_eq!(gpio_power_label(&power), "No Eyes");
assert_eq!(gpio_power_label(&power), "On");
power.detected_devices = 1;
assert_eq!(gpio_power_label(&power), "1 Eye");
assert_eq!(gpio_power_label(&power), "On");
power.detected_devices = 2;
assert_eq!(gpio_power_label(&power), "2 Eyes");
assert_eq!(gpio_power_label(&power), "On");
}
fn eye_health_sample() -> PerformanceSample {
PerformanceSample {
rtt_ms: 20.0,
probe_spread_ms: 2.0,
input_latency_ms: 12.0,
probe_loss_pct: 0.0,
client_process_cpu_pct: 10.0,
server_process_cpu_pct: 8.0,
video_loss_pct: 0.0,
left_receive_fps: 30.0,
left_present_fps: 30.0,
left_server_fps: 30.0,
left_stream_spread_ms: 3.0,
left_packet_gap_peak_ms: 20.0,
left_present_gap_peak_ms: 20.0,
left_queue_depth: 0,
left_queue_peak: 0,
left_server_source_gap_peak_ms: 20.0,
left_server_send_gap_peak_ms: 20.0,
left_server_queue_peak: 0,
left_server_encoder_label: "source-pass-through".to_string(),
left_decoder_label: "nvidia-h264".to_string(),
left_stream_caps_label: "video/x-h264".to_string(),
left_decoded_caps_label: "video/x-raw".to_string(),
left_rendered_caps_label: "video/x-raw".to_string(),
right_receive_fps: 30.0,
right_present_fps: 30.0,
right_server_fps: 30.0,
right_stream_spread_ms: 3.0,
right_packet_gap_peak_ms: 20.0,
right_present_gap_peak_ms: 20.0,
right_queue_depth: 0,
right_queue_peak: 0,
right_server_source_gap_peak_ms: 20.0,
right_server_send_gap_peak_ms: 20.0,
right_server_queue_peak: 0,
right_server_encoder_label: "source-pass-through".to_string(),
right_decoder_label: "nvidia-h264".to_string(),
right_stream_caps_label: "video/x-h264".to_string(),
right_decoded_caps_label: "video/x-raw".to_string(),
right_rendered_caps_label: "video/x-raw".to_string(),
upstream_camera: UpstreamStreamTelemetry::default(),
upstream_microphone: UpstreamStreamTelemetry::default(),
upstream_mode: "bundled-webcam-media".to_string(),
dropped_frames: 0,
queue_depth: 0,
}
}
#[test]
fn eye_stream_chip_reports_per_eye_fps_quality() {
let sample = eye_health_sample();
assert_eq!(
eye_stream_health(Some(&sample), EyeChipSide::Left, false),
(
StatusLightState::Idle,
"Off".to_string(),
"Relay is not connected.".to_string()
)
);
let (state, label, tooltip) = eye_stream_health(Some(&sample), EyeChipSide::Left, true);
assert_eq!(state, StatusLightState::Connected);
assert_eq!(label, "30 fps");
assert!(tooltip.contains("present=30.0 fps"));
let mut degraded = sample.clone();
degraded.right_present_fps = 20.0;
degraded.right_queue_peak = 3;
let (state, label, tooltip) = eye_stream_health(Some(&degraded), EyeChipSide::Right, true);
assert_eq!(state, StatusLightState::Caution);
assert_eq!(label, "20 fps");
assert!(tooltip.contains("queue=0/3"));
let mut broken = sample;
broken.left_receive_fps = 0.0;
broken.left_present_fps = 0.0;
let (state, label, _) = eye_stream_health(Some(&broken), EyeChipSide::Left, true);
assert_eq!(state, StatusLightState::Warning);
assert_eq!(label, "0 fps");
}
#[gtk::test]
@ -624,7 +707,7 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
assert_eq!(
recovery_uac_health(&state, false, None),
(StatusLightState::Live, "Opus".to_string())
(StatusLightState::Live, "PCM".to_string())
);
assert_eq!(
recovery_uac_health(&state, true, None),
@ -642,7 +725,7 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
};
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "Opus".to_string())
(StatusLightState::Live, "PCM".to_string())
);
state.set_server_media_caps(None, None, None, None);
@ -652,7 +735,7 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
);
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "Opus".to_string())
(StatusLightState::Live, "PCM".to_string())
);
state.select_upstream_audio_transport(UpstreamAudioTransport::Pcm);

View File

@ -41,6 +41,10 @@ pub fn build_launcher_view(
routing_value,
gpio_light,
gpio_value,
left_eye_light,
left_eye_value,
right_eye_light,
right_eye_value,
usb_light,
usb_value,
uac_light,

View File

@ -110,6 +110,10 @@
routing_value,
gpio_light,
gpio_value,
left_eye_light,
left_eye_value,
right_eye_light,
right_eye_value,
usb_light,
usb_value,
uac_light,

View File

@ -11,6 +11,10 @@ struct LauncherShellContext {
routing_value: gtk::Label,
gpio_light: gtk::Box,
gpio_value: gtk::Label,
left_eye_light: gtk::Box,
left_eye_value: gtk::Label,
right_eye_light: gtk::Box,
right_eye_value: gtk::Label,
usb_light: gtk::Box,
usb_value: gtk::Label,
uac_light: gtk::Box,

View File

@ -49,14 +49,20 @@
let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "");
let (routing_chip, routing_light, routing_value) =
build_status_chip_with_light("Inputs", "Local");
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Off");
let (left_eye_chip, left_eye_light, left_eye_value) =
build_status_chip_with_light("Left", "Off");
let (right_eye_chip, right_eye_light, right_eye_value) =
build_status_chip_with_light("Right", "Off");
let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("HID", "Unknown");
let (uac_chip, uac_light, uac_value) = build_status_chip_with_light("UAC", "Unknown");
let (uvc_chip, uvc_light, uvc_value) = build_status_chip_with_light("UVC", "Unknown");
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
let (shortcut_chip, shortcut_value) = build_status_chip("Key", "Pause");
chips.append(&relay_chip);
chips.append(&routing_chip);
chips.append(&gpio_chip);
chips.append(&left_eye_chip);
chips.append(&right_eye_chip);
chips.append(&usb_chip);
chips.append(&uac_chip);
chips.append(&uvc_chip);
@ -70,6 +76,7 @@
chips_shell.set_has_frame(false);
chips_shell.set_propagate_natural_width(false);
chips_shell.set_min_content_width(0);
chips_shell.set_size_request(0, -1);
hero.append(&chips_shell);
root.append(&hero);
@ -121,6 +128,10 @@
routing_value,
gpio_light,
gpio_value,
left_eye_light,
left_eye_value,
right_eye_light,
right_eye_value,
usb_light,
usb_value,
uac_light,

View File

@ -294,7 +294,7 @@ fn human_audio_node_label(value: &str) -> String {
}
}
const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 24;
const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 18;
fn shorten_label_with_limit(value: &str, max: usize) -> String {
let compact = value.replace('_', " ");

View File

@ -47,12 +47,15 @@ fn build_subgroup_with_action(title: &str, action: Option<&gtk::Widget>) -> gtk:
group
}
const STATUS_CHIP_WIDTH: i32 = 84;
const STATUS_CHIP_VALUE_CHARS: i32 = 8;
/// Build a fixed-width status chip so changing labels do not move the header row.
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
chip.add_css_class("status-chip");
chip.set_hexpand(false);
chip.set_size_request(96, -1);
chip.set_size_request(STATUS_CHIP_WIDTH, -1);
let label_widget = gtk::Label::new(Some(label));
label_widget.add_css_class("status-chip-label");
@ -64,7 +67,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
value_widget.set_xalign(0.5);
value_widget.set_ellipsize(pango::EllipsizeMode::End);
value_widget.set_single_line_mode(true);
value_widget.set_width_chars(9);
value_widget.set_width_chars(STATUS_CHIP_VALUE_CHARS);
chip.append(&label_widget);
chip.append(&value_widget);
(chip, value_widget)
@ -75,7 +78,7 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
chip.add_css_class("status-chip");
chip.set_hexpand(false);
chip.set_size_request(96, -1);
chip.set_size_request(STATUS_CHIP_WIDTH, -1);
let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
meta.add_css_class("status-chip-meta");
@ -95,7 +98,7 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
value_widget.set_xalign(0.5);
value_widget.set_ellipsize(pango::EllipsizeMode::End);
value_widget.set_single_line_mode(true);
value_widget.set_width_chars(9);
value_widget.set_width_chars(STATUS_CHIP_VALUE_CHARS);
chip.append(&meta);
chip.append(&value_widget);
(chip, light, value_widget)

View File

@ -6,6 +6,10 @@ pub struct SummaryWidgets {
pub routing_value: gtk::Label,
pub gpio_light: gtk::Box,
pub gpio_value: gtk::Label,
pub left_eye_light: gtk::Box,
pub left_eye_value: gtk::Label,
pub right_eye_light: gtk::Box,
pub right_eye_value: gtk::Label,
pub usb_light: gtk::Box,
pub usb_value: gtk::Label,
pub uac_light: gtk::Box,
@ -217,7 +221,7 @@ const LAUNCHER_DEFAULT_WIDTH: i32 = 1540;
const LAUNCHER_DEFAULT_HEIGHT: i32 = 880;
const OPERATIONS_RAIL_WIDTH: i32 = 276;
const RELAY_SUBGROUP_LABEL_WIDTH: i32 = 11;
const DEVICE_STAGING_PANEL_WIDTH: i32 = 560;
const DEVICE_STAGING_PANEL_WIDTH: i32 = 512;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 270;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 480;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 299;

View File

@ -1,14 +1,8 @@
pub fn gpio_power_label(power: &CapturePowerStatus) -> String {
if !power.available {
return "Unavailable".to_string();
}
if !power.enabled {
return "Power Off".to_string();
}
match power.detected_devices {
0 => "No Eyes".to_string(),
1 => "1 Eye".to_string(),
count => format!("{count} Eyes"),
if power.available && power.enabled {
"On".to_string()
} else {
"Off".to_string()
}
}
@ -286,14 +280,7 @@ fn media_stream_health(
}
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
if !power.available || !power.enabled {
return StatusLightState::Idle;
}
match power.detected_devices {
0 => StatusLightState::Warning,
1 => StatusLightState::Caution,
_ => StatusLightState::Live,
}
StatusLightState::from_active(power.available && power.enabled)
}
fn gpio_detection_detail(detected_devices: u32) -> String {
@ -304,6 +291,79 @@ fn gpio_detection_detail(detected_devices: u32) -> String {
}
}
#[derive(Clone, Copy)]
enum EyeChipSide {
Left,
Right,
}
fn eye_stream_health(
sample: Option<&crate::launcher::diagnostics::PerformanceSample>,
side: EyeChipSide,
relay_live: bool,
) -> (StatusLightState, String, String) {
if !relay_live {
return (
StatusLightState::Idle,
"Off".to_string(),
"Relay is not connected.".to_string(),
);
}
let Some(sample) = sample else {
return (
StatusLightState::Warning,
"0 fps".to_string(),
"No eye telemetry sample is available yet.".to_string(),
);
};
let (receive_fps, present_fps, server_fps, queue_depth, queue_peak, packet_gap, present_gap) =
match side {
EyeChipSide::Left => (
sample.left_receive_fps,
sample.left_present_fps,
sample.left_server_fps,
sample.left_queue_depth,
sample.left_queue_peak,
sample.left_packet_gap_peak_ms,
sample.left_present_gap_peak_ms,
),
EyeChipSide::Right => (
sample.right_receive_fps,
sample.right_present_fps,
sample.right_server_fps,
sample.right_queue_depth,
sample.right_queue_peak,
sample.right_packet_gap_peak_ms,
sample.right_present_gap_peak_ms,
),
};
let label = format!("{:.0} fps", present_fps.max(0.0));
let target_fps = server_fps.max(receive_fps).max(1.0);
let ratio = (present_fps / target_fps).clamp(0.0, 1.0);
let has_glitch = sample.video_loss_pct > 0.1
|| sample.dropped_frames > 0
|| queue_depth > 0
|| queue_peak > 2
|| packet_gap > 90.0
|| present_gap > 90.0;
let state = if present_fps < 1.0 || receive_fps < 1.0 {
StatusLightState::Warning
} else if ratio >= 0.98 && !has_glitch {
StatusLightState::Connected
} else if ratio >= 0.90 && sample.video_loss_pct < 1.0 {
StatusLightState::Live
} else if ratio >= 0.65 {
StatusLightState::Caution
} else {
StatusLightState::Warning
};
let tooltip = format!(
"present={present_fps:.1} fps receive={receive_fps:.1} fps server={server_fps:.1} fps loss={:.1}% dropped={} queue={}/{} gaps={packet_gap:.0}/{present_gap:.0} ms",
sample.video_loss_pct, sample.dropped_frames, queue_depth, queue_peak
);
(state, label, tooltip)
}
/// Highlights the currently active capture mode so it reads like a segmented control.
fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) {
for button in [

View File

@ -68,7 +68,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
let gpio_label = if state.server_available {
gpio_power_label(&state.capture_power)
} else {
"Offline".to_string()
"Off".to_string()
};
set_status_light(
&widgets.summary.gpio_light,
@ -79,12 +79,32 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
},
);
widgets.summary.gpio_value.set_text(&gpio_label);
widgets.summary.gpio_value.set_tooltip_text(Some(&gpio_label));
widgets
.summary
.gpio_value
.set_tooltip_text(Some(&capture_power_detail(&state.capture_power)));
widgets
.summary
.shortcut_value
.set_text(&toggle_key_label(&state.swap_key));
let (left_eye_state, left_eye_value, left_eye_tooltip) =
eye_stream_health(latest_sample.as_ref(), EyeChipSide::Left, relay_live);
set_status_light(&widgets.summary.left_eye_light, left_eye_state);
widgets.summary.left_eye_value.set_text(&left_eye_value);
widgets
.summary
.left_eye_value
.set_tooltip_text(Some(&left_eye_tooltip));
let (right_eye_state, right_eye_value, right_eye_tooltip) =
eye_stream_health(latest_sample.as_ref(), EyeChipSide::Right, relay_live);
set_status_light(&widgets.summary.right_eye_light, right_eye_state);
widgets.summary.right_eye_value.set_text(&right_eye_value);
widgets
.summary
.right_eye_value
.set_tooltip_text(Some(&right_eye_tooltip));
let (usb_state, usb_value) = recovery_usb_health(state, relay_live);
set_status_light(&widgets.summary.usb_light, usb_state);
widgets.summary.usb_value.set_text(&usb_value);
@ -285,9 +305,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.upstream_audio_transport_combo
.set_tooltip_text(Some(if relay_live {
"Changing upstream audio transport restarts the microphone path; Opus compresses, PCM is the fallback."
"Changing upstream audio transport restarts the microphone path; PCM is stable, Opus is compressed and experimental."
} else {
"Choose Opus for compressed upstream audio or PCM as the known-good fallback."
"Choose PCM for the known-good route or Opus for compressed upstream audio experiments."
}));
widgets.input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Route Local",

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.23"
version = "0.22.24"
edition = "2024"
build = "build.rs"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.23"
version = "0.22.24"
edition = "2024"
autobins = false

View File

@ -158,7 +158,7 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() {
assert!(UI_LAYOUT_SRC.contains("combo.set_popup_fixed_width(false);"));
assert!(UI_LAYOUT_SRC.contains("fn combo_active_full_label("));
assert!(UI_LAYOUT_SRC.contains("Selected: {full}. Open the dropdown for compact choices"));
assert!(UI_LAYOUT_SRC.contains("const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 24;"));
assert!(UI_LAYOUT_SRC.contains("const DEVICE_COMBO_BUTTON_LABEL_LIMIT: usize = 18;"));
assert!(UI_LAYOUT_SRC.contains("append_input_choice(combo, value);"));
assert!(
UI_LAYOUT_SRC.contains("shorten_label_with_limit(&label, DEVICE_COMBO_BUTTON_LABEL_LIMIT)")
@ -189,7 +189,7 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() {
fn device_staging_and_testing_stay_independent_so_preview_does_not_fill_dead_height() {
assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(false);"));
assert!(UI_LAYOUT_SRC.contains("staging_row.set_vexpand(false);"));
assert_eq!(const_i32("DEVICE_STAGING_PANEL_WIDTH"), 560);
assert_eq!(const_i32("DEVICE_STAGING_PANEL_WIDTH"), 512);
assert!(UI_LAYOUT_SRC.contains("devices_panel.set_hexpand(false);"));
assert!(
UI_LAYOUT_SRC.contains("devices_panel.set_size_request(DEVICE_STAGING_PANEL_WIDTH, -1);")
@ -324,13 +324,15 @@ fn status_chip_text_is_centered_inside_each_pill() {
UI_LAYOUT_SRC
.matches("set_halign(gtk::Align::Center);")
.count()
>= 5,
>= 7,
"chip labels and values should be horizontally centered"
);
assert!(
UI_LAYOUT_SRC.matches("set_xalign(0.5);").count() >= 4,
UI_LAYOUT_SRC.matches("set_xalign(0.5);").count() >= 6,
"chip text lines should center their own text, not only their widgets"
);
assert!(UI_LAYOUT_SRC.contains("build_status_chip_with_light(\"Left\", \"Off\")"));
assert!(UI_LAYOUT_SRC.contains("build_status_chip_with_light(\"Right\", \"Off\")"));
}
#[test]

View File

@ -288,11 +288,9 @@ fn launcher_audio_transport_selection_reaches_relay_env_and_chips() {
assert!(
UI_RUNTIME_SRC.contains("Changing upstream audio transport restarts the microphone path")
);
assert!(
UI_RUNTIME_SRC.contains(
"Choose Opus for compressed upstream audio or PCM as the known-good fallback."
)
);
assert!(UI_RUNTIME_SRC.contains(
"Choose PCM for the known-good route or Opus for compressed upstream audio experiments."
));
assert!(UI_SRC.contains("audio_combo.connect_changed"));
assert!(UI_SRC.contains("toggle.connect_toggled"));
}