use std::{cell::RefCell, rc::Rc}; use evdev::Device; use gtk::{pango, prelude::*}; use super::{ devices::DeviceCatalog, diagnostics::DiagnosticsLog, preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, FeedSourceChoice, FeedSourcePreset, LauncherState, }, }; #[derive(Clone)] pub struct SummaryWidgets { pub relay_light: gtk::Box, pub relay_value: gtk::Label, pub routing_light: gtk::Box, pub routing_value: gtk::Label, pub gpio_light: gtk::Box, pub gpio_value: gtk::Label, pub shortcut_value: gtk::Label, } #[derive(Clone)] pub struct DisplayPaneWidgets { pub root: gtk::Box, pub stack: gtk::Stack, pub preview_frame: gtk::AspectFrame, pub picture: gtk::Picture, pub stream_status: gtk::Label, pub placeholder: gtk::Label, pub feed_source_combo: gtk::ComboBoxText, pub capture_resolution_combo: gtk::ComboBoxText, pub breakout_combo: gtk::ComboBoxText, pub action_button: gtk::Button, pub preview_binding: Rc>>, pub title: String, } pub struct PopoutWindowHandle { pub window: gtk::ApplicationWindow, pub frame: gtk::AspectFrame, pub picture: gtk::Picture, pub status_label: gtk::Label, pub binding: PreviewBinding, } #[derive(Clone)] pub struct LauncherWidgets { pub status_label: gtk::Label, pub diagnostics_log: Rc>, pub diagnostics_label: gtk::Label, pub diagnostics_scroll: gtk::ScrolledWindow, pub diagnostics_popout_label: Rc>>, pub diagnostics_popout_scroll: Rc>>, pub diagnostics_rendered_text: Rc>, pub session_log_buffer: gtk::TextBuffer, pub session_log_view: gtk::TextView, pub summary: SummaryWidgets, pub power_detail: gtk::Label, pub audio_check_detail: gtk::Label, pub audio_check_meter: gtk::ProgressBar, pub display_panes: [DisplayPaneWidgets; 2], pub server_entry: gtk::Entry, pub start_button: gtk::Button, pub power_auto_button: gtk::Button, pub power_on_button: gtk::Button, pub power_off_button: gtk::Button, pub audio_gain_scale: gtk::Scale, pub audio_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 device_refresh_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, pub microphone_test_button: gtk::Button, pub microphone_replay_button: gtk::Button, pub speaker_test_button: gtk::Button, pub diagnostics_copy_button: gtk::Button, pub diagnostics_popout_button: gtk::Button, pub console_copy_button: gtk::Button, pub console_popout_button: gtk::Button, pub _device_body_height_group: gtk::SizeGroup, } #[derive(Clone)] pub struct DeviceStageWidgets { pub camera_preview: gtk::Picture, pub camera_status: gtk::Label, } pub struct LauncherView { pub window: gtk::ApplicationWindow, pub server_entry: gtk::Entry, pub camera_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText, pub keyboard_combo: gtk::ComboBoxText, pub mouse_combo: gtk::ComboBoxText, pub device_stage: DeviceStageWidgets, pub widgets: LauncherWidgets, pub preview: Option>, pub popouts: Rc; 2]>>, pub diagnostics_popout: Rc>>, pub log_popout: Rc>>, } pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons"); const LAUNCHER_DEFAULT_WIDTH: i32 = 1360; const LAUNCHER_DEFAULT_HEIGHT: i32 = 820; const OPERATIONS_RAIL_WIDTH: i32 = 288; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; const EYE_PREVIEW_MIN_HEIGHT: i32 = 258; const EYE_PREVIEW_MIN_WIDTH: i32 = 460; const SIDE_LOG_HEIGHT: i32 = 124; pub fn build_launcher_view( app: >k::Application, server_addr: &str, catalog: &DeviceCatalog, state: &LauncherState, ) -> LauncherView { let window = gtk::ApplicationWindow::builder() .application(app) .title("Lesavka") .default_width(LAUNCHER_DEFAULT_WIDTH) .default_height(LAUNCHER_DEFAULT_HEIGHT) .resizable(false) .build(); window.set_size_request(LAUNCHER_DEFAULT_WIDTH, LAUNCHER_DEFAULT_HEIGHT); install_css(&window); install_window_icon(&window); let root = gtk::Box::new(gtk::Orientation::Vertical, 8); 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); let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8); hero.set_hexpand(true); let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); brand_row.set_halign(gtk::Align::Start); let heading = gtk::Label::new(Some("Lesavka")); heading.add_css_class("title-2"); heading.set_halign(gtk::Align::Start); let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION))); version_tag.add_css_class("version-tag"); version_tag.set_halign(gtk::Align::Start); version_tag.set_valign(gtk::Align::End); brand_row.append(&heading); brand_row.append(&version_tag); brand_box.append(&brand_row); hero.append(&brand_box); let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6); 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 (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); chips.append(&relay_chip); chips.append(&routing_chip); chips.append(&gpio_chip); chips.append(&shortcut_chip); hero.append(&chips); root.append(&hero); let content = gtk::Box::new(gtk::Orientation::Horizontal, 8); content.set_hexpand(true); content.set_vexpand(true); root.append(&content); let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8); workspace.set_hexpand(true); workspace.set_vexpand(true); content.append(&workspace); let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); 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); display_row.set_hexpand(true); display_row.set_vexpand(true); display_row.set_homogeneous(true); let left_pane = build_display_pane("Left Eye", "/dev/lesavka_l_eye"); let right_pane = build_display_pane("Right Eye", "/dev/lesavka_r_eye"); display_row.append(&left_pane.root); display_row.append(&right_pane.root); workspace.append(&display_row); let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); staging_row.set_hexpand(true); staging_row.set_vexpand(false); staging_row.set_valign(gtk::Align::Start); staging_row.set_homogeneous(true); workspace.append(&staging_row); let device_refresh_button = gtk::Button::with_label("Refresh Devices"); stabilize_button(&device_refresh_button, 132); device_refresh_button.set_tooltip_text(Some( "Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.", )); let (devices_panel, devices_body) = build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref())); devices_panel.set_hexpand(true); devices_panel.set_vexpand(false); devices_panel.set_valign(gtk::Align::Fill); devices_body.set_spacing(8); let control_group = build_subgroup("Control Inputs"); let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10); control_group.append(&control_stack); let camera_combo = gtk::ComboBoxText::new(); camera_combo.append(Some("auto"), "auto"); for camera in &catalog.cameras { append_stage_choice(&camera_combo, camera); } super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref()); let camera_test_button = gtk::Button::with_label("Start Preview"); stabilize_button(&camera_test_button, 118); camera_test_button.set_tooltip_text(Some( "Open a local preview for the selected webcam so you can confirm the right source.", )); let speaker_combo = gtk::ComboBoxText::new(); speaker_combo.append(Some("auto"), "auto"); for speaker in &catalog.speakers { append_stage_choice(&speaker_combo, speaker); } super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref()); let speaker_test_button = gtk::Button::with_label("Play Tone"); stabilize_button(&speaker_test_button, 118); speaker_test_button.set_tooltip_text(Some( "Play a short continuous tone through the selected speaker until you stop the test.", )); let keyboard_combo = gtk::ComboBoxText::new(); keyboard_combo.append(Some("all"), "all keyboards"); for keyboard in &catalog.keyboards { append_input_choice(&keyboard_combo, keyboard); } super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref()); keyboard_combo.set_tooltip_text(Some( "Leave this on all keyboards to relay every keyboard, or pick one specific device.", )); let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo); control_stack.append(&keyboard_row); let mouse_combo = gtk::ComboBoxText::new(); mouse_combo.append(Some("all"), "all mice"); for mouse in &catalog.mice { append_input_choice(&mouse_combo, mouse); } super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref()); mouse_combo.set_tooltip_text(Some( "Leave this on all mice to relay every pointer, or pick one specific device.", )); let mouse_row = build_inline_selector_row("Mouse", &mouse_combo); control_stack.append(&mouse_row); devices_body.append(&control_group); let media_group = build_subgroup("Media Controls"); let media_grid = gtk::Grid::new(); media_grid.set_row_spacing(10); media_grid.set_column_spacing(8); media_group.append(&media_grid); camera_combo.set_size_request(0, -1); speaker_combo.set_size_request(0, -1); attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button); attach_device_row( &media_grid, 1, "Speaker", &speaker_combo, &speaker_test_button, ); let microphone_combo = gtk::ComboBoxText::new(); microphone_combo.append(Some("auto"), "auto"); for microphone in &catalog.microphones { append_stage_choice(µphone_combo, microphone); } super::ui_runtime::set_combo_active_text( µphone_combo, state.devices.microphone.as_deref(), ); let microphone_test_button = gtk::Button::with_label("Monitor Mic"); stabilize_button(µphone_test_button, 118); microphone_test_button.set_tooltip_text(Some( "Monitor the selected microphone through the selected speaker until you stop the test.", )); microphone_combo.set_size_request(0, -1); attach_device_row( &media_grid, 2, "Microphone", µphone_combo, µphone_test_button, ); let audio_check_detail = gtk::Label::new(Some("Idle")); audio_check_detail.add_css_class("dim-label"); audio_check_detail.set_wrap(false); audio_check_detail.set_ellipsize(pango::EllipsizeMode::End); audio_check_detail.set_xalign(0.0); audio_check_detail.set_visible(false); let audio_check_meter = gtk::ProgressBar::new(); audio_check_meter.add_css_class("audio-check-meter"); audio_check_meter.set_show_text(false); devices_body.append(&media_group); staging_row.append(&devices_panel); let (preview_panel, preview_body) = build_panel("Device Testing"); preview_panel.set_hexpand(true); 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); 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_halign(gtk::Align::Fill); camera_preview.set_valign(gtk::Align::Fill); camera_preview.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, ); camera_preview.set_keep_aspect_ratio(true); camera_preview.add_css_class("camera-preview-frame"); let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview.")); camera_status.add_css_class("dim-label"); camera_status.set_wrap(false); camera_status.set_ellipsize(pango::EllipsizeMode::End); 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_halign(gtk::Align::Fill); camera_preview_shell.set_valign(gtk::Align::Fill); 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_halign(gtk::Align::Fill); camera_preview_frame.set_valign(gtk::Align::Fill); camera_preview_frame.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, CAMERA_PREVIEW_VIEWPORT_HEIGHT, ); 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.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_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_valign(gtk::Align::Fill); let microphone_replay_button = gtk::Button::with_label("Replay"); stabilize_button(µphone_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_halign(gtk::Align::Center); audio_check_meter.set_size_request(20, 0); audio_check_meter.set_show_text(false); audio_check_meter.set_text(Some("Idle")); playback_body.append(&audio_check_meter); playback_body.append(µphone_replay_button); playback_group.append(&playback_body); testing_row.append(&playback_group); preview_body.append(&testing_row); staging_row.append(&preview_panel); let (connection_panel, connection_body) = build_panel("Relay Controls"); 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_text(server_addr); server_entry.set_tooltip_text(Some( "Relay host address for previews, power control, and the live session.", )); let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); relay_row.set_halign(gtk::Align::Fill); relay_row.set_hexpand(true); relay_row.append(&server_entry); let start_button = gtk::Button::with_label("Connect"); start_button.add_css_class("suggested-action"); stabilize_button(&start_button, 92); relay_row.append(&start_button); connection_body.append(&relay_row); let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); live_actions_row.set_homogeneous(true); let clipboard_button = gtk::Button::with_label("Send Clipboard"); clipboard_button.set_hexpand(true); stabilize_button(&clipboard_button, 108); clipboard_button.set_tooltip_text(Some( "Type the current local clipboard into the remote target. This stays launcher-only.", )); let probe_button = gtk::Button::with_label("Copy Gate Probe"); probe_button.set_hexpand(true); stabilize_button(&probe_button, 108); probe_button.set_tooltip_text(Some( "Copy the hygiene/quality probe command into the local clipboard.", )); let usb_recover_button = gtk::Button::with_label("Recover USB"); usb_recover_button.set_hexpand(true); stabilize_button(&usb_recover_button, 108); usb_recover_button.set_tooltip_text(Some( "Force the remote USB gadget to re-enumerate when keyboard, mouse, webcam, or audio stop showing up on the host.", )); live_actions_row.append(&clipboard_button); live_actions_row.append(&probe_button); live_actions_row.append(&usb_recover_button); connection_body.append(&live_actions_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let power_heading = gtk::Label::new(Some("Power")); power_heading.add_css_class("subgroup-title"); power_heading.set_halign(gtk::Align::Start); let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); power_shell.set_halign(gtk::Align::Fill); let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); power_row.set_hexpand(true); power_heading.set_width_chars(5); power_row.append(&power_heading); let power_on_button = gtk::Button::with_label("On"); stabilize_button(&power_on_button, 52); power_on_button.add_css_class("pill-toggle"); let power_auto_button = gtk::Button::with_label("Auto"); stabilize_button(&power_auto_button, 52); power_auto_button.add_css_class("pill-toggle"); let power_off_button = gtk::Button::with_label("Off"); stabilize_button(&power_off_button, 52); power_off_button.add_css_class("pill-toggle"); let power_detail = gtk::Label::new(Some("Capture power status is loading...")); power_detail.add_css_class("dim-label"); power_detail.set_wrap(true); power_detail.set_xalign(0.0); power_row.append(&power_on_button); power_row.append(&power_auto_button); power_row.append(&power_off_button); let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); audio_gain_row.set_size_request(220, -1); audio_gain_row.set_hexpand(true); let audio_gain_label = gtk::Label::new(Some("Audio")); audio_gain_label.add_css_class("dim-label"); audio_gain_label.set_halign(gtk::Align::Start); audio_gain_label.set_width_chars(5); let audio_gain_adjustment = gtk::Adjustment::new( state.audio_gain_percent as f64, 0.0, super::state::MAX_AUDIO_GAIN_PERCENT as f64, 25.0, 100.0, 0.0, ); let audio_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); audio_gain_scale.set_draw_value(false); audio_gain_scale.set_hexpand(true); audio_gain_scale.set_tooltip_text(Some( "Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.", )); let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); audio_gain_value.add_css_class("dim-label"); audio_gain_value.set_width_chars(5); audio_gain_value.set_xalign(1.0); audio_gain_row.append(&audio_gain_label); audio_gain_row.append(&audio_gain_scale); audio_gain_row.append(&audio_gain_value); power_shell.append(&power_row); power_shell.append(&audio_gain_row); connection_body.append(&power_shell); let routing_heading = gtk::Label::new(Some("Input")); routing_heading.add_css_class("subgroup-title"); routing_heading.set_halign(gtk::Align::Start); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); routing_row.set_hexpand(true); routing_heading.set_width_chars(5); routing_row.append(&routing_heading); let input_toggle_button = gtk::Button::with_label("Route"); input_toggle_button.set_hexpand(true); stabilize_button(&input_toggle_button, 106); input_toggle_button.set_tooltip_text(Some( "Change live keyboard and mouse ownership between this machine and the remote target.", )); let swap_key_button = gtk::Button::with_label("Set Swap Key"); stabilize_button(&swap_key_button, 106); routing_row.append(&input_toggle_button); routing_row.append(&swap_key_button); connection_body.append(&routing_row); operations.append(&connection_panel); let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics"); let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); diagnostics_toolbar.set_homogeneous(true); let diagnostics_copy_button = gtk::Button::with_label("Copy Report"); stabilize_button(&diagnostics_copy_button, 112); let diagnostics_popout_button = gtk::Button::with_label("Break Out"); stabilize_button(&diagnostics_popout_button, 112); diagnostics_toolbar.append(&diagnostics_copy_button); diagnostics_toolbar.append(&diagnostics_popout_button); let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16))); let diagnostics_label = gtk::Label::new(None); diagnostics_label.add_css_class("status-log"); 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_halign(gtk::Align::Start); diagnostics_label.set_valign(gtk::Align::Start); diagnostics_label.set_hexpand(true); let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); diagnostics_shell.set_hexpand(true); diagnostics_shell.set_vexpand(false); diagnostics_shell.append(&diagnostics_label); let diagnostics_scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(false) .min_content_height(SIDE_LOG_HEIGHT) .max_content_height(SIDE_LOG_HEIGHT) .child(&diagnostics_shell) .build(); diagnostics_body.append(&diagnostics_toolbar); diagnostics_body.append(&diagnostics_scroll); operations.append(&diagnostics_panel); let (console_panel, console_body) = build_panel("Session Console"); console_panel.set_vexpand(false); let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); console_toolbar.set_homogeneous(true); let console_copy_button = gtk::Button::with_label("Copy Log"); stabilize_button(&console_copy_button, 104); let console_popout_button = gtk::Button::with_label("Break Out Log"); stabilize_button(&console_popout_button, 104); console_toolbar.append(&console_copy_button); console_toolbar.append(&console_popout_button); let status_label = gtk::Label::new(Some("Session log ready.")); status_label.add_css_class("status-line"); status_label.set_halign(gtk::Align::Start); status_label.set_wrap(true); status_label.set_xalign(0.0); let session_log_buffer = gtk::TextBuffer::new(None); session_log_buffer.create_tag(Some("log-launcher"), &[("foreground", &"#8bd5ca")]); session_log_buffer.create_tag(Some("log-relay"), &[("foreground", &"#89b4fa")]); session_log_buffer.create_tag(Some("log-preview"), &[("foreground", &"#cba6f7")]); session_log_buffer.create_tag(Some("log-stderr"), &[("foreground", &"#f9e2af")]); session_log_buffer.create_tag(Some("log-warn"), &[("foreground", &"#fab387")]); session_log_buffer.create_tag(Some("log-error"), &[("foreground", &"#f38ba8")]); super::ui_runtime::append_session_log(&session_log_buffer, "[launcher] Session log ready."); let session_log_view = gtk::TextView::with_buffer(&session_log_buffer); session_log_view.add_css_class("status-log"); session_log_view.set_editable(false); session_log_view.set_cursor_visible(false); session_log_view.set_monospace(true); session_log_view.set_wrap_mode(gtk::WrapMode::WordChar); let log_scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(false) .min_content_height(SIDE_LOG_HEIGHT) .max_content_height(SIDE_LOG_HEIGHT) .child(&session_log_view) .build(); console_body.append(&console_toolbar); console_body.append(&log_scroll); operations.append(&console_panel); { let buffer = session_log_buffer.clone(); let view = session_log_view.clone(); status_label.connect_notify_local(Some("label"), move |label, _| { super::ui_runtime::append_session_log(&buffer, &format!("[launcher] {}", label.text())); let mut end = buffer.end_iter(); view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); }); } let preview = match LauncherPreview::new(server_addr.to_string()) { Ok(preview) => Some(Rc::new(preview)), Err(err) => { status_label.set_text(&format!("Preview unavailable: {err}")); None } }; let left_pane = left_pane; let right_pane = right_pane; if let Some(preview) = preview.as_ref() { *left_pane.preview_binding.borrow_mut() = if state.feed_source_preset(0) == FeedSourcePreset::Off { None } else { preview.install_on_picture( 0, PreviewSurface::Inline, &left_pane.picture, &left_pane.stream_status, ) }; *right_pane.preview_binding.borrow_mut() = if state.feed_source_preset(1) == FeedSourcePreset::Off { None } else { preview.install_on_picture( 1, PreviewSurface::Inline, &right_pane.picture, &right_pane.stream_status, ) }; } else { left_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable"); } sync_feed_source_combo( &left_pane.feed_source_combo, state.feed_source_options(0), state.feed_source_preset(0), ); sync_feed_source_combo( &right_pane.feed_source_combo, state.feed_source_options(1), state.feed_source_preset(1), ); if state.feed_source_preset(0) != FeedSourcePreset::Off { let choice = state .display_capture_size_choice(0) .unwrap_or_else(|| state.capture_size_choice(0)); if state.feed_source_preset(0) == FeedSourcePreset::ThisEye { sync_capture_resolution_combo( &left_pane.capture_resolution_combo, state.capture_size_options(), state.capture_size_preset(0), ); } else { sync_capture_resolution_locked( &left_pane.capture_resolution_combo, state.capture_size_options(), choice.preset, ); } } else { sync_capture_resolution_disabled(&left_pane.capture_resolution_combo); } if state.feed_source_preset(1) != FeedSourcePreset::Off { let choice = state .display_capture_size_choice(1) .unwrap_or_else(|| state.capture_size_choice(1)); if state.feed_source_preset(1) == FeedSourcePreset::ThisEye { sync_capture_resolution_combo( &right_pane.capture_resolution_combo, state.capture_size_options(), state.capture_size_preset(1), ); } else { sync_capture_resolution_locked( &right_pane.capture_resolution_combo, state.capture_size_options(), choice.preset, ); } } else { sync_capture_resolution_disabled(&right_pane.capture_resolution_combo); } sync_breakout_size_combo( &left_pane.breakout_combo, state.breakout_size_options(0), state.breakout_size_preset(0), ); sync_breakout_size_combo( &right_pane.breakout_combo, state.breakout_size_options(1), state.breakout_size_preset(1), ); let diagnostics_popout_label = Rc::new(RefCell::new(None)); let diagnostics_popout_scroll = Rc::new(RefCell::new(None)); let widgets = LauncherWidgets { status_label: status_label.clone(), diagnostics_log: diagnostics_log.clone(), diagnostics_label: diagnostics_label.clone(), diagnostics_scroll: diagnostics_scroll.clone(), diagnostics_popout_label: diagnostics_popout_label.clone(), diagnostics_popout_scroll: diagnostics_popout_scroll.clone(), diagnostics_rendered_text: Rc::new(RefCell::new(String::new())), session_log_buffer: session_log_buffer.clone(), session_log_view: session_log_view.clone(), summary: SummaryWidgets { relay_light, relay_value, routing_light, routing_value, gpio_light, gpio_value, shortcut_value, }, power_detail, audio_check_detail, audio_check_meter, display_panes: [left_pane.clone(), right_pane.clone()], server_entry: server_entry.clone(), start_button: start_button.clone(), power_auto_button: power_auto_button.clone(), power_on_button: power_on_button.clone(), power_off_button: power_off_button.clone(), audio_gain_scale: audio_gain_scale.clone(), audio_gain_value: audio_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(), device_refresh_button: device_refresh_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), microphone_test_button: microphone_test_button.clone(), microphone_replay_button: microphone_replay_button.clone(), speaker_test_button: speaker_test_button.clone(), diagnostics_copy_button: diagnostics_copy_button.clone(), diagnostics_popout_button: diagnostics_popout_button.clone(), console_copy_button: console_copy_button.clone(), console_popout_button: console_popout_button.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)); let log_popout = Rc::new(RefCell::new(None)); super::ui_runtime::refresh_diagnostics_report(&widgets, state, false); window.set_child(Some(&root)); LauncherView { window, server_entry, camera_combo, microphone_combo, speaker_combo, keyboard_combo, mouse_combo, device_stage: DeviceStageWidgets { camera_preview, camera_status, }, widgets, preview, popouts, diagnostics_popout, log_popout, } } pub fn install_css(window: >k::ApplicationWindow) { let provider = gtk::CssProvider::new(); provider.load_from_data( r#" window.lesavka { background: #101319; color: #eef2f7; } box.launcher-root { background: linear-gradient(180deg, #11161f 0%, #161d28 100%); } box.panel { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 18px; padding: 10px; } box.subgroup { background: rgba(255, 255, 255, 0.025); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 14px; padding: 8px; } label.panel-title { font-weight: 700; font-size: 1.05rem; margin-bottom: 4px; } label.subgroup-title { font-weight: 700; opacity: 0.92; } label.version-tag { font-size: 0.76rem; opacity: 0.72; margin-bottom: 3px; } box.status-chip { background: rgba(91, 179, 162, 0.12); border: 1px solid rgba(91, 179, 162, 0.25); border-radius: 999px; padding: 6px 9px; } box.status-light { min-width: 10px; min-height: 10px; border-radius: 999px; background: rgba(214, 81, 81, 0.92); } box.status-light-live { background: rgba(96, 214, 126, 0.95); } box.status-light-idle { background: rgba(214, 81, 81, 0.92); } box.status-light-warning { background: rgba(242, 143, 54, 0.95); } box.status-light-caution { background: rgba(227, 201, 73, 0.95); } label.status-chip-label { font-size: 0.78rem; opacity: 0.72; } label.status-chip-value { font-size: 0.93rem; font-weight: 700; } box.display-card { background: rgba(255, 255, 255, 0.045); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 22px; padding: 16px; } box.display-placeholder { background: rgba(255, 255, 255, 0.03); border: 1px dashed rgba(255, 255, 255, 0.18); border-radius: 16px; padding: 24px; } picture.camera-preview-frame { background: rgba(0, 0, 0, 0.28); border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 14px; } label.status-line { opacity: 0.9; } textview.status-log, label.status-log { font-family: monospace; background: rgba(0, 0, 0, 0.22); border-radius: 14px; padding: 10px; } progressbar.audio-check-meter trough { min-width: 14px; min-height: 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); } progressbar.audio-check-meter.vertical trough { min-height: 116px; } progressbar.audio-check-meter progress { border-radius: 999px; background: rgba(91, 179, 162, 0.88); } entry.server-entry { min-height: 38px; } button.pill-toggle { min-height: 36px; padding: 0 14px; } button.pill-toggle-active { background: rgba(91, 179, 162, 0.2); border-color: rgba(91, 179, 162, 0.45); font-weight: 700; } "#, ); if let Some(display) = gtk::gdk::Display::default() { gtk::style_context_add_provider_for_display( &display, &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, ); } window.add_css_class("lesavka"); } pub fn install_window_icon(window: &impl IsA) { if let Some(display) = gtk::gdk::Display::default() { let theme = gtk::IconTheme::for_display(&display); theme.add_search_path(LESAVKA_ICON_SEARCH_PATH); } gtk::Window::set_default_icon_name(LESAVKA_ICON_NAME); window.as_ref().set_icon_name(Some(LESAVKA_ICON_NAME)); } fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { build_panel_with_action(title, None) } fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) { let panel = gtk::Box::new(gtk::Orientation::Vertical, 8); panel.add_css_class("panel"); let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); header.set_hexpand(true); header.set_halign(gtk::Align::Fill); let heading = gtk::Label::new(Some(title)); heading.add_css_class("panel-title"); heading.set_halign(gtk::Align::Start); heading.set_hexpand(true); header.append(&heading); if let Some(action) = action { header.append(action); } panel.append(&header); let body = gtk::Box::new(gtk::Orientation::Vertical, 8); panel.append(&body); (panel, body) } fn build_subgroup(title: &str) -> gtk::Box { let group = gtk::Box::new(gtk::Orientation::Vertical, 8); group.add_css_class("subgroup"); let heading = gtk::Label::new(Some(title)); heading.add_css_class("subgroup-title"); heading.set_halign(gtk::Align::Start); group.append(&heading); group } fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); chip.set_hexpand(false); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); label_widget.set_halign(gtk::Align::Start); let value_widget = gtk::Label::new(Some(value)); value_widget.add_css_class("status-chip-value"); value_widget.set_halign(gtk::Align::Start); chip.append(&label_widget); chip.append(&value_widget); (chip, value_widget) } fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); chip.set_hexpand(false); let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta.add_css_class("status-chip-meta"); let light = gtk::Box::new(gtk::Orientation::Horizontal, 0); light.add_css_class("status-light"); light.add_css_class("status-light-idle"); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("status-chip-label"); label_widget.set_halign(gtk::Align::Start); meta.append(&light); meta.append(&label_widget); let value_widget = gtk::Label::new(Some(value)); value_widget.add_css_class("status-chip-value"); value_widget.set_halign(gtk::Align::Start); chip.append(&meta); chip.append(&value_widget); (chip, light, value_widget) } pub fn sync_feed_source_combo( combo: >k::ComboBoxText, options: Vec, selected: FeedSourcePreset, ) { combo.remove_all(); for option in options { combo.append(Some(option.preset.as_id()), option.label); } combo.set_active_id(Some(selected.as_id())); combo.set_sensitive(true); } pub fn sync_capture_resolution_combo( combo: >k::ComboBoxText, options: Vec, selected: CaptureSizePreset, ) { combo.remove_all(); let option_count = options.len(); for option in options { let label = format!( "{} • {}x{} @ {} fps (Device H.264)", option.preset.label(), option.width, option.height, option.fps, ); combo.append(Some(option.preset.as_id()), &label); } combo.set_active_id(Some(selected.as_id())); combo.set_sensitive(option_count > 1); } pub fn sync_capture_resolution_locked( combo: >k::ComboBoxText, options: Vec, selected: CaptureSizePreset, ) { sync_capture_resolution_combo(combo, options, selected); combo.set_sensitive(false); } pub fn sync_capture_resolution_disabled(combo: >k::ComboBoxText) { combo.remove_all(); combo.append(Some("off"), "Feed disabled"); combo.set_active_id(Some("off")); combo.set_sensitive(false); } pub fn sync_breakout_size_combo( combo: >k::ComboBoxText, options: Vec, selected: BreakoutSizePreset, ) { combo.remove_all(); for option in options { let label = match option.preset { BreakoutSizePreset::Source => { format!( "{} • {}x{} (Source Size)", option.preset.label(), option.width, option.height ) } BreakoutSizePreset::FillDisplay => { format!( "{} • {}x{} (Display Size)", option.preset.label(), option.width, option.height ) } _ => format!( "{} • {}x{}", option.preset.label(), option.width, option.height ), }; combo.append(Some(option.preset.as_id()), &label); } combo.set_active_id(Some(selected.as_id())); } pub fn sync_stage_device_combo( combo: >k::ComboBoxText, values: &[String], selected: Option<&str>, ) { combo.remove_all(); combo.append(Some("auto"), "auto"); for value in values { append_stage_choice(combo, value); } super::ui_runtime::set_combo_active_text(combo, selected); } pub fn sync_input_device_combo( combo: >k::ComboBoxText, values: &[String], selected: Option<&str>, all_label: &str, ) { combo.remove_all(); combo.append(Some("all"), all_label); for value in values { append_input_choice(combo, value); } super::ui_runtime::set_combo_active_text(combo, selected); } fn attach_device_row( grid: >k::Grid, row: i32, label: &str, combo: >k::ComboBoxText, test_button: >k::Button, ) { let label_widget = gtk::Label::new(Some(label)); label_widget.set_halign(gtk::Align::Start); combo.set_hexpand(true); grid.attach(&label_widget, 0, row, 1, 1); grid.attach(combo, 1, row, 1, 1); grid.attach(test_button, 2, row, 1, 1); } fn build_inline_selector_row(label: &str, combo: >k::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_xalign(0.0); combo.set_hexpand(true); combo.set_size_request(0, -1); block.append(&label_widget); block.append(combo); block } fn build_inline_combo_row( label: &str, combo: &impl IsA, min_label_chars: i32, ) -> gtk::Box { let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let label_widget = gtk::Label::new(Some(label)); label_widget.add_css_class("dim-label"); label_widget.set_width_chars(min_label_chars); label_widget.set_xalign(0.0); label_widget.set_halign(gtk::Align::Start); row.append(&label_widget); row.append(combo); row } fn append_input_choice(combo: >k::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}"))) .unwrap_or_else(|| short.to_string()); combo.append(Some(value), &label); } fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { combo.append(Some(value), &compact_stage_label(value)); } fn compact_stage_label(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return "auto".to_string(); } if let Some(short) = trimmed.rsplit('/').next() && short != trimmed { return shorten_label(short); } if let Some(rest) = trimmed .strip_prefix("alsa_input.") .or_else(|| trimmed.strip_prefix("alsa_output.")) { return shorten_label(rest); } shorten_label(trimmed) } fn shorten_label(value: &str) -> String { const MAX: usize = 44; let compact = value.replace('_', " "); let mut chars = compact.chars(); let preview: String = chars.by_ref().take(MAX).collect(); if chars.next().is_some() { format!("{preview}…") } else { preview } } 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); 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); 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_ellipsize(pango::EllipsizeMode::Start); header.append(&title_label); header.append(&capture_label); root.append(&header); let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_halign(gtk::Align::Fill); picture.set_valign(gtk::Align::Fill); 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_halign(gtk::Align::Fill); preview_box.set_valign(gtk::Align::Fill); 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(true); preview_frame.set_vexpand(true); preview_frame.set_halign(gtk::Align::Fill); preview_frame.set_valign(gtk::Align::Fill); 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 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_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.add_named(&preview_box, Some("preview")); stack.add_named(&placeholder_box, Some("placeholder")); stack.set_visible_child_name("preview"); root.append(&stack); let stream_status = gtk::Label::new(Some("Connect relay to preview.")); stream_status.add_css_class("status-line"); stream_status.set_halign(gtk::Align::Start); stream_status.set_hexpand(true); stream_status.set_ellipsize(pango::EllipsizeMode::End); stream_status.set_single_line_mode(true); stream_status.set_max_width_chars(24); stream_status.set_tooltip_text(Some("Connect relay to preview.")); root.append(&stream_status); let feed_source_combo = gtk::ComboBoxText::new(); feed_source_combo.set_tooltip_text(Some( "Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.", )); feed_source_combo.set_hexpand(true); feed_source_combo.set_size_request(0, -1); let capture_resolution_combo = gtk::ComboBoxText::new(); capture_resolution_combo.set_tooltip_text(Some( "Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.", )); capture_resolution_combo.set_size_request(0, -1); capture_resolution_combo.set_hexpand(true); let breakout_combo = gtk::ComboBoxText::new(); breakout_combo.set_tooltip_text(Some( "Choose the client-side breakout window size for this eye feed. Source Size preserves the feed's own dimensions; Display Size fills the effective monitor 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, 104); action_button.set_halign(gtk::Align::End); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); 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); 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, 1, 1); controls_grid.attach(&breakout_row, 0, 1, 1, 1); controls_grid.attach(&action_button, 1, 1, 1, 1); footer_shell.append(&controls_grid); root.append(&footer_shell); DisplayPaneWidgets { root, stack, preview_frame, picture, stream_status, placeholder, feed_source_combo, capture_resolution_combo, breakout_combo, action_button, preview_binding: Rc::new(RefCell::new(None)), title: title.to_string(), } } fn stabilize_button(button: >k::Button, width: i32) { button.set_size_request(width, 36); }