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.
|
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
|
## What It Does
|
||||||
|
|
||||||
- Shows the left and right eye feeds in the launcher, with breakout windows when you want more room.
|
- 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.
|
- 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.
|
- 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.
|
- 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 super::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
mod utility_actions;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_server_addr_prefers_explicit_server_flag() {
|
fn resolve_server_addr_prefers_explicit_server_flag() {
|
||||||
let args = vec![
|
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, 350.0, 200.0));
|
||||||
assert!(should_reset_scale_on_double_click(2, 75.0, 100.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_available(true);
|
||||||
state.set_server_version(Some("0.12.3".to_string()));
|
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_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()));
|
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");
|
assert_eq!(server_version_label(&state), "v0.12.4");
|
||||||
|
|
||||||
state.set_server_version(Some(" ".to_string()));
|
state.set_server_version(Some(" ".to_string()));
|
||||||
|
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
|
||||||
assert_eq!(server_version_label(&state), "-");
|
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 std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use evdev::Device;
|
use evdev::Device;
|
||||||
use gtk::{pango, prelude::*};
|
use gtk::{glib, pango, prelude::*};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
devices::{CameraMode, DeviceCatalog},
|
devices::{CameraMode, DeviceCatalog},
|
||||||
@ -18,6 +18,7 @@ include!("ui_components/build_contexts.rs");
|
|||||||
include!("ui_components/style.rs");
|
include!("ui_components/style.rs");
|
||||||
include!("ui_components/panel_chips.rs");
|
include!("ui_components/panel_chips.rs");
|
||||||
include!("ui_components/combo_helpers.rs");
|
include!("ui_components/combo_helpers.rs");
|
||||||
|
include!("ui_components/control_buttons.rs");
|
||||||
include!("ui_components/display_pane.rs");
|
include!("ui_components/display_pane.rs");
|
||||||
include!("ui_components/scale_reset.rs");
|
include!("ui_components/scale_reset.rs");
|
||||||
|
|
||||||
|
|||||||
@ -14,12 +14,14 @@
|
|||||||
control_group.append(&control_stack);
|
control_group.append(&control_stack);
|
||||||
|
|
||||||
let camera_combo = gtk::ComboBoxText::new();
|
let camera_combo = gtk::ComboBoxText::new();
|
||||||
|
camera_combo.add_css_class("compact-combo");
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
&camera_combo,
|
&camera_combo,
|
||||||
&catalog.cameras,
|
&catalog.cameras,
|
||||||
state.devices.camera.as_deref(),
|
state.devices.camera.as_deref(),
|
||||||
);
|
);
|
||||||
let camera_quality_combo = gtk::ComboBoxText::new();
|
let camera_quality_combo = gtk::ComboBoxText::new();
|
||||||
|
camera_quality_combo.add_css_class("compact-combo");
|
||||||
sync_camera_quality_combo(
|
sync_camera_quality_combo(
|
||||||
&camera_quality_combo,
|
&camera_quality_combo,
|
||||||
&state.camera_quality_options(catalog),
|
&state.camera_quality_options(catalog),
|
||||||
@ -32,6 +34,7 @@
|
|||||||
camera_test_button.set_tooltip_text(Some("Preview selected webcam locally."));
|
camera_test_button.set_tooltip_text(Some("Preview selected webcam locally."));
|
||||||
|
|
||||||
let speaker_combo = gtk::ComboBoxText::new();
|
let speaker_combo = gtk::ComboBoxText::new();
|
||||||
|
speaker_combo.add_css_class("compact-combo");
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
&speaker_combo,
|
&speaker_combo,
|
||||||
&catalog.speakers,
|
&catalog.speakers,
|
||||||
@ -42,6 +45,7 @@
|
|||||||
speaker_test_button.set_tooltip_text(Some("Play a local test tone."));
|
speaker_test_button.set_tooltip_text(Some("Play a local test tone."));
|
||||||
|
|
||||||
let keyboard_combo = gtk::ComboBoxText::new();
|
let keyboard_combo = gtk::ComboBoxText::new();
|
||||||
|
keyboard_combo.add_css_class("compact-combo");
|
||||||
keyboard_combo.append(Some("all"), "all keyboards");
|
keyboard_combo.append(Some("all"), "all keyboards");
|
||||||
for keyboard in &catalog.keyboards {
|
for keyboard in &catalog.keyboards {
|
||||||
append_input_choice(&keyboard_combo, keyboard);
|
append_input_choice(&keyboard_combo, keyboard);
|
||||||
@ -52,6 +56,7 @@
|
|||||||
control_stack.append(&keyboard_row);
|
control_stack.append(&keyboard_row);
|
||||||
|
|
||||||
let mouse_combo = gtk::ComboBoxText::new();
|
let mouse_combo = gtk::ComboBoxText::new();
|
||||||
|
mouse_combo.add_css_class("compact-combo");
|
||||||
mouse_combo.append(Some("all"), "all mice");
|
mouse_combo.append(Some("all"), "all mice");
|
||||||
for mouse in &catalog.mice {
|
for mouse in &catalog.mice {
|
||||||
append_input_choice(&mouse_combo, mouse);
|
append_input_choice(&mouse_combo, mouse);
|
||||||
@ -145,6 +150,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let microphone_combo = gtk::ComboBoxText::new();
|
let microphone_combo = gtk::ComboBoxText::new();
|
||||||
|
microphone_combo.add_css_class("compact-combo");
|
||||||
sync_stage_device_combo(
|
sync_stage_device_combo(
|
||||||
µphone_combo,
|
µphone_combo,
|
||||||
&catalog.microphones,
|
&catalog.microphones,
|
||||||
|
|||||||
@ -6,35 +6,24 @@
|
|||||||
server_entry.set_width_chars(18);
|
server_entry.set_width_chars(18);
|
||||||
server_entry.set_text(server_addr);
|
server_entry.set_text(server_addr);
|
||||||
server_entry.set_tooltip_text(Some("Relay host address."));
|
server_entry.set_tooltip_text(Some("Relay host address."));
|
||||||
let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let relay_grid = gtk::Grid::new();
|
||||||
relay_row.set_halign(gtk::Align::Fill);
|
relay_grid.set_column_homogeneous(true);
|
||||||
relay_row.set_hexpand(true);
|
relay_grid.set_column_spacing(8);
|
||||||
relay_row.append(&server_entry);
|
relay_grid.set_hexpand(true);
|
||||||
let start_button = gtk::Button::with_label("Connect");
|
relay_grid.set_row_spacing(8);
|
||||||
start_button.add_css_class("suggested-action");
|
relay_grid.attach(&server_entry, 0, 0, 2, 1);
|
||||||
start_button.set_hexpand(false);
|
|
||||||
stabilize_button(&start_button, 108);
|
|
||||||
relay_row.append(&start_button);
|
|
||||||
connection_body.append(&relay_row);
|
|
||||||
|
|
||||||
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let start_button = rail_button("Connect", "Start or stop relay.");
|
||||||
live_actions_row.set_homogeneous(true);
|
start_button.add_css_class("suggested-action");
|
||||||
let clipboard_button = gtk::Button::with_label("Send Clipboard");
|
relay_grid.attach(&start_button, 2, 0, 1, 1);
|
||||||
clipboard_button.set_hexpand(true);
|
|
||||||
stabilize_button(&clipboard_button, 108);
|
let clipboard_button = rail_button("Send Clipboard", "Type clipboard remotely.");
|
||||||
clipboard_button.set_tooltip_text(Some("Type clipboard remotely."));
|
let probe_button = rail_button("Copy Gate Probe", "Copy quality probe.");
|
||||||
let probe_button = gtk::Button::with_label("Copy Gate Probe");
|
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB.");
|
||||||
probe_button.set_hexpand(true);
|
relay_grid.attach(&clipboard_button, 0, 1, 1, 1);
|
||||||
stabilize_button(&probe_button, 108);
|
relay_grid.attach(&probe_button, 1, 1, 1, 1);
|
||||||
probe_button.set_tooltip_text(Some("Copy quality probe."));
|
relay_grid.attach(&usb_recover_button, 2, 1, 1, 1);
|
||||||
let usb_recover_button = gtk::Button::with_label("Recover USB");
|
connection_body.append(&relay_grid);
|
||||||
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);
|
|
||||||
|
|
||||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||||
|
|||||||
@ -19,13 +19,7 @@ pub fn sync_capture_resolution_combo(
|
|||||||
combo.remove_all();
|
combo.remove_all();
|
||||||
let option_count = options.len();
|
let option_count = options.len();
|
||||||
for option in options {
|
for option in options {
|
||||||
let label = format!(
|
let label = compact_capture_mode_label(option.width, option.height, option.fps);
|
||||||
"{} • {}x{} @ {} fps (Device H.264)",
|
|
||||||
option.preset.label(),
|
|
||||||
option.width,
|
|
||||||
option.height,
|
|
||||||
option.fps,
|
|
||||||
);
|
|
||||||
combo.append(Some(option.preset.as_id()), &label);
|
combo.append(Some(option.preset.as_id()), &label);
|
||||||
}
|
}
|
||||||
combo.set_active_id(Some(selected.as_id()));
|
combo.set_active_id(Some(selected.as_id()));
|
||||||
@ -56,28 +50,11 @@ pub fn sync_breakout_size_combo(
|
|||||||
combo.remove_all();
|
combo.remove_all();
|
||||||
for option in options {
|
for option in options {
|
||||||
let label = match option.preset {
|
let label = match option.preset {
|
||||||
BreakoutSizePreset::Source => {
|
BreakoutSizePreset::Source => format!("Source {}", compact_size_label(option.height)),
|
||||||
format!(
|
|
||||||
"{} • {}x{} (Source Size)",
|
|
||||||
option.preset.label(),
|
|
||||||
option.width,
|
|
||||||
option.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
BreakoutSizePreset::FillDisplay => {
|
BreakoutSizePreset::FillDisplay => {
|
||||||
format!(
|
format!("Display {}", compact_size_label(option.height))
|
||||||
"{} • {}x{} (Display Size)",
|
|
||||||
option.preset.label(),
|
|
||||||
option.width,
|
|
||||||
option.height
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
_ => format!(
|
_ => format!("{} {}x{}", option.preset.label(), option.width, option.height),
|
||||||
"{} • {}x{}",
|
|
||||||
option.preset.label(),
|
|
||||||
option.width,
|
|
||||||
option.height
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
combo.append(Some(option.preset.as_id()), &label);
|
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));
|
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>) {
|
fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) {
|
||||||
if selected
|
if selected
|
||||||
.filter(|value| !value.trim().is_empty())
|
.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);
|
root.append(&stack);
|
||||||
|
|
||||||
let feed_source_combo = gtk::ComboBoxText::new();
|
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_tooltip_text(Some("Eye source for this pane."));
|
||||||
feed_source_combo.set_hexpand(true);
|
feed_source_combo.set_hexpand(true);
|
||||||
feed_source_combo.set_size_request(0, -1);
|
feed_source_combo.set_size_request(0, -1);
|
||||||
let capture_resolution_combo = gtk::ComboBoxText::new();
|
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_tooltip_text(Some("Eye capture mode."));
|
||||||
capture_resolution_combo.set_size_request(0, -1);
|
capture_resolution_combo.set_size_request(0, -1);
|
||||||
capture_resolution_combo.set_hexpand(true);
|
capture_resolution_combo.set_hexpand(true);
|
||||||
let breakout_combo = gtk::ComboBoxText::new();
|
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_tooltip_text(Some("Breakout window size."));
|
||||||
breakout_combo.set_size_request(0, -1);
|
breakout_combo.set_size_request(0, -1);
|
||||||
breakout_combo.set_hexpand(true);
|
breakout_combo.set_hexpand(true);
|
||||||
@ -125,7 +128,3 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
|||||||
title: title.to_string(),
|
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));
|
let label_widget = gtk::Label::new(Some(label));
|
||||||
label_widget.add_css_class("status-chip-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));
|
let value_widget = gtk::Label::new(Some(value));
|
||||||
value_widget.add_css_class("status-chip-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(&label_widget);
|
||||||
chip.append(&value_widget);
|
chip.append(&value_widget);
|
||||||
(chip, 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);
|
let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
meta.add_css_class("status-chip-meta");
|
meta.add_css_class("status-chip-meta");
|
||||||
|
meta.set_halign(gtk::Align::Center);
|
||||||
let light = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let light = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
light.add_css_class("status-light");
|
light.add_css_class("status-light");
|
||||||
light.add_css_class("status-light-idle");
|
light.add_css_class("status-light-idle");
|
||||||
let label_widget = gtk::Label::new(Some(label));
|
let label_widget = gtk::Label::new(Some(label));
|
||||||
label_widget.add_css_class("status-chip-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(&light);
|
||||||
meta.append(&label_widget);
|
meta.append(&label_widget);
|
||||||
let value_widget = gtk::Label::new(Some(value));
|
let value_widget = gtk::Label::new(Some(value));
|
||||||
value_widget.add_css_class("status-chip-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(&meta);
|
||||||
chip.append(&value_widget);
|
chip.append(&value_widget);
|
||||||
(chip, light, 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_button(1);
|
||||||
gesture.set_propagation_phase(gtk::PropagationPhase::Capture);
|
gesture.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let scale_for_click = scale.clone();
|
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) {
|
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);
|
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(
|
fn should_reset_scale_on_double_click(
|
||||||
n_press: i32,
|
n_press: i32,
|
||||||
current_value: f64,
|
current_value: f64,
|
||||||
|
|||||||
@ -52,6 +52,9 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
|||||||
box.status-light-live {
|
box.status-light-live {
|
||||||
background: rgba(96, 214, 126, 0.95);
|
background: rgba(96, 214, 126, 0.95);
|
||||||
}
|
}
|
||||||
|
box.status-light-connected {
|
||||||
|
background: rgba(76, 154, 255, 0.95);
|
||||||
|
}
|
||||||
box.status-light-idle {
|
box.status-light-idle {
|
||||||
background: rgba(214, 81, 81, 0.92);
|
background: rgba(214, 81, 81, 0.92);
|
||||||
}
|
}
|
||||||
@ -121,6 +124,14 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
|||||||
entry.server-entry {
|
entry.server-entry {
|
||||||
min-height: 38px;
|
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 {
|
button.pill-toggle {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|||||||
@ -182,10 +182,10 @@ pub struct LauncherView {
|
|||||||
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
||||||
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
||||||
const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
|
const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
|
||||||
const LAUNCHER_DEFAULT_HEIGHT: i32 = 940;
|
const LAUNCHER_DEFAULT_HEIGHT: i32 = 900;
|
||||||
const OPERATIONS_RAIL_WIDTH: i32 = 288;
|
const OPERATIONS_RAIL_WIDTH: i32 = 304;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
||||||
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
||||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 320;
|
const EYE_PREVIEW_MIN_HEIGHT: i32 = 300;
|
||||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 568;
|
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
|
||||||
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
fn set_status_light(light: >k::Box, state: StatusLightState) {
|
fn set_status_light(light: >k::Box, state: StatusLightState) {
|
||||||
light.remove_css_class("status-light-live");
|
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-idle");
|
||||||
light.remove_css_class("status-light-warning");
|
light.remove_css_class("status-light-warning");
|
||||||
light.remove_css_class("status-light-caution");
|
light.remove_css_class("status-light-caution");
|
||||||
|
|||||||
@ -41,6 +41,7 @@ pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
|
|||||||
enum StatusLightState {
|
enum StatusLightState {
|
||||||
Idle,
|
Idle,
|
||||||
Live,
|
Live,
|
||||||
|
Connected,
|
||||||
Warning,
|
Warning,
|
||||||
Caution,
|
Caution,
|
||||||
}
|
}
|
||||||
@ -54,6 +55,7 @@ impl StatusLightState {
|
|||||||
match self {
|
match self {
|
||||||
Self::Idle => "status-light-idle",
|
Self::Idle => "status-light-idle",
|
||||||
Self::Live => "status-light-live",
|
Self::Live => "status-light-live",
|
||||||
|
Self::Connected => "status-light-connected",
|
||||||
Self::Warning => "status-light-warning",
|
Self::Warning => "status-light-warning",
|
||||||
Self::Caution => "status-light-caution",
|
Self::Caution => "status-light-caution",
|
||||||
}
|
}
|
||||||
@ -61,15 +63,44 @@ impl StatusLightState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState {
|
fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState {
|
||||||
|
if !state.server_available || !server_version_known(state) {
|
||||||
|
StatusLightState::Idle
|
||||||
|
} else if server_versions_match(state) {
|
||||||
if relay_live {
|
if relay_live {
|
||||||
|
StatusLightState::Connected
|
||||||
|
} else {
|
||||||
StatusLightState::Live
|
StatusLightState::Live
|
||||||
} else if state.server_available {
|
}
|
||||||
|
} else if relay_live {
|
||||||
StatusLightState::Caution
|
StatusLightState::Caution
|
||||||
} else {
|
} 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 {
|
fn server_version_label(state: &LauncherState) -> String {
|
||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return "-".to_string();
|
return "-".to_string();
|
||||||
|
|||||||
@ -18,7 +18,10 @@ use super::{
|
|||||||
preview::{LauncherPreview, PreviewSurface},
|
preview::{LauncherPreview, PreviewSurface},
|
||||||
runtime_env_vars,
|
runtime_env_vars,
|
||||||
state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
|
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";
|
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
|
.audio_channel_toggle
|
||||||
.set_active(state.channels.audio);
|
.set_active(state.channels.audio);
|
||||||
}
|
}
|
||||||
widgets
|
set_rail_button_label(
|
||||||
.start_button
|
&widgets.start_button,
|
||||||
.set_label(if relay_live { "Disconnect" } else { "Connect" });
|
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.start_button.set_sensitive(true);
|
||||||
widgets.server_entry.set_sensitive(!relay_live);
|
widgets.server_entry.set_sensitive(!relay_live);
|
||||||
widgets.start_button.set_tooltip_text(Some(if relay_live {
|
widgets.start_button.set_tooltip_text(Some(if relay_live {
|
||||||
|
|||||||
@ -343,7 +343,7 @@
|
|||||||
"client/src/launcher/ui_components.rs": {
|
"client/src/launcher/ui_components.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 104
|
"loc": 105
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/assemble_view.rs": {
|
"client/src/launcher/ui_components/assemble_view.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -358,12 +358,12 @@
|
|||||||
"client/src/launcher/ui_components/build_device_controls.rs": {
|
"client/src/launcher/ui_components/build_device_controls.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 290
|
"loc": 296
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_operations_rail.rs": {
|
"client/src/launcher/ui_components/build_operations_rail.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 235
|
"loc": 224
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/build_shell.rs": {
|
"client/src/launcher/ui_components/build_shell.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -373,27 +373,32 @@
|
|||||||
"client/src/launcher/ui_components/combo_helpers.rs": {
|
"client/src/launcher/ui_components/combo_helpers.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 11,
|
"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": {
|
"client/src/launcher/ui_components/display_pane.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 1,
|
"doc_debt": 1,
|
||||||
"loc": 131
|
"loc": 130
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/panel_chips.rs": {
|
"client/src/launcher/ui_components/panel_chips.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 74
|
"loc": 79
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/scale_reset.rs": {
|
"client/src/launcher/ui_components/scale_reset.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 21
|
"loc": 44
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/style.rs": {
|
"client/src/launcher/ui_components/style.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 152
|
"loc": 163
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_components/types.rs": {
|
"client/src/launcher/ui_components/types.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -418,7 +423,7 @@
|
|||||||
"client/src/launcher/ui_runtime/log_filtering.rs": {
|
"client/src/launcher/ui_runtime/log_filtering.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 139
|
"loc": 140
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime/process_logs.rs": {
|
"client/src/launcher/ui_runtime/process_logs.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -433,12 +438,12 @@
|
|||||||
"client/src/launcher/ui_runtime/status_details.rs": {
|
"client/src/launcher/ui_runtime/status_details.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 13,
|
"doc_debt": 13,
|
||||||
"loc": 253
|
"loc": 284
|
||||||
},
|
},
|
||||||
"client/src/launcher/ui_runtime/status_refresh.rs": {
|
"client/src/launcher/ui_runtime/status_refresh.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 3,
|
"doc_debt": 3,
|
||||||
"loc": 261
|
"loc": 272
|
||||||
},
|
},
|
||||||
"client/src/layout.rs": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 0,
|
"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/display_pane.rs"),
|
||||||
include_str!("../../client/src/launcher/ui_components/build_device_controls.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/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 {
|
fn const_i32(name: &str) -> i32 {
|
||||||
@ -36,15 +40,27 @@ fn source_index(needle: &str) -> usize {
|
|||||||
#[test]
|
#[test]
|
||||||
fn launcher_default_size_stays_inside_1080p() {
|
fn launcher_default_size_stays_inside_1080p() {
|
||||||
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
|
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_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]
|
#[test]
|
||||||
fn eye_panes_keep_the_locked_larger_preview_footprint() {
|
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_WIDTH"), 460);
|
||||||
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320);
|
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!(
|
assert!(
|
||||||
UI_LAYOUT_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|
UI_LAYOUT_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|
||||||
|| UI_LAYOUT_SRC.contains("capture_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]
|
#[test]
|
||||||
fn device_staging_and_testing_bottoms_stay_locked_together() {
|
fn device_staging_and_testing_bottoms_stay_locked_together() {
|
||||||
assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(true);"));
|
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]
|
#[test]
|
||||||
fn relay_controls_keep_connect_inline_with_server_entry() {
|
fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||||
assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")"));
|
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!(
|
assert!(
|
||||||
UI_LAYOUT_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
|
source_index("relay_grid.attach(&server_entry, 0, 0, 2, 1);")
|
||||||
);
|
< source_index("relay_grid.attach(&start_button, 2, 0, 1, 1);")
|
||||||
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);")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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/relay_input_bindings.rs"),
|
||||||
include_str!("../../client/src/launcher/ui/runtime_poll.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/stage_device_bindings.rs"),
|
||||||
|
include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"),
|
||||||
);
|
);
|
||||||
const DEVICE_TEST_SRC: &str = concat!(
|
const DEVICE_TEST_SRC: &str = concat!(
|
||||||
include_str!("../../client/src/launcher/device_test.rs"),
|
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\""));
|
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]
|
#[test]
|
||||||
fn server_chip_distinguishes_reachable_from_connected() {
|
fn server_chip_distinguishes_reachable_from_connected() {
|
||||||
assert!(UI_RUNTIME_SRC.contains("fn server_light_state("));
|
assert!(UI_RUNTIME_SRC.contains("fn server_light_state("));
|
||||||
assert!(UI_RUNTIME_SRC.contains("if relay_live"));
|
assert!(UI_RUNTIME_SRC.contains("StatusLightState::Connected"));
|
||||||
assert!(UI_RUNTIME_SRC.contains("} else if state.server_available {"));
|
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("StatusLightState::Caution"));
|
||||||
assert!(UI_RUNTIME_SRC.contains("fn server_version_label("));
|
assert!(UI_RUNTIME_SRC.contains("fn server_version_label("));
|
||||||
assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();"));
|
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]
|
#[test]
|
||||||
fn launcher_brand_uses_readable_icon_size() {
|
fn launcher_brand_uses_readable_icon_size() {
|
||||||
assert!(UI_COMPONENTS_SRC.contains("brand_icon.set_pixel_size(44);"));
|
assert!(UI_COMPONENTS_SRC.contains("brand_icon.set_pixel_size(44);"));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user