lesavka/client/src/launcher/ui_components.rs

532 lines
18 KiB
Rust

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,
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<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(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(
&microphone_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",
&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("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: &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;
}
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: &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, 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(),
}
}