fix(ui): restore compact launcher layout

This commit is contained in:
Brad Stein 2026-04-23 14:15:02 -03:00
parent 7fbba1314a
commit 09c877a204
17 changed files with 318 additions and 110 deletions

View File

@ -1,11 +1,13 @@
use super::*;
use crate::launcher::{
devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState,
devices::{CameraMode, DeviceCatalog},
preview::PreviewBinding,
state::LauncherState,
ui_components::build_launcher_view,
};
use gtk::prelude::*;
use serial_test::serial;
use std::{cell::RefCell, rc::Rc};
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
#[test]
fn local_test_detail_mentions_idle_and_running_modes() {
@ -59,19 +61,121 @@ fn launcher_shell_measures_inside_a_1080p_desktop_budget() {
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1920 && natural_width <= 1920,
min_width <= 1280 && natural_width <= 1280,
"launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
tall_min_width <= 1920 && tall_natural_width <= 1920,
tall_min_width <= 1280 && tall_natural_width <= 1280,
"launcher 1080p-tall width regressed: min={tall_min_width}, natural={tall_natural_width}"
);
assert!(
min_height <= 1080 && natural_height <= 1080,
min_height <= 860 && natural_height <= 1080,
"launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
#[gtk::test]
#[serial]
fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-layout-budget-populated")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let catalog = realistic_device_catalog();
let mut state = LauncherState::new();
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
let (min_width, natural_width, _, _) = view.window.measure(gtk::Orientation::Horizontal, -1);
let (tall_min_width, tall_natural_width, _, _) =
view.window.measure(gtk::Orientation::Horizontal, 1080);
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1280 && natural_width <= 1280,
"populated launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
tall_min_width <= 1280 && tall_natural_width <= 1280,
"populated launcher 1080p-tall width regressed: min={tall_min_width}, natural={tall_natural_width}"
);
assert!(
min_height <= 860 && natural_height <= 1080,
"populated launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
#[gtk::test]
#[serial]
fn populated_launcher_runtime_widgets_stay_compact() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-layout-widget-budget")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let catalog = realistic_device_catalog();
let mut state = LauncherState::new();
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
let (camera_min_w, camera_nat_w, _, _) = view
.device_stage
.camera_preview
.measure(gtk::Orientation::Horizontal, -1);
let (camera_min_h, camera_nat_h, _, _) = view
.device_stage
.camera_preview
.measure(gtk::Orientation::Vertical, 160);
let (testing_panel_min_h, testing_panel_nat_h, _, _) = view
.device_stage
._preview_panel
.measure(gtk::Orientation::Vertical, 320);
let (left_min_w, left_nat_w, _, _) = view.widgets.display_panes[0]
.root
.measure(gtk::Orientation::Horizontal, -1);
let (left_min_h, left_nat_h, _, _) = view.widgets.display_panes[0]
.root
.measure(gtk::Orientation::Vertical, 640);
let (server_min_w, server_nat_w, _, _) =
view.server_entry.measure(gtk::Orientation::Horizontal, -1);
assert!(
camera_min_w <= 160 && camera_nat_w <= 160,
"camera preview width regressed: min={camera_min_w}, natural={camera_nat_w}"
);
assert!(
camera_min_h <= 90 && camera_nat_h <= 90,
"camera preview height regressed: min={camera_min_h}, natural={camera_nat_h}"
);
assert!(
testing_panel_min_h <= 260 && testing_panel_nat_h <= 260,
"device testing panel height regressed: min={testing_panel_min_h}, natural={testing_panel_nat_h}"
);
assert!(
left_min_w <= 445 && left_nat_w <= 445,
"eye pane width regressed: min={left_min_w}, natural={left_nat_w}"
);
assert!(
left_min_h <= 520 && left_nat_h <= 520,
"eye pane height regressed: min={left_min_h}, natural={left_nat_h}"
);
assert!(
server_min_w <= 168 && server_nat_w <= 180,
"server entry width regressed: min={server_min_w}, natural={server_nat_w}"
);
}
#[test]
fn server_chip_state_tracks_connection_not_just_reachability() {
let mut state = LauncherState::new();
@ -292,6 +396,30 @@ fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() {
assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview);
}
fn realistic_device_catalog() -> DeviceCatalog {
DeviceCatalog {
cameras: vec!["usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string()],
camera_modes: [(
"usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(),
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30),
],
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
microphones: vec![
"alsa_input.usb-Focusrite_Scarlett_2i2_USB_Y7ABC12345-00.analog-stereo".to_string(),
],
speakers: vec![
"alsa_output.pci-0000_00_1f.3.analog-stereo".to_string(),
"bluez_output.80_C3_BA_76_26_AB.1".to_string(),
],
keyboards: vec!["usb-Corsair_K70_RGB_PRO_Mechanical_Gaming_Keyboard-event-kbd".to_string()],
mice: vec!["usb-Logitech_G502_X_LIGHTSPEED_Gaming_Mouse-event-mouse".to_string()],
}
}
#[gtk::test]
#[serial]
fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() {

View File

@ -37,6 +37,7 @@ use {
gtk::prelude::*,
lesavka_common::lesavka::CapturePowerCommand,
lesavka_common::process_metrics::ProcessCpuSampler,
serde_json::json,
std::cell::{Cell, RefCell},
std::collections::VecDeque,
std::process::Command,
@ -53,6 +54,10 @@ include!("ui/diagnostic_sampling.rs");
include!("ui/preview_profiles.rs");
#[cfg(not(coverage))]
include!("ui/activation_context.rs");
#[cfg(not(coverage))]
include!("ui/startup_window_guard.rs");
#[cfg(coverage)]
include!("ui/session_preview_coverage.rs");
#[cfg(not(coverage))]
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
@ -109,6 +114,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
app.connect_activate(move |app| {
let ActivationContext {
window,
launcher_size,
server_entry,
camera_combo,
camera_quality_combo,
@ -154,6 +160,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let _: () = include!("ui/runtime_poll.rs");
window.present();
schedule_launcher_window_guard(app, &window, launcher_size);
});
}
@ -166,15 +173,6 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
Ok(())
}
/// Keep the coverage stub aligned with the real preview activation rule.
#[cfg(coverage)]
fn session_preview_active(
state: &crate::launcher::state::LauncherState,
child_running: bool,
) -> bool {
(child_running || state.remote_active) && state.capture_power.mode != "forced-off"
}
#[cfg(all(test, not(coverage)))]
#[path = "tests/ui_preview_profiles.rs"]
mod tests;

View File

@ -1,6 +1,7 @@
#[cfg(not(coverage))]
struct ActivationContext {
window: gtk::ApplicationWindow,
launcher_size: (i32, i32),
server_entry: gtk::Entry,
camera_combo: gtk::ComboBoxText,
camera_quality_combo: gtk::ComboBoxText,

View File

@ -132,6 +132,7 @@
ActivationContext {
window,
launcher_size: (launcher_width, launcher_height),
server_entry,
camera_combo,
camera_quality_combo,

View File

@ -0,0 +1,7 @@
/// Keep the coverage stub aligned with the real preview activation rule.
fn session_preview_active(
state: &crate::launcher::state::LauncherState,
child_running: bool,
) -> bool {
(child_running || state.remote_active) && state.capture_power.mode != "forced-off"
}

View File

@ -0,0 +1,53 @@
/// Re-applies the compact launcher size after `present()` so persisted window-manager
/// geometry cannot silently reopen the launcher too large for a 1080p desktop.
fn schedule_launcher_window_guard(
app: &gtk::Application,
window: &gtk::ApplicationWindow,
launcher_size: (i32, i32),
) {
let guard_window = window.clone();
glib::timeout_add_local_once(Duration::from_millis(120), move || {
if guard_window.is_maximized() {
guard_window.unmaximize();
}
guard_window.set_default_size(launcher_size.0, launcher_size.1);
guard_window.queue_allocate();
});
let Ok(path) = std::env::var("LESAVKA_LAUNCHER_MEASURE_PATH") else {
return;
};
let measure_window = window.clone();
let app = app.clone();
glib::timeout_add_local_once(Duration::from_millis(320), move || {
write_launcher_measurement(&measure_window, launcher_size, &path);
if std::env::var("LESAVKA_LAUNCHER_MEASURE_EXIT").ok().as_deref() == Some("1") {
app.quit();
}
});
}
/// Emits a one-shot launcher size snapshot for local verification runs.
fn write_launcher_measurement(
window: &gtk::ApplicationWindow,
launcher_size: (i32, i32),
path: &str,
) {
let (min_width, natural_width, _, _) = window.measure(gtk::Orientation::Horizontal, -1);
let (min_height, natural_height, _, _) =
window.measure(gtk::Orientation::Vertical, launcher_size.0);
let payload = json!({
"requested_width": launcher_size.0,
"requested_height": launcher_size.1,
"window_width": window.width(),
"window_height": window.height(),
"min_width": min_width,
"natural_width": natural_width,
"min_height": min_height,
"natural_height": natural_height,
});
let _ = std::fs::write(
path,
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()),
);
}

View File

@ -61,7 +61,7 @@ pub fn build_launcher_view(
mic_gain_value,
audio_check_detail,
audio_check_meter,
device_body_height_group,
preview_panel,
camera_preview,
camera_status,
camera_test_button,

View File

@ -148,7 +148,6 @@
console_popout_button: console_popout_button.clone(),
console_level_combo: console_level_combo.clone(),
session_log_level: session_log_level.clone(),
_device_body_height_group: device_body_height_group,
};
let popouts = Rc::new(RefCell::new([None, None]));
let diagnostics_popout = Rc::new(RefCell::new(None));
@ -168,6 +167,7 @@
keyboard_combo,
mouse_combo,
device_stage: DeviceStageWidgets {
_preview_panel: preview_panel,
camera_preview,
camera_status,
},

View File

@ -31,7 +31,7 @@ struct DeviceControlsContext {
mic_gain_value: gtk::Label,
audio_check_detail: gtk::Label,
audio_check_meter: gtk::ProgressBar,
device_body_height_group: gtk::SizeGroup,
preview_panel: gtk::Box,
camera_preview: gtk::Picture,
camera_status: gtk::Label,
camera_test_button: gtk::Button,

View File

@ -185,24 +185,21 @@
staging_row.append(&devices_panel);
let (preview_panel, preview_body) = build_panel("Device Testing");
preview_panel.set_hexpand(true);
preview_panel.set_hexpand(false);
preview_panel.set_vexpand(false);
preview_panel.set_valign(gtk::Align::Fill);
preview_body.set_vexpand(false);
preview_body.set_spacing(8);
let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
testing_row.set_hexpand(true);
testing_row.set_vexpand(true);
testing_row.set_valign(gtk::Align::Fill);
let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);
device_body_height_group.add_widget(&devices_body);
device_body_height_group.add_widget(&testing_row);
testing_row.set_hexpand(false);
testing_row.set_vexpand(false);
testing_row.set_valign(gtk::Align::Start);
let camera_preview = gtk::Picture::new();
camera_preview.set_can_shrink(false);
camera_preview.set_hexpand(true);
camera_preview.set_vexpand(true);
camera_preview.set_can_shrink(true);
camera_preview.set_hexpand(false);
camera_preview.set_vexpand(false);
camera_preview.set_halign(gtk::Align::Fill);
camera_preview.set_valign(gtk::Align::Fill);
camera_preview.set_valign(gtk::Align::Start);
camera_preview.set_size_request(
CAMERA_PREVIEW_VIEWPORT_WIDTH,
CAMERA_PREVIEW_VIEWPORT_HEIGHT,
@ -216,19 +213,19 @@
camera_status.set_xalign(0.0);
camera_status.set_visible(false);
let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
camera_preview_shell.set_hexpand(true);
camera_preview_shell.set_vexpand(true);
camera_preview_shell.set_hexpand(false);
camera_preview_shell.set_vexpand(false);
camera_preview_shell.set_halign(gtk::Align::Fill);
camera_preview_shell.set_valign(gtk::Align::Fill);
camera_preview_shell.set_valign(gtk::Align::Start);
camera_preview_shell.set_size_request(
CAMERA_PREVIEW_VIEWPORT_WIDTH,
CAMERA_PREVIEW_VIEWPORT_HEIGHT,
);
let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
camera_preview_frame.set_hexpand(true);
camera_preview_frame.set_vexpand(true);
camera_preview_frame.set_hexpand(false);
camera_preview_frame.set_vexpand(false);
camera_preview_frame.set_halign(gtk::Align::Fill);
camera_preview_frame.set_valign(gtk::Align::Fill);
camera_preview_frame.set_valign(gtk::Align::Start);
camera_preview_frame.set_size_request(
CAMERA_PREVIEW_VIEWPORT_WIDTH,
CAMERA_PREVIEW_VIEWPORT_HEIGHT,
@ -236,27 +233,27 @@
camera_preview_frame.set_child(Some(&camera_preview));
camera_preview_shell.append(&camera_preview_frame);
let webcam_group = build_subgroup("Webcam Preview");
webcam_group.set_hexpand(true);
webcam_group.set_vexpand(true);
webcam_group.set_valign(gtk::Align::Fill);
webcam_group.set_hexpand(false);
webcam_group.set_vexpand(false);
webcam_group.set_valign(gtk::Align::Start);
webcam_group.append(&camera_preview_shell);
testing_row.append(&webcam_group);
let playback_group = build_subgroup("Mic Playback");
playback_group.set_hexpand(false);
playback_group.set_vexpand(true);
playback_group.set_vexpand(false);
playback_group.set_valign(gtk::Align::Fill);
playback_group.set_size_request(72, -1);
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6);
playback_body.set_halign(gtk::Align::Center);
playback_body.set_vexpand(true);
playback_body.set_vexpand(false);
playback_body.set_valign(gtk::Align::Fill);
let microphone_replay_button = gtk::Button::with_label("Replay");
stabilize_button(&microphone_replay_button, 70);
audio_check_meter.set_orientation(gtk::Orientation::Vertical);
audio_check_meter.set_inverted(true);
audio_check_meter.set_hexpand(false);
audio_check_meter.set_vexpand(true);
audio_check_meter.set_vexpand(false);
audio_check_meter.set_halign(gtk::Align::Center);
audio_check_meter.set_size_request(20, 0);
audio_check_meter.set_show_text(false);
@ -285,7 +282,7 @@
mic_gain_value,
audio_check_detail,
audio_check_meter,
device_body_height_group,
preview_panel,
camera_preview,
camera_status,
camera_test_button,

View File

@ -3,12 +3,12 @@
let server_entry = gtk::Entry::new();
server_entry.add_css_class("server-entry");
server_entry.set_hexpand(true);
server_entry.set_width_chars(18);
server_entry.set_width_chars(14);
server_entry.set_text(server_addr);
server_entry.set_tooltip_text(Some("Relay host address."));
let relay_grid = gtk::Grid::new();
relay_grid.set_column_homogeneous(true);
relay_grid.set_column_spacing(8);
relay_grid.set_column_spacing(6);
relay_grid.set_hexpand(true);
relay_grid.set_row_spacing(8);
relay_grid.attach(&server_entry, 0, 0, 2, 1);
@ -17,8 +17,8 @@
start_button.add_css_class("suggested-action");
relay_grid.attach(&start_button, 2, 0, 1, 1);
let clipboard_button = rail_button("Send Clipboard", "Type clipboard remotely.");
let probe_button = rail_button("Copy Gate Probe", "Copy quality probe.");
let 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);
@ -104,10 +104,12 @@
diagnostics_label.set_selectable(true);
diagnostics_label.set_xalign(0.0);
diagnostics_label.set_yalign(0.0);
diagnostics_label.set_wrap(false);
diagnostics_label.set_wrap(true);
diagnostics_label.set_wrap_mode(pango::WrapMode::WordChar);
diagnostics_label.set_halign(gtk::Align::Start);
diagnostics_label.set_valign(gtk::Align::Start);
diagnostics_label.set_hexpand(true);
diagnostics_label.set_width_chars(1);
let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
diagnostics_shell.set_hexpand(true);
diagnostics_shell.set_vexpand(false);
@ -118,6 +120,7 @@
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.child(&diagnostics_shell)
.build();
diagnostics_scroll.set_propagate_natural_width(false);
diagnostics_body.append(&diagnostics_toolbar);
diagnostics_body.append(&diagnostics_scroll);
operations.append(&diagnostics_panel);
@ -173,6 +176,7 @@
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.child(&session_log_view)
.build();
log_scroll.set_propagate_natural_width(false);
console_body.append(&console_toolbar);
console_body.append(&log_scroll);
operations.append(&console_panel);

View File

@ -9,12 +9,12 @@
install_css(&window);
install_window_icon(&window);
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
root.add_css_class("launcher-root");
root.set_margin_start(10);
root.set_margin_end(10);
root.set_margin_top(10);
root.set_margin_bottom(10);
root.set_margin_start(7);
root.set_margin_end(7);
root.set_margin_top(8);
root.set_margin_bottom(8);
let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8);
hero.set_hexpand(true);
@ -57,24 +57,24 @@
hero.append(&chips);
root.append(&hero);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 5);
content.set_hexpand(true);
content.set_vexpand(true);
root.append(&content);
let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8);
let workspace = gtk::Box::new(gtk::Orientation::Vertical, 6);
workspace.set_hexpand(true);
workspace.set_vexpand(true);
content.append(&workspace);
let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
let operations = gtk::Box::new(gtk::Orientation::Vertical, 6);
operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
operations.set_hexpand(false);
operations.set_vexpand(true);
operations.set_valign(gtk::Align::Fill);
content.append(&operations);
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
display_row.set_hexpand(true);
display_row.set_vexpand(false);
display_row.set_valign(gtk::Align::Start);
@ -85,11 +85,11 @@
display_row.append(&right_pane.root);
workspace.append(&display_row);
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
staging_row.set_hexpand(true);
staging_row.set_vexpand(false);
staging_row.set_valign(gtk::Align::Start);
staging_row.set_homogeneous(true);
staging_row.set_homogeneous(false);
workspace.append(&staging_row);
LauncherShellContext {

View File

@ -128,7 +128,7 @@ fn build_inline_selector_row(label: &str, combo: &gtk::ComboBoxText) -> gtk::Box
let block = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let label_widget = gtk::Label::new(Some(label));
label_widget.set_halign(gtk::Align::Start);
label_widget.set_width_chars(9);
label_widget.set_width_chars(7);
label_widget.set_xalign(0.0);
combo.set_hexpand(true);
combo.set_size_request(0, -1);
@ -157,9 +157,9 @@ fn append_input_choice(combo: &gtk::ComboBoxText, value: &str) {
let short = value.rsplit('/').next().unwrap_or(value);
let label = Device::open(value)
.ok()
.and_then(|device| device.name().map(|name| format!("{name}{short}")))
.and_then(|device| device.name().map(ToString::to_string))
.unwrap_or_else(|| short.to_string());
combo.append(Some(value), &label);
combo.append(Some(value), &shorten_input_label(&label));
}
fn append_stage_choice(combo: &gtk::ComboBoxText, value: &str) {
@ -243,9 +243,9 @@ fn human_audio_node_label(value: &str) -> String {
.replace(['-', '_'], " ");
if compact.starts_with("pci ") || compact.starts_with("pci-") {
if compact.contains("analog stereo") {
"Built-in analog stereo".to_string()
"Analog stereo".to_string()
} else {
"Built-in audio".to_string()
"System audio".to_string()
}
} else {
compact
@ -253,10 +253,17 @@ fn human_audio_node_label(value: &str) -> String {
}
fn shorten_label(value: &str) -> String {
const MAX: usize = 44;
shorten_label_with_limit(value, 20)
}
fn shorten_input_label(value: &str) -> String {
shorten_label_with_limit(value, 22)
}
fn shorten_label_with_limit(value: &str, max: usize) -> String {
let compact = value.replace('_', " ");
let mut chars = compact.chars();
let preview: String = chars.by_ref().take(MAX).collect();
let preview: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
format!("{preview}")
} else {

View File

@ -1,5 +1,5 @@
const RAIL_BUTTON_WIDTH: i32 = 92;
const RAIL_BUTTON_LABEL_CHARS: i32 = 14;
const RAIL_BUTTON_WIDTH: i32 = 86;
const RAIL_BUTTON_LABEL_CHARS: i32 = 11;
/// Build a rail button that can shrink without forcing the operations column wider.
fn rail_button(label: &str, tooltip: &str) -> gtk::Button {

View File

@ -2,7 +2,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
root.add_css_class("display-card");
root.set_hexpand(true);
root.set_vexpand(true);
root.set_vexpand(false);
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.set_hexpand(true);
@ -20,18 +20,18 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let picture = gtk::Picture::new();
picture.set_hexpand(true);
picture.set_vexpand(true);
picture.set_vexpand(false);
picture.set_halign(gtk::Align::Fill);
picture.set_valign(gtk::Align::Fill);
picture.set_valign(gtk::Align::Start);
picture.set_can_shrink(true);
picture.set_keep_aspect_ratio(true);
picture.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
preview_box.set_hexpand(true);
preview_box.set_vexpand(true);
preview_box.set_vexpand(false);
preview_box.set_halign(gtk::Align::Fill);
preview_box.set_valign(gtk::Align::Fill);
preview_box.set_valign(gtk::Align::Start);
preview_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
let preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
preview_frame.set_hexpand(false);
@ -53,13 +53,13 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6);
placeholder_box.add_css_class("display-placeholder");
placeholder_box.set_hexpand(true);
placeholder_box.set_vexpand(true);
placeholder_box.set_vexpand(false);
placeholder_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
placeholder_box.append(&placeholder);
let stack = gtk::Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.set_vexpand(false);
stack.add_named(&preview_box, Some("preview"));
stack.add_named(&placeholder_box, Some("placeholder"));
stack.set_visible_child_name("preview");
@ -81,7 +81,7 @@ 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, 104);
stabilize_button(&action_button, 96);
action_button.set_halign(gtk::Align::End);
let stream_status = gtk::Label::new(Some("Preview pending"));
stream_status.add_css_class("status-line");
@ -91,17 +91,18 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stream_status.set_hexpand(true);
stream_status.set_ellipsize(pango::EllipsizeMode::End);
stream_status.set_single_line_mode(true);
stream_status.set_width_chars(12);
stream_status.set_max_width_chars(18);
stream_status.set_width_chars(10);
stream_status.set_max_width_chars(16);
stream_status.set_tooltip_text(Some("Eye stream status."));
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
footer_shell.set_vexpand(false);
let controls_grid = gtk::Grid::new();
controls_grid.set_column_spacing(8);
controls_grid.set_row_spacing(8);
controls_grid.set_hexpand(true);
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7);
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7);
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
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);
feed_row.set_hexpand(true);
capture_row.set_hexpand(true);
breakout_row.set_hexpand(true);

View File

@ -153,11 +153,11 @@ pub struct LauncherWidgets {
pub console_popout_button: gtk::Button,
pub console_level_combo: gtk::ComboBoxText,
pub session_log_level: Rc<RefCell<ConsoleLogLevel>>,
pub _device_body_height_group: gtk::SizeGroup,
}
#[derive(Clone)]
pub struct DeviceStageWidgets {
pub _preview_panel: gtk::Box,
pub camera_preview: gtk::Picture,
pub camera_status: gtk::Label,
}
@ -183,9 +183,9 @@ pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
const LAUNCHER_DEFAULT_WIDTH: i32 = 1280;
const LAUNCHER_DEFAULT_HEIGHT: i32 = 780;
const OPERATIONS_RAIL_WIDTH: i32 = 304;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 146;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
const OPERATIONS_RAIL_WIDTH: i32 = 288;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 90;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 160;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 203;
const EYE_PREVIEW_MIN_WIDTH: i32 = 360;
const SIDE_LOG_MIN_HEIGHT: i32 = 124;

View File

@ -7,6 +7,8 @@
//! regressions can hide diagnostics or make eye/device previews unusable.
const UI_LAYOUT_SRC: &str = concat!(
include_str!("../../client/src/launcher/ui.rs"),
include_str!("../../client/src/launcher/ui/startup_window_guard.rs"),
include_str!("../../client/src/launcher/ui_components/types.rs"),
include_str!("../../client/src/launcher/ui_components/build_shell.rs"),
include_str!("../../client/src/launcher/ui/preview_profiles.rs"),
@ -52,22 +54,22 @@ fn launcher_default_size_stays_inside_1080p() {
.contains("window.set_size_request(LAUNCHER_DEFAULT_WIDTH, LAUNCHER_DEFAULT_HEIGHT);"),
"the top-level window should not pin a larger minimum than the startup budget"
);
assert!(
UI_LAYOUT_SRC.contains("let max_width = width.saturating_sub(72).max(640) as i32;")
);
assert!(
UI_LAYOUT_SRC.contains("let max_height = height.saturating_sub(120).max(520) as i32;")
);
assert!(UI_LAYOUT_SRC.contains("let max_width = width.saturating_sub(72).max(640) as i32;"));
assert!(UI_LAYOUT_SRC.contains("let max_height = height.saturating_sub(120).max(520) as i32;"));
assert!(
UI_LAYOUT_SRC.contains("(1280.min(max_width), 780.min(max_height))"),
"the activation path must use the same compact startup budget as the shell"
);
assert!(UI_LAYOUT_SRC.contains("schedule_launcher_window_guard(app, &window, launcher_size);"));
assert!(
UI_LAYOUT_SRC.contains("guard_window.set_default_size(launcher_size.0, launcher_size.1);")
);
}
#[test]
fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() {
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460);
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 258);
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 360);
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 203);
assert!(UI_LAYOUT_SRC.contains("display_row.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("display_row.set_valign(gtk::Align::Start);"));
assert!(
@ -96,6 +98,8 @@ 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("fn shorten_input_label("));
assert!(UI_LAYOUT_SRC.contains("shorten_label_with_limit(value, 22)"));
assert!(
!UI_LAYOUT_SRC.contains("@ {} fps (Device H.264)"),
"long capture labels force a huge GTK combo natural width"
@ -113,27 +117,33 @@ fn eye_and_device_combos_use_compact_labels_so_the_rail_stays_visible() {
}
#[test]
fn device_staging_and_testing_bottoms_stay_locked_together() {
assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(true);"));
fn device_staging_and_testing_stay_independent_so_preview_does_not_fill_dead_height() {
assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(false);"));
assert!(UI_LAYOUT_SRC.contains("staging_row.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_LAYOUT_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_LAYOUT_SRC.contains(
"let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);"
));
assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&devices_body);"));
assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&testing_row);"));
assert!(UI_LAYOUT_SRC.contains("preview_panel.set_hexpand(false);"));
assert!(
!UI_LAYOUT_SRC.contains("gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical)"),
"the webcam testing column must not inherit the full height of the staging controls"
);
}
#[test]
fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() {
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 280);
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 146);
assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);"));
fn device_testing_keeps_webcam_and_mic_playback_compact() {
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 160);
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 90);
assert!(UI_LAYOUT_SRC.contains("testing_row.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("testing_row.set_valign(gtk::Align::Start);"));
assert!(UI_LAYOUT_SRC.contains("webcam_group.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Start);"));
assert!(UI_LAYOUT_SRC.contains("camera_preview.set_can_shrink(true);"));
assert!(UI_LAYOUT_SRC.contains("camera_preview.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("playback_group.set_valign(gtk::Align::Fill);"));
assert!(UI_LAYOUT_SRC.contains("playback_group.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("preview_body.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("playback_body.set_valign(gtk::Align::Fill);"));
assert!(UI_LAYOUT_SRC.contains("audio_check_meter.set_vexpand(true);"));
assert!(UI_LAYOUT_SRC.contains("audio_check_meter.set_vexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("playback_body.append(&audio_check_meter);"));
assert!(UI_LAYOUT_SRC.contains("playback_body.append(&microphone_replay_button);"));
}
@ -195,17 +205,17 @@ fn status_chip_text_is_centered_inside_each_pill() {
#[test]
fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")"));
assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 304);
assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 92);
assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 14);
assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 288);
assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86);
assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11);
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 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);"));
@ -246,6 +256,7 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
);
assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&speaker_combo);"));
assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&audio_gain_scale);"));
assert!(UI_LAYOUT_SRC.contains("label_widget.set_width_chars(7);"));
assert!(
UI_LAYOUT_SRC
.contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);")