532 lines
18 KiB
Rust
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: >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(),
|
|
}
|
|
}
|