fix(ui): polish launcher previews and popouts

This commit is contained in:
Brad Stein 2026-04-23 15:18:30 -03:00
parent 09c877a204
commit 0325f32d58
14 changed files with 221 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -2,12 +2,27 @@ use super::*;
use crate::launcher::{
devices::{CameraMode, DeviceCatalog},
preview::PreviewBinding,
state::LauncherState,
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize},
ui_components::build_launcher_view,
};
use gtk::prelude::*;
use serial_test::serial;
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
use std::{
cell::RefCell,
collections::BTreeMap,
rc::Rc,
time::{Duration, Instant},
};
fn present_and_settle(window: &gtk::ApplicationWindow) {
window.set_default_size(1280, 780);
window.present();
let deadline = Instant::now() + Duration::from_millis(450);
while Instant::now() < deadline {
while glib::MainContext::default().iteration(false) {}
std::thread::sleep(Duration::from_millis(15));
}
}
#[test]
fn local_test_detail_mentions_idle_and_running_modes() {
@ -54,22 +69,17 @@ fn launcher_shell_measures_inside_a_1080p_desktop_budget() {
&DeviceCatalog::default(),
&state,
);
present_and_settle(&view.window);
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,
min_width <= 1280 && view.window.width() <= 1280,
"launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
tall_min_width <= 1280 && tall_natural_width <= 1280,
"launcher 1080p-tall width regressed: min={tall_min_width}, natural={tall_natural_width}"
);
assert!(
min_height <= 860 && natural_height <= 1080,
min_height <= 900 && view.window.height() <= 900 && natural_height <= 1080,
"launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
@ -91,22 +101,17 @@ fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() {
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
present_and_settle(&view.window);
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,
min_width <= 1280 && view.window.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,
min_height <= 900 && view.window.height() <= 900 && natural_height <= 1080,
"populated launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
@ -128,6 +133,7 @@ fn populated_launcher_runtime_widgets_stay_compact() {
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
present_and_settle(&view.window);
let (camera_min_w, camera_nat_w, _, _) = view
.device_stage
@ -139,7 +145,7 @@ fn populated_launcher_runtime_widgets_stay_compact() {
.measure(gtk::Orientation::Vertical, 160);
let (testing_panel_min_h, testing_panel_nat_h, _, _) = view
.device_stage
._preview_panel
.preview_panel
.measure(gtk::Orientation::Vertical, 320);
let (left_min_w, left_nat_w, _, _) = view.widgets.display_panes[0]
.root
@ -163,7 +169,7 @@ fn populated_launcher_runtime_widgets_stay_compact() {
"device testing panel height regressed: min={testing_panel_min_h}, natural={testing_panel_nat_h}"
);
assert!(
left_min_w <= 445 && left_nat_w <= 445,
left_min_w <= 445 && left_nat_w <= 470,
"eye pane width regressed: min={left_min_w}, natural={left_nat_w}"
);
assert!(
@ -174,6 +180,75 @@ fn populated_launcher_runtime_widgets_stay_compact() {
server_min_w <= 168 && server_nat_w <= 180,
"server entry width regressed: min={server_min_w}, natural={server_nat_w}"
);
assert!(
(view.device_stage.devices_panel.height() - view.device_stage.preview_panel.height()).abs()
<= 2,
"device staging and upstream media heights diverged: staging={}, upstream={}",
view.device_stage.devices_panel.height(),
view.device_stage.preview_panel.height()
);
assert!(
view.widgets.display_panes[0].preview_frame.height()
>= view.device_stage.camera_preview_frame.height(),
"eye preview should stay at least as tall as the webcam preview: eye={}, webcam={}",
view.widgets.display_panes[0].preview_frame.height(),
view.device_stage.camera_preview_frame.height()
);
}
#[gtk::test]
#[serial]
fn breakout_size_changes_resize_the_open_popout_window() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-breakout-resize")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let window = gtk::ApplicationWindow::builder()
.application(&app)
.default_width(540)
.default_height(304)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
let picture = gtk::Picture::new();
frame.set_child(Some(&picture));
root.append(&frame);
window.set_child(Some(&root));
let handle = PopoutWindowHandle {
window,
root,
frame: frame.clone(),
picture: picture.clone(),
status_label: gtk::Label::new(None),
binding: PreviewBinding::test_stub(),
};
apply_popout_window_size(
&handle,
BreakoutSizeChoice {
preset: BreakoutSizePreset::P720,
width: 1280,
height: 720,
},
PreviewSourceSize {
width: 1920,
height: 1080,
fps: 60,
},
);
assert_eq!(handle.root.width_request(), 1280);
assert_eq!(handle.root.height_request(), 720);
assert_eq!(handle.frame.width_request(), 1280);
assert_eq!(handle.frame.height_request(), 720);
assert_eq!(handle.picture.width_request(), 1280);
assert_eq!(handle.picture.height_request(), 720);
}
#[test]
@ -318,6 +393,7 @@ fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
.application(&app)
.title("Left")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
@ -328,6 +404,7 @@ fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
.application(&app)
.title("Right")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
@ -383,6 +460,7 @@ fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() {
let mut slot = popouts.borrow_mut();
slot[0] = Some(PopoutWindowHandle {
window,
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
@ -455,6 +533,7 @@ fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() {
.application(&app)
.title("Left")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),

View File

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

View File

@ -26,7 +26,9 @@
};
} else {
left_pane.stream_status.set_text("Preview unavailable");
left_pane.preview_placeholder.set_visible(true);
right_pane.stream_status.set_text("Preview unavailable");
right_pane.preview_placeholder.set_visible(true);
}
sync_feed_source_combo(
&left_pane.feed_source_combo,
@ -167,7 +169,9 @@
keyboard_combo,
mouse_combo,
device_stage: DeviceStageWidgets {
_preview_panel: preview_panel,
devices_panel,
preview_panel,
camera_preview_frame,
camera_preview,
camera_status,
},

View File

@ -31,7 +31,9 @@ struct DeviceControlsContext {
mic_gain_value: gtk::Label,
audio_check_detail: gtk::Label,
audio_check_meter: gtk::ProgressBar,
devices_panel: gtk::Box,
preview_panel: gtk::Box,
camera_preview_frame: gtk::AspectFrame,
camera_preview: gtk::Picture,
camera_status: gtk::Label,
camera_test_button: gtk::Button,

View File

@ -184,14 +184,14 @@
devices_body.append(&media_group);
staging_row.append(&devices_panel);
let (preview_panel, preview_body) = build_panel("Device Testing");
let (preview_panel, preview_body) = build_panel("Upstream Media");
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(false);
testing_row.set_hexpand(true);
testing_row.set_vexpand(false);
testing_row.set_valign(gtk::Align::Start);
let camera_preview = gtk::Picture::new();
@ -282,7 +282,9 @@
mic_gain_value,
audio_check_detail,
audio_check_meter,
devices_panel,
preview_panel,
camera_preview_frame,
camera_preview,
camera_status,
camera_test_button,

View File

@ -27,12 +27,6 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
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(false);
preview_box.set_halign(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);
preview_frame.set_vexpand(false);
@ -40,22 +34,62 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
preview_frame.set_valign(gtk::Align::Center);
preview_frame.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
preview_frame.set_child(Some(&picture));
preview_box.append(&preview_frame);
let placeholder = gtk::Label::new(Some(
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
));
placeholder.set_wrap(true);
placeholder.set_justify(gtk::Justification::Center);
placeholder.set_halign(gtk::Align::Center);
placeholder.set_valign(gtk::Align::Center);
let preview_placeholder = eye_placeholder_picture(title, false);
preview_placeholder.add_css_class("display-placeholder-art");
preview_placeholder.set_hexpand(true);
preview_placeholder.set_vexpand(true);
preview_placeholder.set_halign(gtk::Align::Center);
preview_placeholder.set_valign(gtk::Align::Center);
preview_placeholder.set_can_shrink(true);
preview_placeholder.set_keep_aspect_ratio(true);
preview_placeholder.set_sensitive(false);
preview_placeholder.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6);
let preview_overlay = gtk::Overlay::new();
preview_overlay.set_hexpand(true);
preview_overlay.set_vexpand(false);
preview_overlay.set_halign(gtk::Align::Fill);
preview_overlay.set_valign(gtk::Align::Start);
preview_overlay.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
preview_overlay.set_child(Some(&preview_frame));
preview_overlay.add_overlay(&preview_placeholder);
preview_overlay.set_clip_overlay(&preview_placeholder, true);
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
preview_box.set_hexpand(true);
preview_box.set_vexpand(false);
preview_box.set_halign(gtk::Align::Fill);
preview_box.set_valign(gtk::Align::Start);
preview_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
preview_box.append(&preview_overlay);
let window_placeholder = eye_placeholder_picture(title, true);
window_placeholder.add_css_class("display-placeholder-art");
window_placeholder.set_hexpand(true);
window_placeholder.set_vexpand(true);
window_placeholder.set_halign(gtk::Align::Center);
window_placeholder.set_valign(gtk::Align::Center);
window_placeholder.set_can_shrink(true);
window_placeholder.set_keep_aspect_ratio(true);
window_placeholder.set_sensitive(false);
window_placeholder.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
placeholder_box.add_css_class("display-placeholder");
placeholder_box.set_hexpand(true);
placeholder_box.set_vexpand(false);
placeholder_box.set_valign(gtk::Align::Start);
placeholder_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT);
placeholder_box.append(&placeholder);
placeholder_box.append(&window_placeholder);
preview_placeholder.set_visible(picture.paintable().is_none());
{
let preview_placeholder = preview_placeholder.clone();
picture.connect_paintable_notify(move |picture| {
preview_placeholder.set_visible(picture.paintable().is_none());
});
}
let stack = gtk::Stack::new();
stack.set_hexpand(true);
@ -70,19 +104,23 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
feed_source_combo.set_tooltip_text(Some("Eye source for this pane."));
feed_source_combo.set_hexpand(true);
feed_source_combo.set_size_request(0, -1);
let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.add_css_class("compact-combo");
capture_resolution_combo.set_tooltip_text(Some("Eye capture mode."));
capture_resolution_combo.set_size_request(0, -1);
capture_resolution_combo.set_hexpand(true);
let breakout_combo = gtk::ComboBoxText::new();
breakout_combo.add_css_class("compact-combo");
breakout_combo.set_tooltip_text(Some("Breakout window size."));
breakout_combo.set_size_request(0, -1);
breakout_combo.set_hexpand(true);
let action_button = gtk::Button::with_label("Break Out");
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");
stream_status.add_css_class("eye-inline-status");
@ -94,12 +132,14 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
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, 6);
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 6);
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 6);
@ -120,7 +160,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
preview_frame,
picture,
stream_status,
placeholder,
preview_placeholder,
feed_source_combo,
capture_resolution_combo,
breakout_combo,
@ -129,3 +169,20 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
title: title.to_string(),
}
}
fn eye_placeholder_picture(title: &str, is_open: bool) -> gtk::Picture {
let side = if title.contains("Left") { "left" } else { "right" };
let state = if is_open { "open" } else { "closed" };
let path = format!(
"{}/assets/placeholders/eye_{}_{}.png",
env!("CARGO_MANIFEST_DIR"),
state,
side,
);
let picture = gtk::Picture::new();
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
picture.set_paintable(Some(&texture));
}
picture
}

