fix(ui): polish launcher status and controls
This commit is contained in:
parent
30ecf47cdc
commit
3f3fad7c50
@ -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.
|
||||
|
||||
|
||||
@ -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![
|
||||
|
||||
@ -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]"));
|
||||
}
|
||||
|
||||
@ -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), "-");
|
||||
}
|
||||
|
||||
|
||||
180
client/src/launcher/tests/utility_actions.rs
Normal file
180
client/src/launcher/tests/utility_actions.rs
Normal 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);
|
||||
}
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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())
|
||||
|
||||
33
client/src/launcher/ui_components/control_buttons.rs
Normal file
33
client/src/launcher/ui_components/control_buttons.rs
Normal 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: >k::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: >k::Button, width: i32) {
|
||||
button.set_size_request(width, 36);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user