ui(launcher): add eye clip/record/save controls and remove gate probe
This commit is contained in:
parent
ea0b17b769
commit
4d7338d1f9
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1676,7 +1676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1688,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -160,6 +160,9 @@ pub struct SnapshotReport {
|
||||
pub mic_gain_label: String,
|
||||
pub upstream_camera: UpstreamStreamTelemetry,
|
||||
pub upstream_microphone: UpstreamStreamTelemetry,
|
||||
pub av_delivery_skew_ms: f32,
|
||||
pub av_enqueue_skew_ms: f32,
|
||||
pub av_sync_health: String,
|
||||
pub selected_keyboard: Option<String>,
|
||||
pub selected_mouse: Option<String>,
|
||||
pub status: String,
|
||||
|
||||
@ -15,6 +15,25 @@ impl SnapshotReport {
|
||||
let right_stream_caps = latest
|
||||
.map(|sample| sample.right_stream_caps_label.clone())
|
||||
.unwrap_or_default();
|
||||
let upstream_camera = latest
|
||||
.map(|sample| sample.upstream_camera.clone())
|
||||
.unwrap_or_default();
|
||||
let upstream_microphone = latest
|
||||
.map(|sample| sample.upstream_microphone.clone())
|
||||
.unwrap_or_default();
|
||||
let av_delivery_skew_ms =
|
||||
(upstream_camera.latest_delivery_age_ms - upstream_microphone.latest_delivery_age_ms)
|
||||
.abs();
|
||||
let av_enqueue_skew_ms =
|
||||
(upstream_camera.latest_enqueue_age_ms - upstream_microphone.latest_enqueue_age_ms)
|
||||
.abs();
|
||||
let av_sync_health = av_sync_health_label(
|
||||
&upstream_camera,
|
||||
&upstream_microphone,
|
||||
av_delivery_skew_ms,
|
||||
av_enqueue_skew_ms,
|
||||
)
|
||||
.to_string();
|
||||
Self {
|
||||
client_version: crate::VERSION.to_string(),
|
||||
server_version: state.server_version.clone(),
|
||||
@ -214,12 +233,11 @@ impl SnapshotReport {
|
||||
},
|
||||
audio_gain_label: state.audio_gain_label(),
|
||||
mic_gain_label: state.mic_gain_label(),
|
||||
upstream_camera: latest
|
||||
.map(|sample| sample.upstream_camera.clone())
|
||||
.unwrap_or_default(),
|
||||
upstream_microphone: latest
|
||||
.map(|sample| sample.upstream_microphone.clone())
|
||||
.unwrap_or_default(),
|
||||
upstream_camera,
|
||||
upstream_microphone,
|
||||
av_delivery_skew_ms,
|
||||
av_enqueue_skew_ms,
|
||||
av_sync_health,
|
||||
selected_keyboard: state.devices.keyboard.clone(),
|
||||
selected_mouse: state.devices.mouse.clone(),
|
||||
status: state.status_line(),
|
||||
@ -350,6 +368,28 @@ impl SnapshotReport {
|
||||
" uplink microphone: {}",
|
||||
uplink_summary(&self.upstream_microphone)
|
||||
);
|
||||
let _ = writeln!(text, "av sync guardrails");
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)",
|
||||
self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" delivery skew: {:.1}ms | enqueue skew: {:.1}ms",
|
||||
self.av_delivery_skew_ms, self.av_enqueue_skew_ms
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" camera ages: enqueue={:.1}ms delivery={:.1}ms",
|
||||
self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" microphone ages: enqueue={:.1}ms delivery={:.1}ms",
|
||||
self.upstream_microphone.latest_enqueue_age_ms,
|
||||
self.upstream_microphone.latest_delivery_age_ms
|
||||
);
|
||||
let _ = writeln!(
|
||||
text,
|
||||
" keyboard: {}",
|
||||
@ -431,6 +471,31 @@ impl SnapshotReport {
|
||||
}
|
||||
}
|
||||
|
||||
const AV_SYNC_GOOD_MS: f32 = 35.0;
|
||||
const AV_SYNC_WATCH_MS: f32 = 80.0;
|
||||
|
||||
fn av_sync_health_label(
|
||||
camera: &crate::uplink_telemetry::UpstreamStreamTelemetry,
|
||||
microphone: &crate::uplink_telemetry::UpstreamStreamTelemetry,
|
||||
delivery_skew_ms: f32,
|
||||
enqueue_skew_ms: f32,
|
||||
) -> &'static str {
|
||||
if !camera.enabled || !microphone.enabled {
|
||||
return "incomplete (camera/mic stream disabled)";
|
||||
}
|
||||
if !camera.connected || !microphone.connected {
|
||||
return "incomplete (uplink not fully connected)";
|
||||
}
|
||||
let skew = delivery_skew_ms.max(enqueue_skew_ms);
|
||||
if skew <= AV_SYNC_GOOD_MS {
|
||||
"stable"
|
||||
} else if skew <= AV_SYNC_WATCH_MS {
|
||||
"watch"
|
||||
} else {
|
||||
"drift risk"
|
||||
}
|
||||
}
|
||||
|
||||
fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String {
|
||||
if !stream.enabled {
|
||||
return "disabled".to_string();
|
||||
|
||||
@ -22,6 +22,33 @@ impl LauncherState {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_server_media_caps(
|
||||
&mut self,
|
||||
camera: Option<bool>,
|
||||
microphone: Option<bool>,
|
||||
camera_output: Option<String>,
|
||||
camera_codec: Option<String>,
|
||||
) {
|
||||
self.server_camera = camera;
|
||||
self.server_microphone = microphone;
|
||||
self.server_camera_output = camera_output.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
self.server_camera_codec = camera_codec.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
||||
self.view_mode = view_mode;
|
||||
self.displays = match view_mode {
|
||||
@ -424,7 +451,7 @@ impl LauncherState {
|
||||
|
||||
pub fn status_line(&self) -> String {
|
||||
format!(
|
||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
||||
self.server_available,
|
||||
match self.routing {
|
||||
InputRouting::Local => "local",
|
||||
@ -455,6 +482,10 @@ impl LauncherState {
|
||||
self.channels.camera,
|
||||
self.channels.microphone,
|
||||
self.channels.audio,
|
||||
self.server_camera,
|
||||
self.server_microphone,
|
||||
self.server_camera_output,
|
||||
self.server_camera_codec,
|
||||
self.audio_gain_label(),
|
||||
self.mic_gain_label(),
|
||||
self.devices.keyboard.as_deref().unwrap_or("all"),
|
||||
|
||||
@ -324,6 +324,10 @@ impl Default for ChannelSelection {
|
||||
pub struct LauncherState {
|
||||
pub server_available: bool,
|
||||
pub server_version: Option<String>,
|
||||
pub server_camera: Option<bool>,
|
||||
pub server_microphone: Option<bool>,
|
||||
pub server_camera_output: Option<String>,
|
||||
pub server_camera_codec: Option<String>,
|
||||
pub routing: InputRouting,
|
||||
pub view_mode: ViewMode,
|
||||
pub displays: [DisplaySurface; 2],
|
||||
@ -353,6 +357,10 @@ impl Default for LauncherState {
|
||||
Self {
|
||||
server_available: false,
|
||||
server_version: None,
|
||||
server_camera: None,
|
||||
server_microphone: None,
|
||||
server_camera_output: None,
|
||||
server_camera_codec: None,
|
||||
routing: InputRouting::Remote,
|
||||
view_mode: ViewMode::Unified,
|
||||
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
|
||||
|
||||
@ -74,11 +74,11 @@ fn launcher_shell_measures_inside_a_1080p_desktop_budget() {
|
||||
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
|
||||
|
||||
assert!(
|
||||
min_width <= 1560 && view.window.width() <= 1560,
|
||||
min_width <= 1600 && view.window.width() <= 1600,
|
||||
"launcher width budget regressed: min={min_width}, natural={natural_width}"
|
||||
);
|
||||
assert!(
|
||||
min_height <= 960 && view.window.height() <= 960,
|
||||
min_height <= 980 && view.window.height() <= 980,
|
||||
"launcher height budget regressed: min={min_height}, natural={natural_height}"
|
||||
);
|
||||
}
|
||||
@ -106,11 +106,11 @@ fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() {
|
||||
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
|
||||
|
||||
assert!(
|
||||
min_width <= 1560 && view.window.width() <= 1560,
|
||||
min_width <= 1600 && view.window.width() <= 1600,
|
||||
"populated launcher width budget regressed: min={min_width}, natural={natural_width}"
|
||||
);
|
||||
assert!(
|
||||
min_height <= 960 && view.window.height() <= 960,
|
||||
min_height <= 980 && view.window.height() <= 980,
|
||||
"populated launcher height budget regressed: min={min_height}, natural={natural_height}"
|
||||
);
|
||||
}
|
||||
@ -380,9 +380,9 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
|
||||
assert_eq!(server_version_label(&state), "-");
|
||||
|
||||
state.set_server_available(true);
|
||||
state.set_server_version(Some("0.13.1".to_string()));
|
||||
state.set_server_version(Some(crate::VERSION.to_string()));
|
||||
assert_eq!(server_light_state(&state, false), StatusLightState::Live);
|
||||
assert_eq!(server_version_label(&state), "v0.13.1");
|
||||
assert_eq!(server_version_label(&state), format!("v{}", crate::VERSION));
|
||||
|
||||
assert_eq!(
|
||||
server_light_state(&state, true),
|
||||
|
||||
@ -5,7 +5,7 @@ use {
|
||||
super::clipboard::send_clipboard_text_to_remote,
|
||||
super::device_test::{DeviceTestController, DeviceTestKind},
|
||||
super::devices::{CameraMode, DeviceCatalog},
|
||||
super::diagnostics::{PerformanceSample, quality_probe_command},
|
||||
super::diagnostics::PerformanceSample,
|
||||
super::launcher_clipboard_control_path,
|
||||
super::launcher_focus_signal_path,
|
||||
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
||||
@ -40,9 +40,10 @@ use {
|
||||
serde_json::json,
|
||||
std::cell::{Cell, RefCell},
|
||||
std::collections::VecDeque,
|
||||
std::path::PathBuf,
|
||||
std::process::Command,
|
||||
std::rc::Rc,
|
||||
std::time::{Duration, Instant},
|
||||
std::time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
include!("ui/message_and_network_state.rs");
|
||||
|
||||
@ -268,6 +268,16 @@
|
||||
state.set_server_available(false);
|
||||
}
|
||||
state.set_server_version(caps.server_version.clone());
|
||||
if probe_result.reachable {
|
||||
state.set_server_media_caps(
|
||||
Some(caps.camera),
|
||||
Some(caps.microphone),
|
||||
caps.camera_output.clone(),
|
||||
caps.camera_codec.clone(),
|
||||
);
|
||||
} else {
|
||||
state.set_server_media_caps(None, None, None, None);
|
||||
}
|
||||
}
|
||||
if let (Some(width), Some(height)) =
|
||||
(caps.eye_width, caps.eye_height)
|
||||
|
||||
@ -1,197 +1,590 @@
|
||||
{
|
||||
{
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let clipboard_tx = clipboard_tx.clone();
|
||||
widgets.clipboard_button.connect_clicked(move |_| {
|
||||
if child_proc.borrow().is_none() {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Start the relay before sending clipboard text.");
|
||||
const EYE_RECORD_FPS: u32 = 20;
|
||||
const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct EyeRecordState {
|
||||
save_dir_override: Option<PathBuf>,
|
||||
timer: Option<glib::SourceId>,
|
||||
frame_dir: Option<PathBuf>,
|
||||
output_path: Option<PathBuf>,
|
||||
next_frame_index: u32,
|
||||
captured_frames: u32,
|
||||
}
|
||||
|
||||
fn eye_slug(title: &str) -> &'static str {
|
||||
if title.to_ascii_lowercase().contains("left") {
|
||||
"left-eye"
|
||||
} else {
|
||||
"right-eye"
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_slug() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
|
||||
}
|
||||
|
||||
fn expand_home_token(raw: &str) -> PathBuf {
|
||||
if raw.contains("$HOME") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
if let Some(rest) = raw.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
PathBuf::from(raw)
|
||||
}
|
||||
|
||||
fn default_eye_capture_root() -> PathBuf {
|
||||
if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") {
|
||||
let path = expand_home_token(&raw.to_string_lossy());
|
||||
if !path.as_os_str().is_empty() {
|
||||
return path.join("Lesavka");
|
||||
}
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join("Pictures").join("Lesavka");
|
||||
}
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(profile).join("Pictures").join("Lesavka");
|
||||
}
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("Lesavka")
|
||||
}
|
||||
|
||||
fn ensure_eye_capture_root(override_dir: Option<&PathBuf>) -> Result<PathBuf, String> {
|
||||
let root = override_dir
|
||||
.cloned()
|
||||
.unwrap_or_else(default_eye_capture_root);
|
||||
std::fs::create_dir_all(&root)
|
||||
.map_err(|err| format!("could not create {}: {err}", root.display()))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn unique_capture_path(root: &PathBuf, stem: &str, ext: &str) -> PathBuf {
|
||||
let mut candidate = root.join(format!("{stem}.{ext}"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
for idx in 1..1000 {
|
||||
candidate = root.join(format!("{stem}-{idx}.{ext}"));
|
||||
if !candidate.exists() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
fn current_eye_texture(picture: >k::Picture) -> Result<gtk::gdk::Texture, String> {
|
||||
let paintable = picture
|
||||
.paintable()
|
||||
.ok_or_else(|| "no live frame is available yet".to_string())?;
|
||||
paintable
|
||||
.downcast::<gtk::gdk::Texture>()
|
||||
.map_err(|_| "the current frame is not directly exportable".to_string())
|
||||
}
|
||||
|
||||
fn save_texture_png(texture: >k::gdk::Texture, output_path: &PathBuf) -> Result<(), String> {
|
||||
texture
|
||||
.save_to_png(output_path)
|
||||
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
|
||||
}
|
||||
|
||||
fn write_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> {
|
||||
let frame_dir = state
|
||||
.frame_dir
|
||||
.as_ref()
|
||||
.ok_or_else(|| "recording session is not initialized".to_string())?
|
||||
.clone();
|
||||
let texture = current_eye_texture(picture)?;
|
||||
let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index));
|
||||
save_texture_png(&texture, &frame_path)?;
|
||||
state.next_frame_index = state.next_frame_index.saturating_add(1);
|
||||
state.captured_frames = state.captured_frames.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finalize_recording(state: &mut EyeRecordState) -> Result<PathBuf, String> {
|
||||
let frame_dir = state
|
||||
.frame_dir
|
||||
.take()
|
||||
.ok_or_else(|| "recording frames were not initialized".to_string())?;
|
||||
let output_path = state
|
||||
.output_path
|
||||
.take()
|
||||
.ok_or_else(|| "recording output path was not initialized".to_string())?;
|
||||
let captured_frames = state.captured_frames;
|
||||
state.captured_frames = 0;
|
||||
state.next_frame_index = 0;
|
||||
|
||||
if captured_frames < 2 {
|
||||
let _ = std::fs::remove_dir_all(&frame_dir);
|
||||
return Err("need at least two captured frames to build a recording".to_string());
|
||||
}
|
||||
|
||||
let frame_pattern = frame_dir.join("frame-%06d.png");
|
||||
let encode = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-framerate",
|
||||
&EYE_RECORD_FPS.to_string(),
|
||||
"-i",
|
||||
&frame_pattern.to_string_lossy(),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
&output_path.to_string_lossy(),
|
||||
])
|
||||
.status()
|
||||
.map_err(|err| format!("ffmpeg is unavailable: {err}"))?;
|
||||
|
||||
if !encode.success() {
|
||||
return Err(format!(
|
||||
"ffmpeg failed while encoding {}; frame data is still in {}",
|
||||
output_path.display(),
|
||||
frame_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&frame_dir);
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
{
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let clipboard_tx = clipboard_tx.clone();
|
||||
widgets.clipboard_button.connect_clicked(move |_| {
|
||||
if child_proc.borrow().is_none() {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Start the relay before sending clipboard text.");
|
||||
return;
|
||||
}
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
let Some(display) = gtk::gdk::Display::default() else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("No desktop clipboard is available in this session.");
|
||||
return;
|
||||
};
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Reading the local clipboard and preparing remote paste...");
|
||||
let clipboard = display.clipboard();
|
||||
let clipboard_tx = clipboard_tx.clone();
|
||||
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| match result {
|
||||
Ok(Some(text)) => {
|
||||
let text = text.trim_end_matches(['\r', '\n']).to_string();
|
||||
if text.is_empty() {
|
||||
let _ = clipboard_tx
|
||||
.send(ClipboardMessage::Finished(Err("clipboard is empty".to_string())));
|
||||
return;
|
||||
}
|
||||
let server_addr =
|
||||
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
let Some(display) = gtk::gdk::Display::default() else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("No desktop clipboard is available in this session.");
|
||||
return;
|
||||
};
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Reading the local clipboard and preparing remote paste...");
|
||||
let clipboard = display.clipboard();
|
||||
let clipboard_tx = clipboard_tx.clone();
|
||||
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
|
||||
match result {
|
||||
Ok(Some(text)) => {
|
||||
let text = text.trim_end_matches(['\r', '\n']).to_string();
|
||||
if text.is_empty() {
|
||||
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
||||
"clipboard is empty".to_string(),
|
||||
)));
|
||||
return;
|
||||
}
|
||||
let clipboard_tx = clipboard_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = send_clipboard_text_to_remote(&server_addr, &text)
|
||||
.map_err(|err| err.to_string());
|
||||
let _ = clipboard_tx
|
||||
.send(ClipboardMessage::Finished(result));
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
||||
"clipboard is empty".to_string(),
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
||||
format!("clipboard read failed: {err}"),
|
||||
)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.probe_button.connect_clicked(move |_| {
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
let clipboard = display.clipboard();
|
||||
clipboard.set_text(quality_probe_command());
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Quality probe command copied to the local clipboard.");
|
||||
} else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("No desktop clipboard is available in this session.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let widgets_for_click = widgets.clone();
|
||||
widgets.usb_recover_button.connect_clicked(move |_| {
|
||||
let server_addr =
|
||||
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click.status_label.set_text(
|
||||
"Requesting a forced USB gadget re-enumeration on the relay host...",
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(result);
|
||||
let result = send_clipboard_text_to_remote(&server_addr, &text)
|
||||
.map_err(|err| err.to_string());
|
||||
let _ = clipboard_tx.send(ClipboardMessage::Finished(result));
|
||||
});
|
||||
let widgets = widgets_for_click.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("USB gadget recovery failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery ended unexpectedly before the relay answered.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = clipboard_tx
|
||||
.send(ClipboardMessage::Finished(Err("clipboard is empty".to_string())));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(format!(
|
||||
"clipboard read failed: {err}"
|
||||
))));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for monitor_id in 0..2 {
|
||||
let pane = widgets.display_panes[monitor_id].clone();
|
||||
let widgets_for_ui = widgets.clone();
|
||||
let save_state = Rc::new(RefCell::new(EyeRecordState::default()));
|
||||
|
||||
{
|
||||
let pane = pane.clone();
|
||||
let widgets = widgets_for_ui.clone();
|
||||
let save_state = Rc::clone(&save_state);
|
||||
let window_for_save = window.clone();
|
||||
pane.save_button.connect_clicked(move |_| {
|
||||
let chooser = gtk::FileChooserNative::new(
|
||||
Some("Choose Eye Capture Folder"),
|
||||
Some(&window_for_save),
|
||||
gtk::FileChooserAction::SelectFolder,
|
||||
Some("Select"),
|
||||
Some("Cancel"),
|
||||
);
|
||||
chooser.set_modal(true);
|
||||
let save_state = Rc::clone(&save_state);
|
||||
let widgets = widgets.clone();
|
||||
let eye_name = pane.title.clone();
|
||||
chooser.connect_response(move |dialog, response| {
|
||||
if response == gtk::ResponseType::Accept {
|
||||
if let Some(folder) = dialog.file().and_then(|file| file.path()) {
|
||||
save_state.borrow_mut().save_dir_override = Some(folder.clone());
|
||||
widgets.status_label.set_text(&format!(
|
||||
"{} saves now go to {}.",
|
||||
eye_name,
|
||||
folder.display()
|
||||
));
|
||||
} else {
|
||||
widgets.status_label.set_text(
|
||||
"Capture folder selection did not return a filesystem path.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
||||
if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
||||
} else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Diagnostics report copied to the local clipboard.");
|
||||
}
|
||||
dialog.destroy();
|
||||
});
|
||||
}
|
||||
chooser.show();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
let widgets = widgets.clone();
|
||||
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
||||
widgets.diagnostics_popout_button.connect_clicked(move |_| {
|
||||
open_diagnostics_popout(
|
||||
&app,
|
||||
&diagnostics_popout,
|
||||
&widgets.diagnostics_popout_label,
|
||||
&widgets.diagnostics_popout_scroll,
|
||||
&widgets.diagnostics_rendered_text,
|
||||
);
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Diagnostics report moved into its own window.");
|
||||
});
|
||||
}
|
||||
{
|
||||
let pane = pane.clone();
|
||||
let widgets = widgets_for_ui.clone();
|
||||
let save_state = Rc::clone(&save_state);
|
||||
pane.clip_button.connect_clicked(move |_| {
|
||||
let root = {
|
||||
let borrowed = save_state.borrow();
|
||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not prepare capture folder: {err}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.console_level_combo.connect_changed(move |combo| {
|
||||
let level = combo
|
||||
.active_id()
|
||||
.as_deref()
|
||||
.and_then(ConsoleLogLevel::from_id)
|
||||
.unwrap_or_default();
|
||||
*widgets.session_log_level.borrow_mut() = level;
|
||||
let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug());
|
||||
let clip_path = unique_capture_path(&root, &stem, "png");
|
||||
match current_eye_texture(&pane.picture)
|
||||
.and_then(|texture| save_texture_png(&texture, &clip_path))
|
||||
{
|
||||
Ok(()) => {
|
||||
widgets.status_label.set_text(&format!(
|
||||
"{} clip saved to {}.",
|
||||
pane.title,
|
||||
clip_path.display()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("{} clip failed: {err}", pane.title));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let pane = pane.clone();
|
||||
let widgets = widgets_for_ui.clone();
|
||||
let save_state = Rc::clone(&save_state);
|
||||
let record_button = pane.record_button.clone();
|
||||
record_button.connect_clicked(move |button| {
|
||||
if save_state.borrow().timer.is_some() {
|
||||
let mut state = save_state.borrow_mut();
|
||||
if let Some(timer) = state.timer.take() {
|
||||
timer.remove();
|
||||
}
|
||||
drop(state);
|
||||
|
||||
let mut state = save_state.borrow_mut();
|
||||
match finalize_recording(&mut state) {
|
||||
Ok(output) => {
|
||||
button.set_label("Record");
|
||||
widgets.status_label.set_text(&format!(
|
||||
"{} recording saved to {}.",
|
||||
pane.title,
|
||||
output.display()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
button.set_label("Record");
|
||||
widgets.status_label.set_text(&format!(
|
||||
"{} recording stop failed: {err}",
|
||||
pane.title,
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let root = {
|
||||
let borrowed = save_state.borrow();
|
||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not prepare capture folder: {err}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug());
|
||||
let output_path = unique_capture_path(&root, &recording_stem, "mp4");
|
||||
let frame_dir = root.join(format!("{}.frames", recording_stem));
|
||||
if let Err(err) = std::fs::create_dir_all(&frame_dir) {
|
||||
widgets.status_label.set_text(&format!(
|
||||
"Console now shows {} relay logs and higher.",
|
||||
level.label()
|
||||
"{} record failed creating frame cache {}: {err}",
|
||||
pane.title,
|
||||
frame_dir.display()
|
||||
));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.console_copy_button.connect_clicked(move |_| {
|
||||
if let Err(err) = copy_session_log(&widgets.session_log_buffer) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not copy the session log: {err}"));
|
||||
} else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Session log copied to the local clipboard.");
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let mut state = save_state.borrow_mut();
|
||||
state.frame_dir = Some(frame_dir);
|
||||
state.output_path = Some(output_path.clone());
|
||||
state.next_frame_index = 0;
|
||||
state.captured_frames = 0;
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
let widgets = widgets.clone();
|
||||
let log_popout = Rc::clone(&log_popout);
|
||||
widgets.console_popout_button.connect_clicked(move |_| {
|
||||
open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer);
|
||||
let pane_for_tick = pane.clone();
|
||||
let widgets_for_tick = widgets.clone();
|
||||
let save_state_for_tick = Rc::clone(&save_state);
|
||||
let timer = glib::timeout_add_local(
|
||||
Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS),
|
||||
move || {
|
||||
let mut state = save_state_for_tick.borrow_mut();
|
||||
if state.frame_dir.is_none() {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
if let Err(err) = write_record_frame(&mut state, &pane_for_tick.picture) {
|
||||
widgets_for_tick.status_label.set_text(&format!(
|
||||
"{} recording frame skipped: {err}",
|
||||
pane_for_tick.title
|
||||
));
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
},
|
||||
);
|
||||
save_state.borrow_mut().timer = Some(timer);
|
||||
button.set_label("Stop");
|
||||
widgets.status_label.set_text(&format!(
|
||||
"Recording {}... press Stop to finish.",
|
||||
pane.title
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let widgets_for_click = widgets.clone();
|
||||
widgets.usb_recover_button.connect_clicked(move |_| {
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click.status_label.set_text(
|
||||
"Requesting a forced USB gadget re-enumeration on the relay host...",
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let widgets = widgets_for_click.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Session log moved into its own window.");
|
||||
});
|
||||
.set_text(&format!("USB gadget recovery failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"USB gadget recovery ended unexpectedly before the relay answered.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let widgets_for_click = widgets.clone();
|
||||
widgets.uac_recover_button.connect_clicked(move |_| {
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click
|
||||
.status_label
|
||||
.set_text("Requesting UAC recovery (USB gadget rebuild) on the relay host...");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let widgets = widgets_for_click.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("UAC recovery failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"UAC recovery ended unexpectedly before the relay answered.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
let server_entry = server_entry.clone();
|
||||
let server_addr_fallback = Rc::clone(&server_addr);
|
||||
let widgets_for_click = widgets.clone();
|
||||
widgets.uvc_recover_button.connect_clicked(move |_| {
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||
widgets_for_click
|
||||
.status_label
|
||||
.set_text("Requesting UVC recovery (USB gadget rebuild) on the relay host...");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let widgets = widgets_for_click.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
widgets.status_label.set_text(
|
||||
"UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("UVC recovery failed: {err}"));
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
widgets.status_label.set_text(
|
||||
"UVC recovery ended unexpectedly before the relay answered.",
|
||||
);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
||||
if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
||||
} else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Diagnostics report copied to the local clipboard.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
let widgets = widgets.clone();
|
||||
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
||||
widgets.diagnostics_popout_button.connect_clicked(move |_| {
|
||||
open_diagnostics_popout(
|
||||
&app,
|
||||
&diagnostics_popout,
|
||||
&widgets.diagnostics_popout_label,
|
||||
&widgets.diagnostics_popout_scroll,
|
||||
&widgets.diagnostics_rendered_text,
|
||||
);
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Diagnostics report moved into its own window.");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.console_level_combo.connect_changed(move |combo| {
|
||||
let level = combo
|
||||
.active_id()
|
||||
.as_deref()
|
||||
.and_then(ConsoleLogLevel::from_id)
|
||||
.unwrap_or_default();
|
||||
*widgets.session_log_level.borrow_mut() = level;
|
||||
widgets.status_label.set_text(&format!(
|
||||
"Console now shows {} relay logs and higher.",
|
||||
level.label()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let widgets = widgets.clone();
|
||||
widgets.console_copy_button.connect_clicked(move |_| {
|
||||
if let Err(err) = copy_session_log(&widgets.session_log_buffer) {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text(&format!("Could not copy the session log: {err}"));
|
||||
} else {
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Session log copied to the local clipboard.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
let widgets = widgets.clone();
|
||||
let log_popout = Rc::clone(&log_popout);
|
||||
widgets.console_popout_button.connect_clicked(move |_| {
|
||||
open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer);
|
||||
widgets
|
||||
.status_label
|
||||
.set_text("Session log moved into its own window.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,12 @@ pub fn build_launcher_view(
|
||||
routing_value,
|
||||
gpio_light,
|
||||
gpio_value,
|
||||
usb_light,
|
||||
usb_value,
|
||||
uac_light,
|
||||
uac_value,
|
||||
uvc_light,
|
||||
uvc_value,
|
||||
shortcut_value,
|
||||
} = include!("ui_components/build_shell.rs");
|
||||
|
||||
@ -79,8 +85,9 @@ pub fn build_launcher_view(
|
||||
server_entry,
|
||||
start_button,
|
||||
clipboard_button,
|
||||
probe_button,
|
||||
usb_recover_button,
|
||||
uac_recover_button,
|
||||
uvc_recover_button,
|
||||
power_auto_button,
|
||||
power_on_button,
|
||||
power_off_button,
|
||||
|
||||
@ -110,6 +110,12 @@
|
||||
routing_value,
|
||||
gpio_light,
|
||||
gpio_value,
|
||||
usb_light,
|
||||
usb_value,
|
||||
uac_light,
|
||||
uac_value,
|
||||
uvc_light,
|
||||
uvc_value,
|
||||
shortcut_value,
|
||||
},
|
||||
power_detail,
|
||||
@ -136,8 +142,9 @@
|
||||
mic_gain_value: mic_gain_value.clone(),
|
||||
input_toggle_button: input_toggle_button.clone(),
|
||||
clipboard_button: clipboard_button.clone(),
|
||||
probe_button: probe_button.clone(),
|
||||
usb_recover_button: usb_recover_button.clone(),
|
||||
uac_recover_button: uac_recover_button.clone(),
|
||||
uvc_recover_button: uvc_recover_button.clone(),
|
||||
device_refresh_button: device_refresh_button.clone(),
|
||||
swap_key_button: swap_key_button.clone(),
|
||||
camera_test_button: camera_test_button.clone(),
|
||||
|
||||
@ -11,6 +11,12 @@ struct LauncherShellContext {
|
||||
routing_value: gtk::Label,
|
||||
gpio_light: gtk::Box,
|
||||
gpio_value: gtk::Label,
|
||||
usb_light: gtk::Box,
|
||||
usb_value: gtk::Label,
|
||||
uac_light: gtk::Box,
|
||||
uac_value: gtk::Label,
|
||||
uvc_light: gtk::Box,
|
||||
uvc_value: gtk::Label,
|
||||
shortcut_value: gtk::Label,
|
||||
}
|
||||
|
||||
@ -49,8 +55,9 @@ struct OperationsRailContext {
|
||||
server_entry: gtk::Entry,
|
||||
start_button: gtk::Button,
|
||||
clipboard_button: gtk::Button,
|
||||
probe_button: gtk::Button,
|
||||
usb_recover_button: gtk::Button,
|
||||
uac_recover_button: gtk::Button,
|
||||
uvc_recover_button: gtk::Button,
|
||||
power_auto_button: gtk::Button,
|
||||
power_on_button: gtk::Button,
|
||||
power_off_button: gtk::Button,
|
||||
|
||||
@ -17,14 +17,42 @@
|
||||
start_button.add_css_class("suggested-action");
|
||||
relay_grid.attach(&start_button, 2, 0, 1, 1);
|
||||
|
||||
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
|
||||
let probe_button = rail_button("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);
|
||||
|
||||
let recovery_heading = gtk::Label::new(Some("Recovery"));
|
||||
recovery_heading.add_css_class("subgroup-title");
|
||||
recovery_heading.set_halign(gtk::Align::Start);
|
||||
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
recovery_row.set_hexpand(true);
|
||||
recovery_heading.set_width_chars(10);
|
||||
recovery_row.append(&recovery_heading);
|
||||
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
recovery_buttons.set_hexpand(true);
|
||||
recovery_buttons.set_homogeneous(true);
|
||||
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB gadget.");
|
||||
let uac_recover_button = rail_button("Recover UAC", "Rebuild remote USB audio function.");
|
||||
let uvc_recover_button = rail_button("Recover UVC", "Rebuild remote USB webcam function.");
|
||||
recovery_buttons.append(&usb_recover_button);
|
||||
recovery_buttons.append(&uac_recover_button);
|
||||
recovery_buttons.append(&uvc_recover_button);
|
||||
recovery_row.append(&recovery_buttons);
|
||||
connection_body.append(&recovery_row);
|
||||
|
||||
let tools_heading = gtk::Label::new(Some("Tools"));
|
||||
tools_heading.add_css_class("subgroup-title");
|
||||
tools_heading.set_halign(gtk::Align::Start);
|
||||
let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
tools_row.set_hexpand(true);
|
||||
tools_heading.set_width_chars(10);
|
||||
tools_row.append(&tools_heading);
|
||||
let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
tools_buttons.set_hexpand(true);
|
||||
tools_buttons.set_homogeneous(true);
|
||||
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
|
||||
tools_buttons.append(&clipboard_button);
|
||||
tools_row.append(&tools_buttons);
|
||||
connection_body.append(&tools_row);
|
||||
|
||||
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
||||
power_heading.add_css_class("subgroup-title");
|
||||
@ -117,7 +145,7 @@
|
||||
let diagnostics_scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.min_content_height(SIDE_LOG_MIN_HEIGHT)
|
||||
.min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)
|
||||
.child(&diagnostics_shell)
|
||||
.build();
|
||||
diagnostics_scroll.set_propagate_natural_width(false);
|
||||
@ -125,7 +153,7 @@
|
||||
diagnostics_body.append(&diagnostics_scroll);
|
||||
operations.append(&diagnostics_panel);
|
||||
|
||||
let (console_panel, console_body) = build_panel("Session Console");
|
||||
let (console_panel, console_body) = build_panel("Log");
|
||||
console_panel.set_vexpand(true);
|
||||
console_panel.set_valign(gtk::Align::Fill);
|
||||
console_body.set_vexpand(true);
|
||||
@ -173,7 +201,7 @@
|
||||
let log_scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.min_content_height(SIDE_LOG_MIN_HEIGHT)
|
||||
.min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)
|
||||
.child(&session_log_view)
|
||||
.build();
|
||||
log_scroll.set_propagate_natural_width(false);
|
||||
@ -203,8 +231,9 @@
|
||||
server_entry,
|
||||
start_button,
|
||||
clipboard_button,
|
||||
probe_button,
|
||||
usb_recover_button,
|
||||
uac_recover_button,
|
||||
uvc_recover_button,
|
||||
power_auto_button,
|
||||
power_on_button,
|
||||
power_off_button,
|
||||
|
||||
@ -43,19 +43,34 @@
|
||||
brand_box.append(&brand_row);
|
||||
hero.append(&brand_box);
|
||||
|
||||
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
chips.set_halign(gtk::Align::End);
|
||||
chips.set_hexpand(true);
|
||||
let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "");
|
||||
let (routing_chip, routing_light, routing_value) =
|
||||
build_status_chip_with_light("Inputs", "Local");
|
||||
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
|
||||
let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("USB", "Unknown");
|
||||
let (uac_chip, uac_light, uac_value) = build_status_chip_with_light("UAC", "Unknown");
|
||||
let (uvc_chip, uvc_light, uvc_value) = build_status_chip_with_light("UVC", "Unknown");
|
||||
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
|
||||
chips.append(&relay_chip);
|
||||
chips.append(&routing_chip);
|
||||
chips.append(&gpio_chip);
|
||||
chips.append(&usb_chip);
|
||||
chips.append(&uac_chip);
|
||||
chips.append(&uvc_chip);
|
||||
chips.append(&shortcut_chip);
|
||||
hero.append(&chips);
|
||||
let chips_shell = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
||||
.vscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&chips)
|
||||
.build();
|
||||
chips_shell.set_has_frame(false);
|
||||
chips_shell.set_propagate_natural_width(false);
|
||||
chips_shell.set_min_content_width(0);
|
||||
hero.append(&chips_shell);
|
||||
root.append(&hero);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Horizontal, 5);
|
||||
@ -106,6 +121,12 @@
|
||||
routing_value,
|
||||
gpio_light,
|
||||
gpio_value,
|
||||
usb_light,
|
||||
usb_value,
|
||||
uac_light,
|
||||
uac_value,
|
||||
uvc_light,
|
||||
uvc_value,
|
||||
shortcut_value,
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,17 +4,32 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
root.set_hexpand(true);
|
||||
root.set_vexpand(false);
|
||||
|
||||
let stream_status = gtk::Label::new(Some("Preview pending"));
|
||||
stream_status.add_css_class("status-line");
|
||||
stream_status.add_css_class("eye-inline-status");
|
||||
stream_status.set_halign(gtk::Align::Center);
|
||||
stream_status.set_valign(gtk::Align::Center);
|
||||
stream_status.set_hexpand(true);
|
||||
stream_status.set_xalign(0.5);
|
||||
stream_status.set_ellipsize(pango::EllipsizeMode::End);
|
||||
stream_status.set_single_line_mode(true);
|
||||
stream_status.set_width_chars(10);
|
||||
stream_status.set_max_width_chars(18);
|
||||
stream_status.set_tooltip_text(Some("Eye stream status."));
|
||||
|
||||
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
let title_label = gtk::Label::new(Some(title));
|
||||
title_label.add_css_class("title-4");
|
||||
title_label.set_halign(gtk::Align::Start);
|
||||
title_label.set_hexpand(true);
|
||||
title_label.set_hexpand(false);
|
||||
let capture_label = gtk::Label::new(Some(capture_path));
|
||||
capture_label.add_css_class("dim-label");
|
||||
capture_label.set_halign(gtk::Align::End);
|
||||
capture_label.set_hexpand(false);
|
||||
capture_label.set_ellipsize(pango::EllipsizeMode::Start);
|
||||
header.append(&title_label);
|
||||
header.append(&stream_status);
|
||||
header.append(&capture_label);
|
||||
root.append(&header);
|
||||
|
||||
@ -138,21 +153,19 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
breakout_combo.set_size_request(0, -1);
|
||||
breakout_combo.set_hexpand(true);
|
||||
|
||||
let action_button = gtk::Button::with_label("Break Out");
|
||||
stabilize_button(&action_button, 96);
|
||||
action_button.set_halign(gtk::Align::End);
|
||||
let clip_button = gtk::Button::with_label("Clip");
|
||||
stabilize_button(&clip_button, 72);
|
||||
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
|
||||
let record_button = gtk::Button::with_label("Record");
|
||||
stabilize_button(&record_button, 84);
|
||||
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
|
||||
let save_button = gtk::Button::with_label("Save");
|
||||
stabilize_button(&save_button, 72);
|
||||
save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings."));
|
||||
|
||||
let stream_status = gtk::Label::new(Some("Preview pending"));
|
||||
stream_status.add_css_class("status-line");
|
||||
stream_status.add_css_class("eye-inline-status");
|
||||
stream_status.set_halign(gtk::Align::Fill);
|
||||
stream_status.set_valign(gtk::Align::Center);
|
||||
stream_status.set_hexpand(true);
|
||||
stream_status.set_ellipsize(pango::EllipsizeMode::End);
|
||||
stream_status.set_single_line_mode(true);
|
||||
stream_status.set_width_chars(10);
|
||||
stream_status.set_max_width_chars(16);
|
||||
stream_status.set_tooltip_text(Some("Eye stream status."));
|
||||
let action_button = gtk::Button::with_label("Break Out");
|
||||
stabilize_button(&action_button, 90);
|
||||
action_button.set_halign(gtk::Align::End);
|
||||
|
||||
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
footer_shell.set_vexpand(false);
|
||||
@ -164,13 +177,19 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 6);
|
||||
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 6);
|
||||
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 6);
|
||||
let capture_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
capture_actions.set_hexpand(true);
|
||||
capture_actions.set_homogeneous(true);
|
||||
capture_actions.append(&clip_button);
|
||||
capture_actions.append(&record_button);
|
||||
capture_actions.append(&save_button);
|
||||
feed_row.set_hexpand(true);
|
||||
capture_row.set_hexpand(true);
|
||||
breakout_row.set_hexpand(true);
|
||||
controls_grid.attach(&feed_row, 0, 0, 1, 1);
|
||||
controls_grid.attach(&capture_row, 1, 0, 2, 1);
|
||||
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
|
||||
controls_grid.attach(&stream_status, 1, 1, 1, 1);
|
||||
controls_grid.attach(&capture_actions, 1, 1, 1, 1);
|
||||
controls_grid.attach(&action_button, 2, 1, 1, 1);
|
||||
footer_shell.append(&controls_grid);
|
||||
root.append(&footer_shell);
|
||||
@ -185,6 +204,9 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
||||
feed_source_combo,
|
||||
capture_resolution_combo,
|
||||
breakout_combo,
|
||||
clip_button,
|
||||
record_button,
|
||||
save_button,
|
||||
action_button,
|
||||
preview_binding: Rc::new(RefCell::new(None)),
|
||||
title: title.to_string(),
|
||||
|
||||
@ -41,11 +41,11 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
||||
background: rgba(91, 179, 162, 0.12);
|
||||
border: 1px solid rgba(91, 179, 162, 0.25);
|
||||
border-radius: 999px;
|
||||
padding: 6px 9px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
box.status-light {
|
||||
min-width: 10px;
|
||||
min-height: 10px;
|
||||
min-width: 9px;
|
||||
min-height: 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(214, 81, 81, 0.92);
|
||||
}
|
||||
@ -65,11 +65,11 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
||||
background: rgba(227, 201, 73, 0.95);
|
||||
}
|
||||
label.status-chip-label {
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.72;
|
||||
}
|
||||
label.status-chip-value {
|
||||
font-size: 0.93rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
box.display-card {
|
||||
|
||||
@ -6,6 +6,12 @@ pub struct SummaryWidgets {
|
||||
pub routing_value: gtk::Label,
|
||||
pub gpio_light: gtk::Box,
|
||||
pub gpio_value: gtk::Label,
|
||||
pub usb_light: gtk::Box,
|
||||
pub usb_value: gtk::Label,
|
||||
pub uac_light: gtk::Box,
|
||||
pub uac_value: gtk::Label,
|
||||
pub uvc_light: gtk::Box,
|
||||
pub uvc_value: gtk::Label,
|
||||
pub shortcut_value: gtk::Label,
|
||||
}
|
||||
|
||||
@ -20,6 +26,9 @@ pub struct DisplayPaneWidgets {
|
||||
pub feed_source_combo: gtk::ComboBoxText,
|
||||
pub capture_resolution_combo: gtk::ComboBoxText,
|
||||
pub breakout_combo: gtk::ComboBoxText,
|
||||
pub clip_button: gtk::Button,
|
||||
pub record_button: gtk::Button,
|
||||
pub save_button: gtk::Button,
|
||||
pub action_button: gtk::Button,
|
||||
pub preview_binding: Rc<RefCell<Option<PreviewBinding>>>,
|
||||
pub title: String,
|
||||
@ -140,8 +149,9 @@ pub struct LauncherWidgets {
|
||||
pub mic_gain_value: gtk::Label,
|
||||
pub input_toggle_button: gtk::Button,
|
||||
pub clipboard_button: gtk::Button,
|
||||
pub probe_button: gtk::Button,
|
||||
pub usb_recover_button: gtk::Button,
|
||||
pub uac_recover_button: gtk::Button,
|
||||
pub uvc_recover_button: gtk::Button,
|
||||
pub device_refresh_button: gtk::Button,
|
||||
pub swap_key_button: gtk::Button,
|
||||
pub camera_test_button: gtk::Button,
|
||||
@ -199,3 +209,5 @@ const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400;
|
||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 315;
|
||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 560;
|
||||
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
|
||||
const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 63;
|
||||
const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;
|
||||
|
||||
@ -248,8 +248,11 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
|
||||
if let Some(binding) = pane.preview_binding.borrow().as_ref() {
|
||||
binding.set_enabled(matches!(surface, DisplaySurface::Preview));
|
||||
}
|
||||
pane.action_button
|
||||
.set_sensitive(pane.preview_binding.borrow().is_some());
|
||||
let preview_ready = pane.preview_binding.borrow().is_some();
|
||||
pane.action_button.set_sensitive(preview_ready);
|
||||
pane.clip_button.set_sensitive(preview_ready);
|
||||
pane.record_button.set_sensitive(preview_ready);
|
||||
pane.save_button.set_sensitive(true);
|
||||
match surface {
|
||||
DisplaySurface::Preview => {
|
||||
pane.stack.set_visible_child_name("preview");
|
||||
@ -262,7 +265,7 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
|
||||
}
|
||||
DisplaySurface::Window => {
|
||||
pane.stack.set_visible_child_name("placeholder");
|
||||
pane.action_button.set_label("Return To Preview");
|
||||
pane.action_button.set_label("Return");
|
||||
pane.stream_status.set_text("Streaming in its own window");
|
||||
pane.preview_placeholder.set_visible(false);
|
||||
}
|
||||
|
||||
@ -117,6 +117,63 @@ fn server_version_label(state: &LauncherState) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||
if !state.server_available {
|
||||
return (StatusLightState::Idle, "Offline".to_string());
|
||||
}
|
||||
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
|
||||
return (StatusLightState::Live, "Enumerated".to_string());
|
||||
}
|
||||
if let Some(output) = state.server_camera_output.as_deref() {
|
||||
return (StatusLightState::Warning, output.to_ascii_uppercase());
|
||||
}
|
||||
if state.server_camera.is_none() && state.server_microphone.is_none() {
|
||||
return (StatusLightState::Caution, "Unknown".to_string());
|
||||
}
|
||||
if state.server_camera == Some(false) && state.server_microphone == Some(false) {
|
||||
return (StatusLightState::Warning, "Missing".to_string());
|
||||
}
|
||||
(StatusLightState::Caution, "Partial".to_string())
|
||||
}
|
||||
|
||||
fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||
if !state.server_available {
|
||||
return (StatusLightState::Idle, "Offline".to_string());
|
||||
}
|
||||
match state.server_microphone {
|
||||
Some(true) => (StatusLightState::Live, "Ready".to_string()),
|
||||
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
|
||||
None => (StatusLightState::Caution, "Unknown".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) {
|
||||
if !state.server_available {
|
||||
return (StatusLightState::Idle, "Offline".to_string());
|
||||
}
|
||||
let codec = state
|
||||
.server_camera_codec
|
||||
.as_deref()
|
||||
.map(|value| value.to_ascii_uppercase())
|
||||
.unwrap_or_else(|| "READY".to_string());
|
||||
match state.server_camera {
|
||||
Some(true) => {
|
||||
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
|
||||
(StatusLightState::Live, codec)
|
||||
} else {
|
||||
let value = state
|
||||
.server_camera_output
|
||||
.as_deref()
|
||||
.map(|output| format!("{}/{}", output.to_ascii_uppercase(), codec))
|
||||
.unwrap_or(codec);
|
||||
(StatusLightState::Caution, value)
|
||||
}
|
||||
}
|
||||
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
|
||||
None => (StatusLightState::Caution, "Unknown".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
|
||||
if !power.available || !power.enabled {
|
||||
return StatusLightState::Idle;
|
||||
|
||||
@ -73,6 +73,16 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
.shortcut_value
|
||||
.set_text(&toggle_key_label(&state.swap_key));
|
||||
|
||||
let (usb_state, usb_value) = recovery_usb_health(state);
|
||||
set_status_light(&widgets.summary.usb_light, usb_state);
|
||||
widgets.summary.usb_value.set_text(&usb_value);
|
||||
let (uac_state, uac_value) = recovery_uac_health(state);
|
||||
set_status_light(&widgets.summary.uac_light, uac_state);
|
||||
widgets.summary.uac_value.set_text(&uac_value);
|
||||
let (uvc_state, uvc_value) = recovery_uvc_health(state);
|
||||
set_status_light(&widgets.summary.uvc_light, uvc_state);
|
||||
widgets.summary.uvc_value.set_text(&uvc_value);
|
||||
|
||||
widgets
|
||||
.power_detail
|
||||
.set_text(&capture_power_detail(&state.capture_power));
|
||||
@ -122,10 +132,15 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
"Start relay and previews."
|
||||
}));
|
||||
widgets.clipboard_button.set_sensitive(relay_live);
|
||||
widgets.probe_button.set_sensitive(true);
|
||||
widgets
|
||||
.usb_recover_button
|
||||
.set_sensitive(state.server_available);
|
||||
widgets
|
||||
.uac_recover_button
|
||||
.set_sensitive(state.server_available);
|
||||
widgets
|
||||
.uvc_recover_button
|
||||
.set_sensitive(state.server_available);
|
||||
widgets.device_refresh_button.set_sensitive(!relay_live);
|
||||
widgets
|
||||
.camera_combo
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.15.0"
|
||||
version = "0.15.2"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -89,13 +89,27 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
|
||||
assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");"));
|
||||
assert!(
|
||||
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
|
||||
< source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
|
||||
source_index("header.append(&title_label);")
|
||||
< source_index("header.append(&stream_status);")
|
||||
);
|
||||
assert!(
|
||||
source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
|
||||
source_index("header.append(&stream_status);")
|
||||
< source_index("header.append(&capture_label);")
|
||||
);
|
||||
assert!(
|
||||
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
|
||||
< source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
|
||||
);
|
||||
assert!(
|
||||
source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
|
||||
< source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);")
|
||||
);
|
||||
assert!(UI_LAYOUT_SRC.contains("let clip_button = gtk::Button::with_label(\"Clip\");"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let record_button = gtk::Button::with_label(\"Record\");"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"Save\");"));
|
||||
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&clip_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&record_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&save_button);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -197,13 +211,20 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() {
|
||||
#[test]
|
||||
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
|
||||
assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124);
|
||||
assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 63);
|
||||
assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("console_panel.set_vexpand(true);"));
|
||||
assert!(
|
||||
UI_LAYOUT_SRC.contains(
|
||||
"const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;"
|
||||
),
|
||||
"relay-control growth should reduce Diagnostics and Log minima through a shared split budget"
|
||||
);
|
||||
assert_eq!(
|
||||
UI_LAYOUT_SRC
|
||||
.matches(".min_content_height(SIDE_LOG_MIN_HEIGHT)")
|
||||
.matches(".min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
@ -260,12 +281,25 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
|
||||
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 recovery_heading = gtk::Label::new(Some(\"Recovery\"));"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let tools_heading = gtk::Label::new(Some(\"Tools\"));"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let probe_button = rail_button(\"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("let uac_recover_button = rail_button(\"Recover UAC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"Recover UVC\""));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
|
||||
assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);"));
|
||||
assert!(!UI_LAYOUT_SRC.contains("Gate Probe"));
|
||||
assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);"));
|
||||
assert!(
|
||||
source_index("relay_grid.attach(&server_entry, 0, 0, 2, 1);")
|
||||
|
||||
@ -166,9 +166,15 @@ 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.probe_button.connect_clicked"));
|
||||
assert!(!UI_SRC.contains("Quality probe command copied to the local clipboard."));
|
||||
assert!(UI_SRC.contains("pane.save_button.connect_clicked"));
|
||||
assert!(UI_SRC.contains("Choose Eye Capture Folder"));
|
||||
assert!(UI_SRC.contains("pane.clip_button.connect_clicked"));
|
||||
assert!(UI_SRC.contains("clip saved to"));
|
||||
assert!(UI_SRC.contains("record_button.connect_clicked"));
|
||||
assert!(UI_SRC.contains("recording saved to"));
|
||||
assert!(UI_SRC.contains("Recording {}... press Stop to finish."));
|
||||
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."));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user