View File

@ -82,7 +82,10 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.18);
border-radius: 16px;
padding: 24px;
padding: 12px;
}
picture.display-placeholder-art {
opacity: 0.78;
}
picture.camera-preview-frame {
background: rgba(0, 0, 0, 0.28);

View File

@ -16,7 +16,7 @@ pub struct DisplayPaneWidgets {
pub preview_frame: gtk::AspectFrame,
pub picture: gtk::Picture,
pub stream_status: gtk::Label,
pub placeholder: gtk::Label,
pub preview_placeholder: gtk::Picture,
pub feed_source_combo: gtk::ComboBoxText,
pub capture_resolution_combo: gtk::ComboBoxText,
pub breakout_combo: gtk::ComboBoxText,
@ -27,6 +27,7 @@ pub struct DisplayPaneWidgets {
pub struct PopoutWindowHandle {
pub window: gtk::ApplicationWindow,
pub root: gtk::Box,
pub frame: gtk::AspectFrame,
pub picture: gtk::Picture,
pub status_label: gtk::Label,
@ -155,9 +156,13 @@ pub struct LauncherWidgets {
pub session_log_level: Rc<RefCell<ConsoleLogLevel>>,
}
/// Runtime handles the GTK layout contract tests read directly.
#[derive(Clone)]
#[allow(dead_code)]
pub struct DeviceStageWidgets {
pub _preview_panel: gtk::Box,
pub devices_panel: gtk::Box,
pub preview_panel: gtk::Box,
pub camera_preview_frame: gtk::AspectFrame,
pub camera_preview: gtk::Picture,
pub camera_status: gtk::Label,
}

View File

@ -117,6 +117,7 @@ pub fn open_popout_window(
let mut popouts = popouts.borrow_mut();
popouts[monitor_id] = Some(PopoutWindowHandle {
window: window.clone(),
root: root.clone(),
frame: frame.clone(),
picture: picture.clone(),
status_label: stream_status.clone(),
@ -141,18 +142,18 @@ pub fn apply_popout_window_size(
size: BreakoutSizeChoice,
display_limit: super::state::PreviewSourceSize,
) {
let Some(root) = handle
.picture
.parent()
.and_then(|widget| widget.downcast::<gtk::Box>().ok())
else {
return;
};
apply_popout_window_geometry(&handle.window, &root, &handle.picture, size, display_limit);
handle.frame.set_size_request(size.width, size.height);
apply_popout_window_geometry(
&handle.window,
&handle.root,
&handle.picture,
size,
display_limit,
);
handle.window.present();
schedule_popout_window_geometry(
handle.window.clone(),
root.clone(),
handle.root.clone(),
handle.picture.clone(),
size,
display_limit,
@ -242,21 +243,16 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
DisplaySurface::Preview => {
pane.stack.set_visible_child_name("preview");
pane.action_button.set_label("Break Out");
pane.placeholder.set_text(
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
);
if pane.preview_binding.borrow().is_none() {
pane.stream_status.set_text("Preview unavailable");
pane.preview_placeholder.set_visible(true);
}
}
DisplaySurface::Window => {
pane.stack.set_visible_child_name("placeholder");
pane.action_button.set_label("Return To Preview");
pane.placeholder.set_text(&format!(
"{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.",
pane.title
));
pane.stream_status.set_text("Streaming in its own window");
pane.preview_placeholder.set_visible(false);
}
}
}

View File

@ -123,12 +123,24 @@ fn device_staging_and_testing_stay_independent_so_preview_does_not_fill_dead_hei
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("preview_panel.set_hexpand(false);"));
assert!(UI_LAYOUT_SRC.contains("build_panel(\"Upstream Media\")"));
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 eye_placeholders_use_overlay_art_without_reflowing_the_shell() {
assert!(UI_LAYOUT_SRC.contains("preview_overlay.add_overlay(&preview_placeholder);"));
assert!(
UI_LAYOUT_SRC.contains("preview_placeholder.set_visible(picture.paintable().is_none());")
);
assert!(UI_LAYOUT_SRC.contains("stack.add_named(&placeholder_box, Some(\"placeholder\"));"));
assert!(UI_LAYOUT_SRC.contains("env!(\"CARGO_MANIFEST_DIR\")"));
assert!(UI_LAYOUT_SRC.contains("/assets/placeholders/eye_{}_{}.png"));
}
#[test]
fn device_testing_keeps_webcam_and_mic_playback_compact() {
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 160);