987 lines
36 KiB
Rust
987 lines
36 KiB
Rust
use std::{cell::RefCell, rc::Rc};
|
|
|
|
use evdev::Device;
|
|
use gtk::{pango, prelude::*};
|
|
|
|
use super::{
|
|
devices::DeviceCatalog,
|
|
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
|
state::{
|
|
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, LauncherState,
|
|
},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub struct SummaryWidgets {
|
|
pub relay_light: gtk::Box,
|
|
pub relay_value: gtk::Label,
|
|
pub routing_light: gtk::Box,
|
|
pub routing_value: gtk::Label,
|
|
pub gpio_light: gtk::Box,
|
|
pub gpio_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 capture_combo: gtk::ComboBoxText,
|
|
pub breakout_combo: gtk::ComboBoxText,
|
|
pub action_button: gtk::Button,
|
|
pub preview_binding: Rc<RefCell<Option<PreviewBinding>>>,
|
|
pub title: String,
|
|
}
|
|
|
|
pub struct PopoutWindowHandle {
|
|
pub window: gtk::ApplicationWindow,
|
|
pub picture: gtk::Picture,
|
|
pub status_label: gtk::Label,
|
|
pub binding: PreviewBinding,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct LauncherWidgets {
|
|
pub status_label: gtk::Label,
|
|
pub session_log_buffer: gtk::TextBuffer,
|
|
pub session_log_view: gtk::TextView,
|
|
pub summary: SummaryWidgets,
|
|
pub power_detail: gtk::Label,
|
|
pub audio_check_detail: gtk::Label,
|
|
pub audio_check_meter: gtk::ProgressBar,
|
|
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 microphone_replay_button: gtk::Button,
|
|
pub speaker_test_button: gtk::Button,
|
|
pub console_copy_button: gtk::Button,
|
|
pub console_popout_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 keyboard_combo: gtk::ComboBoxText,
|
|
pub mouse_combo: gtk::ComboBoxText,
|
|
pub device_stage: DeviceStageWidgets,
|
|
pub widgets: LauncherWidgets,
|
|
pub preview: Option<Rc<LauncherPreview>>,
|
|
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
|
|
pub log_popout: Rc<RefCell<Option<gtk::ApplicationWindow>>>,
|
|
}
|
|
|
|
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
|
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
|
const LAUNCHER_DEFAULT_WIDTH: i32 = 1510;
|
|
const LAUNCHER_DEFAULT_HEIGHT: i32 = 930;
|
|
const OPERATIONS_RAIL_WIDTH: i32 = 304;
|
|
const STAGING_COMBO_WIDTH: i32 = 690;
|
|
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 178;
|
|
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 316;
|
|
|
|
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")
|
|
.default_width(LAUNCHER_DEFAULT_WIDTH)
|
|
.default_height(LAUNCHER_DEFAULT_HEIGHT)
|
|
.build();
|
|
install_css(&window);
|
|
install_window_icon(&window);
|
|
|
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
root.add_css_class("launcher-root");
|
|
root.set_margin_start(10);
|
|
root.set_margin_end(10);
|
|
root.set_margin_top(10);
|
|
root.set_margin_bottom(10);
|
|
|
|
let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
hero.set_hexpand(true);
|
|
|
|
let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
brand_row.set_halign(gtk::Align::Start);
|
|
let heading = gtk::Label::new(Some("Lesavka"));
|
|
heading.add_css_class("title-2");
|
|
heading.set_halign(gtk::Align::Start);
|
|
let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION)));
|
|
version_tag.add_css_class("version-tag");
|
|
version_tag.set_halign(gtk::Align::Start);
|
|
version_tag.set_valign(gtk::Align::End);
|
|
brand_row.append(&heading);
|
|
brand_row.append(&version_tag);
|
|
brand_box.append(&brand_row);
|
|
hero.append(&brand_box);
|
|
|
|
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
|
chips.set_halign(gtk::Align::End);
|
|
chips.set_hexpand(true);
|
|
let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "");
|
|
let (routing_chip, routing_light, routing_value) =
|
|
build_status_chip_with_light("Inputs", "Local");
|
|
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
|
|
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
|
|
stabilize_chip(&relay_chip, 104);
|
|
stabilize_chip(&routing_chip, 84);
|
|
stabilize_chip(&gpio_chip, 84);
|
|
stabilize_chip(&shortcut_chip, 88);
|
|
chips.append(&relay_chip);
|
|
chips.append(&routing_chip);
|
|
chips.append(&gpio_chip);
|
|
chips.append(&shortcut_chip);
|
|
hero.append(&chips);
|
|
root.append(&hero);
|
|
|
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
content.set_hexpand(true);
|
|
content.set_vexpand(true);
|
|
root.append(&content);
|
|
|
|
let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
workspace.set_hexpand(true);
|
|
workspace.set_vexpand(true);
|
|
content.append(&workspace);
|
|
|
|
let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
|
|
operations.set_hexpand(false);
|
|
operations.set_vexpand(true);
|
|
content.append(&operations);
|
|
|
|
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
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);
|
|
workspace.append(&display_row);
|
|
|
|
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
staging_row.set_hexpand(true);
|
|
staging_row.set_vexpand(false);
|
|
workspace.append(&staging_row);
|
|
|
|
let (devices_panel, devices_body) = build_panel("Device Staging");
|
|
devices_panel.set_hexpand(true);
|
|
devices_panel.set_vexpand(false);
|
|
devices_body.set_spacing(8);
|
|
|
|
let control_group = build_subgroup("Control Inputs");
|
|
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
|
control_row.set_homogeneous(true);
|
|
control_group.append(&control_row);
|
|
|
|
let camera_combo = gtk::ComboBoxText::new();
|
|
camera_combo.append(Some("auto"), "auto");
|
|
for camera in &catalog.cameras {
|
|
append_stage_choice(&camera_combo, 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");
|
|
stabilize_button(&camera_test_button, 118);
|
|
camera_test_button.set_tooltip_text(Some(
|
|
"Open a local preview for the selected webcam so you can confirm the right source.",
|
|
));
|
|
|
|
let speaker_combo = gtk::ComboBoxText::new();
|
|
speaker_combo.append(Some("auto"), "auto");
|
|
for speaker in &catalog.speakers {
|
|
append_stage_choice(&speaker_combo, 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");
|
|
stabilize_button(&speaker_test_button, 118);
|
|
speaker_test_button.set_tooltip_text(Some(
|
|
"Play a short continuous tone through the selected speaker until you stop the test.",
|
|
));
|
|
|
|
let keyboard_combo = gtk::ComboBoxText::new();
|
|
keyboard_combo.append(Some("all"), "all keyboards");
|
|
for keyboard in &catalog.keyboards {
|
|
append_input_choice(&keyboard_combo, keyboard);
|
|
}
|
|
super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref());
|
|
keyboard_combo.set_tooltip_text(Some(
|
|
"Leave this on all keyboards to relay every keyboard, or pick one specific device.",
|
|
));
|
|
let keyboard_block = build_selector_block("Keyboard", &keyboard_combo);
|
|
control_row.append(&keyboard_block);
|
|
|
|
let mouse_combo = gtk::ComboBoxText::new();
|
|
mouse_combo.append(Some("all"), "all mice");
|
|
for mouse in &catalog.mice {
|
|
append_input_choice(&mouse_combo, mouse);
|
|
}
|
|
super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref());
|
|
mouse_combo.set_tooltip_text(Some(
|
|
"Leave this on all mice to relay every pointer, or pick one specific device.",
|
|
));
|
|
let mouse_block = build_selector_block("Mouse", &mouse_combo);
|
|
control_row.append(&mouse_block);
|
|
devices_body.append(&control_group);
|
|
|
|
let media_group = build_subgroup("Media Controls");
|
|
let media_grid = gtk::Grid::new();
|
|
media_grid.set_row_spacing(10);
|
|
media_grid.set_column_spacing(8);
|
|
media_group.append(&media_grid);
|
|
camera_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
|
|
speaker_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
|
|
attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button);
|
|
attach_device_row(
|
|
&media_grid,
|
|
1,
|
|
"Speaker",
|
|
&speaker_combo,
|
|
&speaker_test_button,
|
|
);
|
|
|
|
let microphone_combo = gtk::ComboBoxText::new();
|
|
microphone_combo.append(Some("auto"), "auto");
|
|
for microphone in &catalog.microphones {
|
|
append_stage_choice(µphone_combo, microphone);
|
|
}
|
|
super::ui_runtime::set_combo_active_text(
|
|
µphone_combo,
|
|
state.devices.microphone.as_deref(),
|
|
);
|
|
let microphone_test_button = gtk::Button::with_label("Monitor Mic");
|
|
stabilize_button(µphone_test_button, 118);
|
|
microphone_test_button.set_tooltip_text(Some(
|
|
"Monitor the selected microphone through the selected speaker until you stop the test.",
|
|
));
|
|
microphone_combo.set_size_request(STAGING_COMBO_WIDTH, -1);
|
|
attach_device_row(
|
|
&media_grid,
|
|
2,
|
|
"Microphone",
|
|
µphone_combo,
|
|
µphone_test_button,
|
|
);
|
|
|
|
let audio_check_detail = gtk::Label::new(Some(
|
|
"Monitor Mic listens locally, Replay Last 3s replays the latest captured mic audio, and Play Tone verifies the speaker path.",
|
|
));
|
|
audio_check_detail.add_css_class("dim-label");
|
|
audio_check_detail.set_wrap(true);
|
|
audio_check_detail.set_xalign(0.0);
|
|
let audio_check_meter = gtk::ProgressBar::new();
|
|
audio_check_meter.add_css_class("audio-check-meter");
|
|
audio_check_meter.set_show_text(false);
|
|
devices_body.append(&media_group);
|
|
staging_row.append(&devices_panel);
|
|
|
|
let (preview_panel, preview_body) = build_panel("Selected Camera Preview");
|
|
preview_panel.set_hexpand(true);
|
|
preview_panel.set_vexpand(false);
|
|
preview_body.set_spacing(6);
|
|
let camera_preview = gtk::Picture::new();
|
|
camera_preview.set_can_shrink(false);
|
|
camera_preview.set_hexpand(true);
|
|
camera_preview.set_vexpand(true);
|
|
camera_preview.set_size_request(
|
|
CAMERA_PREVIEW_VIEWPORT_WIDTH,
|
|
CAMERA_PREVIEW_VIEWPORT_HEIGHT,
|
|
);
|
|
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);
|
|
let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
camera_preview_shell.set_hexpand(true);
|
|
camera_preview_shell.set_vexpand(false);
|
|
camera_preview_shell.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
|
|
let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
|
|
camera_preview_frame.set_hexpand(true);
|
|
camera_preview_frame.set_vexpand(false);
|
|
camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
|
|
camera_preview_frame.set_child(Some(&camera_preview));
|
|
camera_preview_shell.append(&camera_preview_frame);
|
|
preview_body.append(&camera_preview_shell);
|
|
|
|
let playback_group = build_subgroup("Mic Playback");
|
|
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
let playback_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
playback_row.set_homogeneous(true);
|
|
let microphone_replay_button = gtk::Button::with_label("Replay Last 3s");
|
|
stabilize_button(µphone_replay_button, 124);
|
|
let audio_preview_heading = gtk::Label::new(Some("Local Playback / Activity"));
|
|
audio_preview_heading.add_css_class("subgroup-title");
|
|
audio_preview_heading.set_hexpand(true);
|
|
audio_preview_heading.set_halign(gtk::Align::Start);
|
|
playback_row.append(µphone_replay_button);
|
|
playback_row.append(&audio_preview_heading);
|
|
playback_body.append(&playback_row);
|
|
playback_body.append(&audio_check_meter);
|
|
playback_group.append(&playback_body);
|
|
preview_body.append(&playback_group);
|
|
staging_row.append(&preview_panel);
|
|
|
|
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_width_chars(18);
|
|
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);
|
|
relay_actions_row.set_homogeneous(true);
|
|
let start_button = gtk::Button::with_label("Connect Relay");
|
|
start_button.add_css_class("suggested-action");
|
|
start_button.set_hexpand(true);
|
|
stabilize_button(&start_button, 180);
|
|
relay_actions_row.append(&start_button);
|
|
connection_body.append(&relay_actions_row);
|
|
|
|
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
live_actions_row.set_homogeneous(true);
|
|
let clipboard_button = gtk::Button::with_label("Send Clipboard");
|
|
clipboard_button.set_hexpand(true);
|
|
stabilize_button(&clipboard_button, 108);
|
|
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);
|
|
stabilize_button(&probe_button, 108);
|
|
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);
|
|
|
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
|
let power_heading = gtk::Label::new(Some("GPIO Power"));
|
|
power_heading.add_css_class("subgroup-title");
|
|
power_heading.set_halign(gtk::Align::Start);
|
|
connection_body.append(&power_heading);
|
|
|
|
let power_shell = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
power_shell.set_halign(gtk::Align::Center);
|
|
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
let power_on_button = gtk::Button::with_label("On");
|
|
stabilize_button(&power_on_button, 64);
|
|
power_on_button.add_css_class("pill-toggle");
|
|
let power_auto_button = gtk::Button::with_label("Auto");
|
|
stabilize_button(&power_auto_button, 64);
|
|
power_auto_button.add_css_class("pill-toggle");
|
|
let power_off_button = gtk::Button::with_label("Off");
|
|
stabilize_button(&power_off_button, 64);
|
|
power_off_button.add_css_class("pill-toggle");
|
|
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_on_button);
|
|
power_row.append(&power_auto_button);
|
|
power_row.append(&power_off_button);
|
|
power_shell.append(&power_row);
|
|
connection_body.append(&power_shell);
|
|
let routing_heading = gtk::Label::new(Some("Input Routing"));
|
|
routing_heading.add_css_class("subgroup-title");
|
|
routing_heading.set_halign(gtk::Align::Start);
|
|
connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
|
connection_body.append(&routing_heading);
|
|
|
|
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
routing_row.set_homogeneous(true);
|
|
let input_toggle_button = gtk::Button::with_label("Change Routing");
|
|
input_toggle_button.set_hexpand(true);
|
|
stabilize_button(&input_toggle_button, 128);
|
|
input_toggle_button.set_tooltip_text(Some(
|
|
"Change live keyboard and mouse ownership between this machine and the remote target.",
|
|
));
|
|
let swap_key_button = gtk::Button::with_label("Set Swap Key");
|
|
stabilize_button(&swap_key_button, 128);
|
|
routing_row.append(&input_toggle_button);
|
|
routing_row.append(&swap_key_button);
|
|
connection_body.append(&routing_row);
|
|
operations.append(&connection_panel);
|
|
|
|
let (console_panel, console_body) = build_panel("Session Console");
|
|
console_panel.set_vexpand(true);
|
|
let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
console_toolbar.set_homogeneous(true);
|
|
let console_copy_button = gtk::Button::with_label("Copy Log");
|
|
stabilize_button(&console_copy_button, 104);
|
|
let console_popout_button = gtk::Button::with_label("Break Out Log");
|
|
stabilize_button(&console_popout_button, 104);
|
|
console_toolbar.append(&console_copy_button);
|
|
console_toolbar.append(&console_popout_button);
|
|
let status_label = gtk::Label::new(Some("Session log ready."));
|
|
status_label.add_css_class("status-line");
|
|
status_label.set_halign(gtk::Align::Start);
|
|
status_label.set_wrap(true);
|
|
status_label.set_xalign(0.0);
|
|
let session_log_buffer = gtk::TextBuffer::new(None);
|
|
session_log_buffer.create_tag(Some("log-launcher"), &[("foreground", &"#8bd5ca")]);
|
|
session_log_buffer.create_tag(Some("log-relay"), &[("foreground", &"#89b4fa")]);
|
|
session_log_buffer.create_tag(Some("log-preview"), &[("foreground", &"#cba6f7")]);
|
|
session_log_buffer.create_tag(Some("log-stderr"), &[("foreground", &"#f9e2af")]);
|
|
session_log_buffer.create_tag(Some("log-warn"), &[("foreground", &"#fab387")]);
|
|
session_log_buffer.create_tag(Some("log-error"), &[("foreground", &"#f38ba8")]);
|
|
super::ui_runtime::append_session_log(&session_log_buffer, "[launcher] Session log ready.");
|
|
let session_log_view = gtk::TextView::with_buffer(&session_log_buffer);
|
|
session_log_view.add_css_class("status-log");
|
|
session_log_view.set_editable(false);
|
|
session_log_view.set_cursor_visible(false);
|
|
session_log_view.set_monospace(true);
|
|
session_log_view.set_wrap_mode(gtk::WrapMode::WordChar);
|
|
let log_scroll = gtk::ScrolledWindow::builder()
|
|
.hexpand(true)
|
|
.vexpand(true)
|
|
.min_content_height(220)
|
|
.child(&session_log_view)
|
|
.build();
|
|
console_body.append(&console_toolbar);
|
|
console_body.append(&log_scroll);
|
|
operations.append(&console_panel);
|
|
|
|
{
|
|
let buffer = session_log_buffer.clone();
|
|
let view = session_log_view.clone();
|
|
status_label.connect_notify_local(Some("label"), move |label, _| {
|
|
super::ui_runtime::append_session_log(&buffer, &format!("[launcher] {}", label.text()));
|
|
let mut end = buffer.end_iter();
|
|
view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
|
|
});
|
|
}
|
|
|
|
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 left_pane = left_pane;
|
|
let right_pane = right_pane;
|
|
if let Some(preview) = preview.as_ref() {
|
|
*left_pane.preview_binding.borrow_mut() = preview.install_on_picture(
|
|
0,
|
|
PreviewSurface::Inline,
|
|
&left_pane.picture,
|
|
&left_pane.stream_status,
|
|
);
|
|
*right_pane.preview_binding.borrow_mut() = 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");
|
|
}
|
|
sync_capture_size_combo(
|
|
&left_pane.capture_combo,
|
|
state.capture_size_options(),
|
|
state.capture_size_preset(0),
|
|
);
|
|
sync_capture_size_combo(
|
|
&right_pane.capture_combo,
|
|
state.capture_size_options(),
|
|
state.capture_size_preset(1),
|
|
);
|
|
sync_breakout_size_combo(
|
|
&left_pane.breakout_combo,
|
|
state.breakout_size_options(),
|
|
state.breakout_size_preset(0),
|
|
);
|
|
sync_breakout_size_combo(
|
|
&right_pane.breakout_combo,
|
|
state.breakout_size_options(),
|
|
state.breakout_size_preset(1),
|
|
);
|
|
|
|
let widgets = LauncherWidgets {
|
|
status_label: status_label.clone(),
|
|
session_log_buffer: session_log_buffer.clone(),
|
|
session_log_view: session_log_view.clone(),
|
|
summary: SummaryWidgets {
|
|
relay_light,
|
|
relay_value,
|
|
routing_light,
|
|
routing_value,
|
|
gpio_light,
|
|
gpio_value,
|
|
shortcut_value,
|
|
},
|
|
power_detail,
|
|
audio_check_detail,
|
|
audio_check_meter,
|
|
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(),
|
|
microphone_replay_button: microphone_replay_button.clone(),
|
|
speaker_test_button: speaker_test_button.clone(),
|
|
console_copy_button: console_copy_button.clone(),
|
|
console_popout_button: console_popout_button.clone(),
|
|
};
|
|
let popouts = Rc::new(RefCell::new([None, None]));
|
|
let log_popout = Rc::new(RefCell::new(None));
|
|
|
|
window.set_child(Some(&root));
|
|
|
|
LauncherView {
|
|
window,
|
|
server_entry,
|
|
camera_combo,
|
|
microphone_combo,
|
|
speaker_combo,
|
|
keyboard_combo,
|
|
mouse_combo,
|
|
device_stage: DeviceStageWidgets {
|
|
camera_preview,
|
|
camera_status,
|
|
},
|
|
widgets,
|
|
preview,
|
|
popouts,
|
|
log_popout,
|
|
}
|
|
}
|
|
|
|
pub fn install_css(window: >k::ApplicationWindow) {
|
|
let provider = gtk::CssProvider::new();
|
|
provider.load_from_data(
|
|
r#"
|
|
window.lesavka {
|
|
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: 10px;
|
|
}
|
|
box.subgroup {
|
|
background: rgba(255, 255, 255, 0.025);
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
border-radius: 14px;
|
|
padding: 8px;
|
|
}
|
|
label.panel-title {
|
|
font-weight: 700;
|
|
font-size: 1.05rem;
|
|
margin-bottom: 4px;
|
|
}
|
|
label.subgroup-title {
|
|
font-weight: 700;
|
|
opacity: 0.92;
|
|
}
|
|
label.version-tag {
|
|
font-size: 0.76rem;
|
|
opacity: 0.72;
|
|
margin-bottom: 3px;
|
|
}
|
|
box.status-chip {
|
|
background: rgba(91, 179, 162, 0.12);
|
|
border: 1px solid rgba(91, 179, 162, 0.25);
|
|
border-radius: 999px;
|
|
padding: 7px 10px;
|
|
}
|
|
box.status-light {
|
|
min-width: 10px;
|
|
min-height: 10px;
|
|
border-radius: 999px;
|
|
background: rgba(214, 81, 81, 0.92);
|
|
}
|
|
box.status-light-live {
|
|
background: rgba(96, 214, 126, 0.95);
|
|
}
|
|
box.status-light-idle {
|
|
background: rgba(214, 81, 81, 0.92);
|
|
}
|
|
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.9;
|
|
}
|
|
textview.status-log {
|
|
font-family: monospace;
|
|
background: rgba(0, 0, 0, 0.22);
|
|
border-radius: 14px;
|
|
padding: 10px;
|
|
}
|
|
progressbar.audio-check-meter trough {
|
|
min-height: 10px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
progressbar.audio-check-meter progress {
|
|
border-radius: 999px;
|
|
background: rgba(91, 179, 162, 0.88);
|
|
}
|
|
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");
|
|
}
|
|
|
|
pub fn install_window_icon(window: &impl IsA<gtk::Window>) {
|
|
if let Some(display) = gtk::gdk::Display::default() {
|
|
let theme = gtk::IconTheme::for_display(&display);
|
|
theme.add_search_path(LESAVKA_ICON_SEARCH_PATH);
|
|
}
|
|
gtk::Window::set_default_icon_name(LESAVKA_ICON_NAME);
|
|
window.as_ref().set_icon_name(Some(LESAVKA_ICON_NAME));
|
|
}
|
|
|
|
fn build_panel(title: &str) -> (gtk::Box, gtk::Box) {
|
|
let panel = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
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, 8);
|
|
panel.append(&body);
|
|
(panel, body)
|
|
}
|
|
|
|
fn build_subgroup(title: &str) -> gtk::Box {
|
|
let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
group.add_css_class("subgroup");
|
|
let heading = gtk::Label::new(Some(title));
|
|
heading.add_css_class("subgroup-title");
|
|
heading.set_halign(gtk::Align::Start);
|
|
group.append(&heading);
|
|
group
|
|
}
|
|
|
|
fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
|
|
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
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 build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) {
|
|
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
chip.add_css_class("status-chip");
|
|
|
|
let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
|
meta.add_css_class("status-chip-meta");
|
|
let light = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
light.add_css_class("status-light");
|
|
light.add_css_class("status-light-idle");
|
|
let label_widget = gtk::Label::new(Some(label));
|
|
label_widget.add_css_class("status-chip-label");
|
|
label_widget.set_halign(gtk::Align::Start);
|
|
meta.append(&light);
|
|
meta.append(&label_widget);
|
|
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(&meta);
|
|
chip.append(&value_widget);
|
|
(chip, light, value_widget)
|
|
}
|
|
|
|
fn stabilize_chip(chip: >k::Box, width: i32) {
|
|
chip.set_size_request(width, -1);
|
|
}
|
|
|
|
pub fn sync_capture_size_combo(
|
|
combo: >k::ComboBoxText,
|
|
options: Vec<CaptureSizeChoice>,
|
|
selected: CaptureSizePreset,
|
|
) {
|
|
combo.remove_all();
|
|
for option in options {
|
|
let label = match option.preset {
|
|
CaptureSizePreset::Source => format!(
|
|
"{}x{} @ {} fps • {} kbit (Source Size)",
|
|
option.width, option.height, option.fps, option.max_bitrate_kbit
|
|
),
|
|
_ => format!(
|
|
"{}x{} @ {} fps • {} kbit",
|
|
option.width, option.height, option.fps, option.max_bitrate_kbit
|
|
),
|
|
};
|
|
combo.append(Some(option.preset.as_id()), &label);
|
|
}
|
|
combo.set_active_id(Some(selected.as_id()));
|
|
}
|
|
|
|
pub fn sync_breakout_size_combo(
|
|
combo: >k::ComboBoxText,
|
|
options: Vec<BreakoutSizeChoice>,
|
|
selected: BreakoutSizePreset,
|
|
) {
|
|
combo.remove_all();
|
|
for option in options {
|
|
let label = match option.preset {
|
|
BreakoutSizePreset::Source => {
|
|
format!("{}x{} (Source Size)", option.width, option.height)
|
|
}
|
|
BreakoutSizePreset::FillDisplay => {
|
|
format!("{}x{} (Display Size)", option.width, option.height)
|
|
}
|
|
_ => format!("{}x{}", option.width, option.height),
|
|
};
|
|
combo.append(Some(option.preset.as_id()), &label);
|
|
}
|
|
combo.set_active_id(Some(selected.as_id()));
|
|
}
|
|
|
|
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_selector_block(label: &str, combo: >k::ComboBoxText) -> gtk::Box {
|
|
let block = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
let label_widget = gtk::Label::new(Some(label));
|
|
label_widget.set_halign(gtk::Align::Start);
|
|
combo.set_hexpand(true);
|
|
combo.set_size_request(0, -1);
|
|
block.append(&label_widget);
|
|
block.append(combo);
|
|
block
|
|
}
|
|
|
|
fn append_input_choice(combo: >k::ComboBoxText, value: &str) {
|
|
let short = value.rsplit('/').next().unwrap_or(value);
|
|
let label = Device::open(value)
|
|
.ok()
|
|
.and_then(|device| device.name().map(|name| format!("{name} • {short}")))
|
|
.unwrap_or_else(|| short.to_string());
|
|
combo.append(Some(value), &label);
|
|
}
|
|
|
|
fn append_stage_choice(combo: >k::ComboBoxText, value: &str) {
|
|
combo.append(Some(value), &compact_stage_label(value));
|
|
}
|
|
|
|
fn compact_stage_label(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return "auto".to_string();
|
|
}
|
|
if let Some(short) = trimmed.rsplit('/').next()
|
|
&& short != trimmed
|
|
{
|
|
return shorten_label(short);
|
|
}
|
|
if let Some(rest) = trimmed
|
|
.strip_prefix("alsa_input.")
|
|
.or_else(|| trimmed.strip_prefix("alsa_output."))
|
|
{
|
|
return shorten_label(rest);
|
|
}
|
|
shorten_label(trimmed)
|
|
}
|
|
|
|
fn shorten_label(value: &str) -> String {
|
|
const MAX: usize = 44;
|
|
let compact = value.replace('_', " ");
|
|
let mut chars = compact.chars();
|
|
let preview: String = chars.by_ref().take(MAX).collect();
|
|
if chars.next().is_some() {
|
|
format!("{preview}…")
|
|
} else {
|
|
preview
|
|
}
|
|
}
|
|
|
|
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(220, 124);
|
|
|
|
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(220, 124);
|
|
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);
|
|
stream_status.set_ellipsize(pango::EllipsizeMode::End);
|
|
stream_status.set_single_line_mode(true);
|
|
stream_status.set_max_width_chars(24);
|
|
stream_status.set_tooltip_text(Some("Connect relay to preview."));
|
|
let capture_combo = gtk::ComboBoxText::new();
|
|
capture_combo.set_tooltip_text(Some(
|
|
"Choose the server-side capture profile for this eye feed: resolution, target fps, and bitrate.",
|
|
));
|
|
capture_combo.set_size_request(272, -1);
|
|
let breakout_combo = gtk::ComboBoxText::new();
|
|
breakout_combo.set_tooltip_text(Some(
|
|
"Choose the client-side breakout window size for this eye feed.",
|
|
));
|
|
breakout_combo.set_size_request(180, -1);
|
|
let action_button = gtk::Button::with_label("Break Out");
|
|
stabilize_button(&action_button, 104);
|
|
action_button.set_halign(gtk::Align::End);
|
|
footer.append(&capture_combo);
|
|
footer.append(&breakout_combo);
|
|
footer.append(&action_button);
|
|
root.append(&footer);
|
|
|
|
DisplayPaneWidgets {
|
|
root,
|
|
stack,
|
|
picture,
|
|
stream_status,
|
|
placeholder,
|
|
capture_combo,
|
|
breakout_combo,
|
|
action_button,
|
|
preview_binding: Rc::new(RefCell::new(None)),
|
|
title: title.to_string(),
|
|
}
|
|
}
|
|
|
|
fn stabilize_button(button: >k::Button, width: i32) {
|
|
button.set_size_request(width, 36);
|
|
}
|