use std::{cell::RefCell, rc::Rc}; use gtk::prelude::*; use super::{ devices::DeviceCatalog, preview::{LauncherPreview, PreviewBinding}, state::LauncherState, }; #[derive(Clone)] pub struct SummaryWidgets { pub relay_value: gtk::Label, pub routing_value: gtk::Label, pub power_value: gtk::Label, pub displays_value: gtk::Label, pub shortcut_value: gtk::Label, } #[derive(Clone)] pub struct DisplayPaneWidgets { pub root: gtk::Box, pub stack: gtk::Stack, pub picture: gtk::Picture, pub stream_status: gtk::Label, pub placeholder: gtk::Label, pub action_button: gtk::Button, pub preview_binding: Option, pub title: String, } pub struct PopoutWindowHandle { pub window: gtk::ApplicationWindow, pub binding: PreviewBinding, } #[derive(Clone)] pub struct LauncherWidgets { pub status_label: gtk::Label, pub summary: SummaryWidgets, pub power_detail: gtk::Label, pub display_panes: [DisplayPaneWidgets; 2], pub start_button: gtk::Button, pub stop_button: gtk::Button, pub power_button: gtk::Button, pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, pub toggle_key_combo: gtk::ComboBoxText, pub camera_test_button: gtk::Button, pub microphone_test_button: gtk::Button, pub speaker_test_button: gtk::Button, } 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 widgets: LauncherWidgets, pub preview: Option>, pub popouts: Rc; 2]>>, } 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 Launcher") .default_width(1480) .default_height(900) .build(); install_css(&window); let root = gtk::Box::new(gtk::Orientation::Vertical, 16); root.add_css_class("launcher-root"); root.set_margin_start(20); root.set_margin_end(20); root.set_margin_top(20); root.set_margin_bottom(20); let hero = gtk::Box::new(gtk::Orientation::Horizontal, 16); hero.set_hexpand(true); let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 4); let heading = gtk::Label::new(Some("Lesavka Control Deck")); heading.add_css_class("title-2"); heading.set_halign(gtk::Align::Start); let subheading = gtk::Label::new(Some( "Relay, capture power, device staging, and eye previews in one control surface.", )); subheading.add_css_class("dim-label"); subheading.set_halign(gtk::Align::Start); brand_box.append(&heading); brand_box.append(&subheading); hero.append(&brand_box); let chips = gtk::Box::new(gtk::Orientation::Horizontal, 10); chips.set_halign(gtk::Align::End); chips.set_hexpand(true); let (relay_chip, relay_value) = build_status_chip("Relay", "Stopped"); let (routing_chip, routing_value) = build_status_chip("Inputs", "Remote"); let (power_chip, power_value) = build_status_chip("Capture", "Unknown"); let (display_chip, displays_value) = build_status_chip("Displays", "Preview"); let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); chips.append(&relay_chip); chips.append(&routing_chip); chips.append(&power_chip); chips.append(&display_chip); chips.append(&shortcut_chip); hero.append(&chips); root.append(&hero); let content = gtk::Box::new(gtk::Orientation::Horizontal, 16); content.set_hexpand(true); content.set_vexpand(true); root.append(&content); let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 12); sidebar.set_size_request(410, -1); sidebar.set_valign(gtk::Align::Fill); content.append(&sidebar); let stage = gtk::Box::new(gtk::Orientation::Vertical, 12); stage.set_hexpand(true); stage.set_vexpand(true); content.append(&stage); let (connection_panel, connection_body) = build_panel("Connection"); let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let server_entry = gtk::Entry::new(); server_entry.add_css_class("server-entry"); server_entry.set_hexpand(true); server_entry.set_text(server_addr); server_entry.set_tooltip_text(Some( "Relay host address for previews, power control, and the live session.", )); let start_button = gtk::Button::with_label("Start Relay"); start_button.add_css_class("suggested-action"); let stop_button = gtk::Button::with_label("Stop Relay"); stop_button.add_css_class("destructive-action"); server_row.append(&server_entry); server_row.append(&start_button); server_row.append(&stop_button); connection_body.append(&server_row); let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let power_button = gtk::Button::with_label("Power Up Feeds"); power_button.set_tooltip_text(Some( "Turns the relay.service-backed capture power on or off from the launcher.", )); 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_button); power_row.append(&power_detail); connection_body.append(&power_row); sidebar.append(&connection_panel); let (routing_panel, routing_body) = build_panel("Input Routing"); let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let input_toggle_button = gtk::Button::with_label("Route Inputs To Local"); input_toggle_button.set_hexpand(true); input_toggle_button.set_tooltip_text(Some( "Switch live keyboard and mouse ownership between the local machine and the remote target.", )); routing_row.append(&input_toggle_button); routing_body.append(&routing_row); let swap_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let swap_label = gtk::Label::new(Some("Swap key")); swap_label.set_halign(gtk::Align::Start); let toggle_key_combo = gtk::ComboBoxText::new(); toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc"); toggle_key_combo.append(Some("pause"), "Pause"); toggle_key_combo.append(Some("f12"), "F12"); toggle_key_combo.append(Some("f11"), "F11"); toggle_key_combo.append(Some("f10"), "F10"); toggle_key_combo.append(Some("off"), "Disabled"); let _ = toggle_key_combo.set_active_id(Some("pause")); toggle_key_combo.set_tooltip_text(Some( "Single-key live input swap while the relay is running.", )); swap_row.append(&swap_label); swap_row.append(&toggle_key_combo); routing_body.append(&swap_row); sidebar.append(&routing_panel); let (devices_panel, devices_body) = build_panel("Devices"); let devices_grid = gtk::Grid::new(); devices_grid.set_row_spacing(8); devices_grid.set_column_spacing(8); devices_body.append(&devices_grid); let camera_combo = gtk::ComboBoxText::new(); camera_combo.append(Some("auto"), "auto"); for camera in &catalog.cameras { camera_combo.append(Some(camera), camera); } super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref()); let camera_test_button = gtk::Button::with_label("Test"); camera_test_button.set_tooltip_text(Some( "Open a local preview for the selected webcam so you can confirm the right source.", )); attach_device_row( &devices_grid, 0, "Camera", &camera_combo, &camera_test_button, ); let microphone_combo = gtk::ComboBoxText::new(); microphone_combo.append(Some("auto"), "auto"); for microphone in &catalog.microphones { microphone_combo.append(Some(microphone), microphone); } super::ui_runtime::set_combo_active_text( µphone_combo, state.devices.microphone.as_deref(), ); let microphone_test_button = gtk::Button::with_label("Test"); microphone_test_button.set_tooltip_text(Some( "Monitor the selected microphone through the selected speaker until you stop the test.", )); attach_device_row( &devices_grid, 1, "Microphone", µphone_combo, µphone_test_button, ); let speaker_combo = gtk::ComboBoxText::new(); speaker_combo.append(Some("auto"), "auto"); for speaker in &catalog.speakers { speaker_combo.append(Some(speaker), speaker); } super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref()); let speaker_test_button = gtk::Button::with_label("Test"); speaker_test_button.set_tooltip_text(Some( "Play a short continuous tone through the selected speaker until you stop the test.", )); attach_device_row( &devices_grid, 2, "Speaker", &speaker_combo, &speaker_test_button, ); sidebar.append(&devices_panel); let (actions_panel, actions_body) = build_panel("Remote Actions"); let actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let clipboard_button = gtk::Button::with_label("Send Clipboard"); 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_tooltip_text(Some( "Copy the hygiene/quality probe command into the local clipboard.", )); actions_row.append(&clipboard_button); actions_row.append(&probe_button); actions_body.append(&actions_row); sidebar.append(&actions_panel); let stage_header = gtk::Box::new(gtk::Orientation::Horizontal, 8); let stage_title = gtk::Label::new(Some("Remote Eyes")); stage_title.add_css_class("title-4"); stage_title.set_halign(gtk::Align::Start); stage_header.append(&stage_title); stage.append(&stage_header); let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 16); display_row.set_hexpand(true); display_row.set_vexpand(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); stage.append(&display_row); let status_label = gtk::Label::new(Some("Launcher ready.")); status_label.add_css_class("status-line"); status_label.set_halign(gtk::Align::Start); status_label.set_ellipsize(gtk::pango::EllipsizeMode::End); root.append(&status_label); 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 mut left_pane = left_pane; let mut right_pane = right_pane; if let Some(preview) = preview.as_ref() { left_pane.preview_binding = preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status); right_pane.preview_binding = preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status); } else { left_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable"); } let widgets = LauncherWidgets { status_label: status_label.clone(), summary: SummaryWidgets { relay_value, routing_value, power_value, displays_value, shortcut_value, }, power_detail, display_panes: [left_pane.clone(), right_pane.clone()], start_button: start_button.clone(), stop_button: stop_button.clone(), power_button: power_button.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), toggle_key_combo: toggle_key_combo.clone(), camera_test_button: camera_test_button.clone(), microphone_test_button: microphone_test_button.clone(), speaker_test_button: speaker_test_button.clone(), }; let popouts = Rc::new(RefCell::new([None, None])); window.set_child(Some(&root)); LauncherView { window, server_entry, camera_combo, microphone_combo, speaker_combo, widgets, preview, popouts, } } pub fn install_css(window: >k::ApplicationWindow) { let provider = gtk::CssProvider::new(); provider.load_from_data( r#" window.lesavka-launcher { 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: 14px; } label.panel-title { font-weight: 700; font-size: 1.05rem; margin-bottom: 4px; } box.status-chip { background: rgba(91, 179, 162, 0.12); border: 1px solid rgba(91, 179, 162, 0.25); border-radius: 999px; padding: 8px 12px; } label.status-chip-label { font-size: 0.78rem; opacity: 0.72; } label.status-chip-value { 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; } label.status-line { opacity: 0.88; } entry.server-entry { min-height: 38px; } "#, ); 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-launcher"); } fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { let panel = gtk::Box::new(gtk::Orientation::Vertical, 10); panel.add_css_class("panel"); let heading = gtk::Label::new(Some(title)); heading.add_css_class("panel-title"); heading.set_halign(gtk::Align::Start); panel.append(&heading); let body = gtk::Box::new(gtk::Orientation::Vertical, 10); panel.append(&body); (panel, body) } fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 2); chip.add_css_class("status-chip"); 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 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_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 title_label = gtk::Label::new(Some(title)); title_label.add_css_class("title-4"); title_label.set_halign(gtk::Align::Start); let capture_label = gtk::Label::new(Some(capture_path)); capture_label.add_css_class("dim-label"); capture_label.set_halign(gtk::Align::Start); root.append(&title_label); root.append(&capture_label); let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_can_shrink(true); picture.set_size_request(540, 304); let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); preview_box.append(&picture); 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(540, 304); 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 footer = gtk::Box::new(gtk::Orientation::Horizontal, 8); let stream_status = gtk::Label::new(Some("Waiting for stream...")); stream_status.set_halign(gtk::Align::Start); stream_status.set_hexpand(true); let action_button = gtk::Button::with_label("Break Out"); action_button.set_halign(gtk::Align::End); footer.append(&stream_status); footer.append(&action_button); root.append(&footer); DisplayPaneWidgets { root, stack, picture, stream_status, placeholder, action_button, preview_binding: None, title: title.to_string(), } }