lesavka/client/src/launcher/ui_components.rs

668 lines
23 KiB
Rust
Raw Normal View History

use std::{cell::RefCell, rc::Rc};
use gtk::prelude::*;
use super::{
devices::DeviceCatalog,
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
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,
pub launch_plan_title: gtk::Label,
pub launch_plan_summary: gtk::Label,
pub launch_plan_detail: gtk::Label,
pub local_test_detail: gtk::Label,
pub display_panes: [DisplayPaneWidgets; 2],
pub start_button: gtk::Button,
pub power_auto_button: gtk::Button,
pub power_on_button: gtk::Button,
pub power_off_button: gtk::Button,
pub input_toggle_button: gtk::Button,
pub clipboard_button: gtk::Button,
pub probe_button: gtk::Button,
pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button,
pub microphone_test_button: gtk::Button,
pub speaker_test_button: gtk::Button,
}
#[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 device_stage: DeviceStageWidgets,
pub widgets: LauncherWidgets,
pub preview: Option<Rc<LauncherPreview>>,
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
}
pub fn build_launcher_view(
app: &gtk::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(420, -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("Session");
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.",
));
connection_body.append(&server_entry);
let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let start_button = gtk::Button::with_label("Connect Relay");
start_button.add_css_class("suggested-action");
start_button.set_hexpand(true);
start_button.set_tooltip_text(Some(
"Connect to the relay host, bring the staged session online, and start the eye previews.",
));
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);
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);
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
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.",
));
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_auto_button);
power_row.append(&power_on_button);
power_row.append(&power_off_button);
connection_body.append(&power_row);
connection_body.append(&power_detail);
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.",
));
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.",
));
routing_row.append(&input_toggle_button);
routing_row.append(&swap_key_button);
routing_body.append(&routing_row);
sidebar.append(&routing_panel);
let (devices_panel, devices_body) = build_panel("Device Staging");
let devices_intro = gtk::Label::new(Some(
"Choose the exact local camera, microphone, and speaker the next relay launch should inherit.",
));
devices_intro.add_css_class("dim-label");
devices_intro.set_wrap(true);
devices_intro.set_xalign(0.0);
devices_body.append(&devices_intro);
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("Start Preview");
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(
&microphone_combo,
state.devices.microphone.as_deref(),
);
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
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",
&microphone_combo,
&microphone_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("Play Tone");
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 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);
let preview_note = gtk::Label::new(Some(
"Verify the chosen webcam here before you launch. Audio device tests still stay local.",
));
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);
camera_preview.set_vexpand(true);
camera_preview.set_size_request(420, 210);
camera_preview.set_keep_aspect_ratio(true);
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);
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);
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);
operations_column.append(&plan_panel);
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,
PreviewSurface::Inline,
&left_pane.picture,
&left_pane.stream_status,
);
right_pane.preview_binding = 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");
}
let widgets = LauncherWidgets {
status_label: status_label.clone(),
summary: SummaryWidgets {
relay_value,
routing_value,
power_value,
displays_value,
shortcut_value,
},
power_detail,
launch_plan_title,
launch_plan_summary,
launch_plan_detail,
local_test_detail,
display_panes: [left_pane.clone(), right_pane.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(),
input_toggle_button: input_toggle_button.clone(),
clipboard_button: clipboard_button.clone(),
probe_button: probe_button.clone(),
swap_key_button: swap_key_button.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,
device_stage: DeviceStageWidgets {
camera_preview,
camera_status,
},
widgets,
preview,
popouts,
}
}
pub fn install_css(window: &gtk::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;
}
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.88;
}
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;
}
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-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: &gtk::Grid,
row: i32,
label: &str,
combo: &gtk::ComboBoxText,
test_button: &gtk::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, 240);
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, 240);
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("Connect relay to preview."));
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(),
}
}