2026-04-14 23:03:18 -03:00
|
|
|
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<PreviewBinding>,
|
|
|
|
|
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,
|
2026-04-15 01:57:14 -03:00
|
|
|
pub launch_plan_title: gtk::Label,
|
|
|
|
|
pub launch_plan_summary: gtk::Label,
|
|
|
|
|
pub launch_plan_detail: gtk::Label,
|
|
|
|
|
pub local_test_detail: gtk::Label,
|
2026-04-14 23:03:18 -03:00
|
|
|
pub display_panes: [DisplayPaneWidgets; 2],
|
|
|
|
|
pub start_button: gtk::Button,
|
2026-04-15 01:20:51 -03:00
|
|
|
pub power_auto_button: gtk::Button,
|
|
|
|
|
pub power_on_button: gtk::Button,
|
|
|
|
|
pub power_off_button: gtk::Button,
|
2026-04-14 23:03:18 -03:00
|
|
|
pub input_toggle_button: gtk::Button,
|
|
|
|
|
pub clipboard_button: gtk::Button,
|
|
|
|
|
pub probe_button: gtk::Button,
|
2026-04-15 04:44:06 -03:00
|
|
|
pub swap_key_button: gtk::Button,
|
2026-04-14 23:03:18 -03:00
|
|
|
pub camera_test_button: gtk::Button,
|
|
|
|
|
pub microphone_test_button: gtk::Button,
|
|
|
|
|
pub speaker_test_button: gtk::Button,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct DeviceStageWidgets {
|
|
|
|
|
pub camera_preview: gtk::Picture,
|
|
|
|
|
pub camera_status: gtk::Label,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
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,
|
2026-04-15 01:20:51 -03:00
|
|
|
pub device_stage: DeviceStageWidgets,
|
2026-04-14 23:03:18 -03:00
|
|
|
pub widgets: LauncherWidgets,
|
|
|
|
|
pub preview: Option<Rc<LauncherPreview>>,
|
|
|
|
|
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 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);
|
2026-04-15 02:46:59 -03:00
|
|
|
sidebar.set_size_request(420, -1);
|
2026-04-14 23:03:18 -03:00
|
|
|
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);
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
let (connection_panel, connection_body) = build_panel("Session");
|
2026-04-14 23:03:18 -03:00
|
|
|
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.",
|
|
|
|
|
));
|
2026-04-15 02:46:59 -03:00
|
|
|
connection_body.append(&server_entry);
|
|
|
|
|
|
|
|
|
|
let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
2026-04-15 04:11:47 -03:00
|
|
|
let start_button = gtk::Button::with_label("Connect Relay");
|
2026-04-14 23:03:18 -03:00
|
|
|
start_button.add_css_class("suggested-action");
|
2026-04-15 02:46:59 -03:00
|
|
|
start_button.set_hexpand(true);
|
2026-04-15 01:57:14 -03:00
|
|
|
start_button.set_tooltip_text(Some(
|
2026-04-15 04:11:47 -03:00
|
|
|
"Connect to the relay host, bring the staged session online, and start the eye previews.",
|
2026-04-15 01:57:14 -03:00
|
|
|
));
|
2026-04-15 02:46:59 -03:00
|
|
|
relay_actions_row.append(&start_button);
|
|
|
|
|
connection_body.append(&relay_actions_row);
|
|
|
|
|
|
|
|
|
|
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
|
|
|
let clipboard_button = gtk::Button::with_label("Send Clipboard");
|
|
|
|
|
clipboard_button.set_hexpand(true);
|
|
|
|
|
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);
|
|
|
|
|
probe_button.set_tooltip_text(Some(
|
|
|
|
|
"Copy the hygiene/quality probe command into the local clipboard.",
|
|
|
|
|
));
|
|
|
|
|
live_actions_row.append(&clipboard_button);
|
|
|
|
|
live_actions_row.append(&probe_button);
|
|
|
|
|
connection_body.append(&live_actions_row);
|
2026-04-14 23:03:18 -03:00
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
let power_intro = gtk::Label::new(Some(
|
|
|
|
|
"Capture power can stay automatic or be forced on/off while you stage a session.",
|
|
|
|
|
));
|
|
|
|
|
power_intro.add_css_class("dim-label");
|
|
|
|
|
power_intro.set_wrap(true);
|
|
|
|
|
power_intro.set_xalign(0.0);
|
|
|
|
|
connection_body.append(&power_intro);
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
2026-04-15 01:20:51 -03:00
|
|
|
let power_auto_button = gtk::Button::with_label("Auto");
|
|
|
|
|
power_auto_button.add_css_class("pill-toggle");
|
|
|
|
|
power_auto_button.set_tooltip_text(Some(
|
|
|
|
|
"Automatic mode follows the active remote preview and relay stream leases.",
|
|
|
|
|
));
|
|
|
|
|
let power_on_button = gtk::Button::with_label("Force On");
|
|
|
|
|
power_on_button.add_css_class("pill-toggle");
|
|
|
|
|
power_on_button.set_tooltip_text(Some(
|
|
|
|
|
"Keep the capture feeds powered even when no preview or session stream is active.",
|
|
|
|
|
));
|
|
|
|
|
let power_off_button = gtk::Button::with_label("Force Off");
|
|
|
|
|
power_off_button.add_css_class("pill-toggle");
|
|
|
|
|
power_off_button.set_tooltip_text(Some(
|
|
|
|
|
"Hold the capture feeds down even if previews or clients ask for them.",
|
2026-04-14 23:03:18 -03:00
|
|
|
));
|
|
|
|
|
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);
|
2026-04-15 01:20:51 -03:00
|
|
|
power_row.append(&power_auto_button);
|
|
|
|
|
power_row.append(&power_on_button);
|
|
|
|
|
power_row.append(&power_off_button);
|
2026-04-14 23:03:18 -03:00
|
|
|
connection_body.append(&power_row);
|
2026-04-15 01:20:51 -03:00
|
|
|
connection_body.append(&power_detail);
|
2026-04-14 23:03:18 -03:00
|
|
|
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.",
|
|
|
|
|
));
|
2026-04-15 04:44:06 -03:00
|
|
|
let swap_key_button = gtk::Button::with_label(&format!(
|
|
|
|
|
"Set Swap Key ({})",
|
|
|
|
|
super::ui_runtime::toggle_key_label(&state.swap_key)
|
|
|
|
|
));
|
|
|
|
|
swap_key_button.set_tooltip_text(Some(
|
|
|
|
|
"Press this, then hit one keyboard key to make it the live local/remote input swap shortcut.",
|
2026-04-14 23:03:18 -03:00
|
|
|
));
|
2026-04-15 04:11:47 -03:00
|
|
|
routing_row.append(&input_toggle_button);
|
2026-04-15 04:44:06 -03:00
|
|
|
routing_row.append(&swap_key_button);
|
2026-04-15 04:11:47 -03:00
|
|
|
routing_body.append(&routing_row);
|
2026-04-14 23:03:18 -03:00
|
|
|
sidebar.append(&routing_panel);
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
let (devices_panel, devices_body) = build_panel("Device Staging");
|
|
|
|
|
let devices_intro = gtk::Label::new(Some(
|
2026-04-15 01:57:14 -03:00
|
|
|
"Choose the exact local camera, microphone, and speaker the next relay launch should inherit.",
|
2026-04-15 01:20:51 -03:00
|
|
|
));
|
|
|
|
|
devices_intro.add_css_class("dim-label");
|
|
|
|
|
devices_intro.set_wrap(true);
|
|
|
|
|
devices_intro.set_xalign(0.0);
|
|
|
|
|
devices_body.append(&devices_intro);
|
2026-04-14 23:03:18 -03:00
|
|
|
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());
|
2026-04-15 01:20:51 -03:00
|
|
|
let camera_test_button = gtk::Button::with_label("Start Preview");
|
2026-04-14 23:03:18 -03:00
|
|
|
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(),
|
|
|
|
|
);
|
2026-04-15 01:20:51 -03:00
|
|
|
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
|
2026-04-14 23:03:18 -03:00
|
|
|
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());
|
2026-04-15 01:20:51 -03:00
|
|
|
let speaker_test_button = gtk::Button::with_label("Play Tone");
|
2026-04-14 23:03:18 -03:00
|
|
|
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,
|
|
|
|
|
);
|
2026-04-15 01:20:51 -03:00
|
|
|
|
2026-04-15 02:46:59 -03:00
|
|
|
sidebar.append(&devices_panel);
|
|
|
|
|
|
|
|
|
|
let stage_header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
|
|
|
let stage_title = gtk::Label::new(Some("Remote Eye Feeds"));
|
|
|
|
|
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);
|
|
|
|
|
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);
|
|
|
|
|
stage.append(&display_row);
|
|
|
|
|
|
|
|
|
|
let workspace_row = gtk::Box::new(gtk::Orientation::Horizontal, 16);
|
|
|
|
|
workspace_row.set_hexpand(true);
|
|
|
|
|
workspace_row.set_vexpand(true);
|
|
|
|
|
stage.append(&workspace_row);
|
|
|
|
|
|
|
|
|
|
let (preview_panel, preview_body) = build_panel("Selected Camera Preview");
|
|
|
|
|
preview_panel.set_hexpand(true);
|
|
|
|
|
preview_panel.set_vexpand(true);
|
2026-04-15 01:20:51 -03:00
|
|
|
let preview_note = gtk::Label::new(Some(
|
2026-04-15 02:46:59 -03:00
|
|
|
"Verify the chosen webcam here before you launch. Audio device tests still stay local.",
|
2026-04-15 01:20:51 -03:00
|
|
|
));
|
|
|
|
|
preview_note.add_css_class("dim-label");
|
|
|
|
|
preview_note.set_wrap(true);
|
|
|
|
|
preview_note.set_xalign(0.0);
|
|
|
|
|
let camera_preview = gtk::Picture::new();
|
|
|
|
|
camera_preview.set_can_shrink(true);
|
|
|
|
|
camera_preview.set_hexpand(true);
|
2026-04-15 02:46:59 -03:00
|
|
|
camera_preview.set_vexpand(true);
|
|
|
|
|
camera_preview.set_size_request(420, 210);
|
|
|
|
|
camera_preview.set_keep_aspect_ratio(true);
|
2026-04-15 01:20:51 -03:00
|
|
|
camera_preview.add_css_class("camera-preview-frame");
|
|
|
|
|
let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview."));
|
|
|
|
|
camera_status.add_css_class("dim-label");
|
|
|
|
|
camera_status.set_wrap(true);
|
|
|
|
|
camera_status.set_xalign(0.0);
|
2026-04-15 02:46:59 -03:00
|
|
|
preview_body.append(&preview_note);
|
|
|
|
|
preview_body.append(&camera_preview);
|
|
|
|
|
preview_body.append(&camera_status);
|
|
|
|
|
workspace_row.append(&preview_panel);
|
|
|
|
|
|
|
|
|
|
let operations_column = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
|
|
|
|
operations_column.set_size_request(340, -1);
|
|
|
|
|
workspace_row.append(&operations_column);
|
2026-04-14 23:03:18 -03:00
|
|
|
|
2026-04-15 01:57:14 -03:00
|
|
|
let (plan_panel, plan_body) = build_panel("Launch Plan");
|
|
|
|
|
let launch_plan_title = gtk::Label::new(Some("Stage locally, then start the relay."));
|
|
|
|
|
launch_plan_title.add_css_class("title-4");
|
|
|
|
|
launch_plan_title.set_halign(gtk::Align::Start);
|
|
|
|
|
launch_plan_title.set_wrap(true);
|
|
|
|
|
let launch_plan_summary =
|
|
|
|
|
gtk::Label::new(Some("Camera: auto\nMicrophone: auto\nSpeaker: auto"));
|
|
|
|
|
launch_plan_summary.add_css_class("launch-plan-summary");
|
|
|
|
|
launch_plan_summary.set_halign(gtk::Align::Start);
|
|
|
|
|
launch_plan_summary.set_xalign(0.0);
|
|
|
|
|
launch_plan_summary.set_wrap(true);
|
|
|
|
|
let local_test_detail = gtk::Label::new(Some(
|
|
|
|
|
"Local checks are idle. Use Start Preview, Monitor Mic, or Play Tone before you launch.",
|
|
|
|
|
));
|
|
|
|
|
local_test_detail.add_css_class("dim-label");
|
|
|
|
|
local_test_detail.set_halign(gtk::Align::Start);
|
|
|
|
|
local_test_detail.set_xalign(0.0);
|
|
|
|
|
local_test_detail.set_wrap(true);
|
|
|
|
|
let launch_plan_detail = gtk::Label::new(Some(
|
|
|
|
|
"Automatic capture mode will wake the remote feeds when previews or the live relay ask for them.",
|
|
|
|
|
));
|
|
|
|
|
launch_plan_detail.add_css_class("dim-label");
|
|
|
|
|
launch_plan_detail.set_halign(gtk::Align::Start);
|
|
|
|
|
launch_plan_detail.set_xalign(0.0);
|
|
|
|
|
launch_plan_detail.set_wrap(true);
|
|
|
|
|
plan_body.append(&launch_plan_title);
|
|
|
|
|
plan_body.append(&launch_plan_summary);
|
|
|
|
|
plan_body.append(&local_test_detail);
|
|
|
|
|
plan_body.append(&launch_plan_detail);
|
2026-04-15 02:46:59 -03:00
|
|
|
operations_column.append(&plan_panel);
|
2026-04-14 23:03:18 -03:00
|
|
|
|
|
|
|
|
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,
|
2026-04-15 01:57:14 -03:00
|
|
|
launch_plan_title,
|
|
|
|
|
launch_plan_summary,
|
|
|
|
|
launch_plan_detail,
|
|
|
|
|
local_test_detail,
|
2026-04-14 23:03:18 -03:00
|
|
|
display_panes: [left_pane.clone(), right_pane.clone()],
|
|
|
|
|
start_button: start_button.clone(),
|
2026-04-15 01:20:51 -03:00
|
|
|
power_auto_button: power_auto_button.clone(),
|
|
|
|
|
power_on_button: power_on_button.clone(),
|
|
|
|
|
power_off_button: power_off_button.clone(),
|
2026-04-14 23:03:18 -03:00
|
|
|
input_toggle_button: input_toggle_button.clone(),
|
|
|
|
|
clipboard_button: clipboard_button.clone(),
|
|
|
|
|
probe_button: probe_button.clone(),
|
2026-04-15 04:44:06 -03:00
|
|
|
swap_key_button: swap_key_button.clone(),
|
2026-04-14 23:03:18 -03:00
|
|
|
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,
|
2026-04-15 01:20:51 -03:00
|
|
|
device_stage: DeviceStageWidgets {
|
|
|
|
|
camera_preview,
|
|
|
|
|
camera_status,
|
|
|
|
|
},
|
2026-04-14 23:03:18 -03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-15 01:20:51 -03:00
|
|
|
picture.camera-preview-frame {
|
|
|
|
|
background: rgba(0, 0, 0, 0.28);
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
label.status-line {
|
|
|
|
|
opacity: 0.88;
|
|
|
|
|
}
|
2026-04-15 01:57:14 -03:00
|
|
|
label.launch-plan-summary {
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
entry.server-entry {
|
|
|
|
|
min-height: 38px;
|
|
|
|
|
}
|
2026-04-15 01:20:51 -03:00
|
|
|
button.pill-toggle {
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
padding: 0 14px;
|
|
|
|
|
}
|
2026-04-15 01:57:14 -03:00
|
|
|
button.pill-toggle-active {
|
|
|
|
|
background: rgba(91, 179, 162, 0.2);
|
|
|
|
|
border-color: rgba(91, 179, 162, 0.45);
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
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);
|
2026-04-15 02:46:59 -03:00
|
|
|
picture.set_size_request(540, 240);
|
2026-04-14 23:03:18 -03:00
|
|
|
|
|
|
|
|
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);
|
2026-04-15 02:46:59 -03:00
|
|
|
placeholder_box.set_size_request(540, 240);
|
2026-04-14 23:03:18 -03:00
|
|
|
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);
|
2026-04-15 04:11:47 -03:00
|
|
|
let stream_status = gtk::Label::new(Some("Connect relay to preview."));
|
2026-04-14 23:03:18 -03:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
}
|