lesavka/client/src/launcher/ui_components.rs

1151 lines
42 KiB
Rust

use std::{cell::RefCell, rc::Rc};
use evdev::Device;
use gtk::{pango, prelude::*};
use super::{
devices::DeviceCatalog,
diagnostics::DiagnosticsLog,
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset,
FeedSourceChoice, FeedSourcePreset, 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 feed_source_combo: gtk::ComboBoxText,
pub capture_resolution_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 diagnostics_log: Rc<RefCell<DiagnosticsLog>>,
pub diagnostics_buffer: gtk::TextBuffer,
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 diagnostics_copy_button: gtk::Button,
pub diagnostics_popout_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 diagnostics_popout: Rc<RefCell<Option<gtk::ApplicationWindow>>>,
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 = 1380;
const LAUNCHER_DEFAULT_HEIGHT: i32 = 860;
const OPERATIONS_RAIL_WIDTH: i32 = 288;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 178;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 316;
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")
.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, 102);
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(0, -1);
speaker_combo.set_size_request(0, -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(&microphone_combo, 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");
stabilize_button(&microphone_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(0, -1);
attach_device_row(
&media_grid,
2,
"Microphone",
&microphone_combo,
&microphone_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(&microphone_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(&microphone_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(&gtk::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(&gtk::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 (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics");
let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
diagnostics_toolbar.set_homogeneous(true);
let diagnostics_copy_button = gtk::Button::with_label("Copy Report");
stabilize_button(&diagnostics_copy_button, 112);
let diagnostics_popout_button = gtk::Button::with_label("Break Out");
stabilize_button(&diagnostics_popout_button, 112);
diagnostics_toolbar.append(&diagnostics_copy_button);
diagnostics_toolbar.append(&diagnostics_popout_button);
let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16)));
let diagnostics_buffer = gtk::TextBuffer::new(None);
let diagnostics_view = gtk::TextView::with_buffer(&diagnostics_buffer);
diagnostics_view.add_css_class("status-log");
diagnostics_view.set_editable(false);
diagnostics_view.set_cursor_visible(false);
diagnostics_view.set_monospace(true);
diagnostics_view.set_wrap_mode(gtk::WrapMode::WordChar);
let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(false)
.min_content_height(190)
.child(&diagnostics_view)
.build();
diagnostics_body.append(&diagnostics_toolbar);
diagnostics_body.append(&diagnostics_scroll);
operations.append(&diagnostics_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() =
if state.feed_source_preset(0) == FeedSourcePreset::Off {
None
} else {
preview.install_on_picture(
0,
PreviewSurface::Inline,
&left_pane.picture,
&left_pane.stream_status,
)
};
*right_pane.preview_binding.borrow_mut() =
if state.feed_source_preset(1) == FeedSourcePreset::Off {
None
} else {
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_feed_source_combo(
&left_pane.feed_source_combo,
state.feed_source_options(0),
state.feed_source_preset(0),
);
sync_feed_source_combo(
&right_pane.feed_source_combo,
state.feed_source_options(1),
state.feed_source_preset(1),
);
if state.feed_source_preset(0) != FeedSourcePreset::Off {
let choice = state
.display_capture_size_choice(0)
.unwrap_or_else(|| state.capture_size_choice(0));
if state.feed_source_preset(0) == FeedSourcePreset::ThisEye {
sync_capture_resolution_combo(
&left_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(0),
);
} else {
sync_capture_resolution_locked(
&left_pane.capture_resolution_combo,
state.capture_size_options(),
choice.preset,
);
}
} else {
sync_capture_resolution_disabled(&left_pane.capture_resolution_combo);
}
if state.feed_source_preset(1) != FeedSourcePreset::Off {
let choice = state
.display_capture_size_choice(1)
.unwrap_or_else(|| state.capture_size_choice(1));
if state.feed_source_preset(1) == FeedSourcePreset::ThisEye {
sync_capture_resolution_combo(
&right_pane.capture_resolution_combo,
state.capture_size_options(),
state.capture_size_preset(1),
);
} else {
sync_capture_resolution_locked(
&right_pane.capture_resolution_combo,
state.capture_size_options(),
choice.preset,
);
}
} else {
sync_capture_resolution_disabled(&right_pane.capture_resolution_combo);
}
sync_breakout_size_combo(
&left_pane.breakout_combo,
state.breakout_size_options(0),
state.breakout_size_preset(0),
);
sync_breakout_size_combo(
&right_pane.breakout_combo,
state.breakout_size_options(1),
state.breakout_size_preset(1),
);
let widgets = LauncherWidgets {
status_label: status_label.clone(),
diagnostics_log: diagnostics_log.clone(),
diagnostics_buffer: diagnostics_buffer.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(),
diagnostics_copy_button: diagnostics_copy_button.clone(),
diagnostics_popout_button: diagnostics_popout_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 diagnostics_popout = Rc::new(RefCell::new(None));
let log_popout = Rc::new(RefCell::new(None));
super::ui_runtime::refresh_diagnostics_report(&widgets, state, false);
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,
diagnostics_popout,
log_popout,
}
}
pub fn install_css(window: &gtk::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: &gtk::Box, width: i32) {
chip.set_size_request(width, -1);
}
pub fn sync_feed_source_combo(
combo: &gtk::ComboBoxText,
options: Vec<FeedSourceChoice>,
selected: FeedSourcePreset,
) {
combo.remove_all();
for option in options {
combo.append(Some(option.preset.as_id()), option.label);
}
combo.set_active_id(Some(selected.as_id()));
combo.set_sensitive(true);
}
pub fn sync_capture_resolution_combo(
combo: &gtk::ComboBoxText,
options: Vec<CaptureSizeChoice>,
selected: CaptureSizePreset,
) {
combo.remove_all();
let option_count = options.len();
for option in options {
let label = format!(
"{}{}x{} @ {} fps (Device H.264)",
option.preset.label(),
option.width,
option.height,
option.fps,
);
combo.append(Some(option.preset.as_id()), &label);
}
combo.set_active_id(Some(selected.as_id()));
combo.set_sensitive(option_count > 1);
}
pub fn sync_capture_resolution_locked(
combo: &gtk::ComboBoxText,
options: Vec<CaptureSizeChoice>,
selected: CaptureSizePreset,
) {
sync_capture_resolution_combo(combo, options, selected);
combo.set_sensitive(false);
}
pub fn sync_capture_resolution_disabled(combo: &gtk::ComboBoxText) {
combo.remove_all();
combo.append(Some("off"), "Feed disabled");
combo.set_active_id(Some("off"));
combo.set_sensitive(false);
}
pub fn sync_breakout_size_combo(
combo: &gtk::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.preset.label(),
option.width,
option.height
)
}
BreakoutSizePreset::FillDisplay => {
format!(
"{}{}x{} (Display Size)",
option.preset.label(),
option.width,
option.height
)
}
_ => format!(
"{}{}x{}",
option.preset.label(),
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: &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_selector_block(label: &str, combo: &gtk::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: &gtk::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: &gtk::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_halign(gtk::Align::Fill);
picture.set_valign(gtk::Align::Fill);
picture.set_can_shrink(true);
picture.set_keep_aspect_ratio(true);
picture.set_size_request(220, 124);
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
preview_box.set_hexpand(true);
preview_box.set_vexpand(true);
preview_box.set_halign(gtk::Align::Fill);
preview_box.set_valign(gtk::Align::Fill);
preview_box.set_size_request(220, 124);
let preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
preview_frame.set_hexpand(true);
preview_frame.set_vexpand(true);
preview_frame.set_halign(gtk::Align::Fill);
preview_frame.set_valign(gtk::Align::Fill);
preview_frame.set_size_request(220, 124);
preview_frame.set_child(Some(&picture));
preview_box.append(&preview_frame);
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 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 feed_source_combo = gtk::ComboBoxText::new();
feed_source_combo.set_tooltip_text(Some(
"Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.",
));
feed_source_combo.set_size_request(118, -1);
let capture_resolution_combo = gtk::ComboBoxText::new();
capture_resolution_combo.set_tooltip_text(Some(
"Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.",
));
capture_resolution_combo.set_size_request(0, -1);
capture_resolution_combo.set_hexpand(true);
let breakout_combo = gtk::ComboBoxText::new();
breakout_combo.set_tooltip_text(Some(
"Choose the client-side breakout window size for this eye feed. Source Size preserves the feed's own dimensions; Display Size fills the effective monitor size.",
));
breakout_combo.set_size_request(0, -1);
breakout_combo.set_hexpand(true);
let action_button = gtk::Button::with_label("Break Out");
stabilize_button(&action_button, 104);
action_button.set_halign(gtk::Align::End);
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
let controls_grid = gtk::Grid::new();
controls_grid.set_column_spacing(8);
controls_grid.set_row_spacing(8);
controls_grid.set_hexpand(true);
controls_grid.attach(&feed_source_combo, 0, 0, 1, 1);
controls_grid.attach(&capture_resolution_combo, 1, 0, 1, 1);
controls_grid.attach(&breakout_combo, 0, 1, 1, 1);
controls_grid.attach(&action_button, 1, 1, 1, 1);
footer_shell.append(&controls_grid);
root.append(&footer_shell);
DisplayPaneWidgets {
root,
stack,
picture,
stream_status,
placeholder,
feed_source_combo,
capture_resolution_combo,
breakout_combo,
action_button,
preview_binding: Rc::new(RefCell::new(None)),
title: title.to_string(),
}
}
fn stabilize_button(button: &gtk::Button, width: i32) {
button.set_size_request(width, 36);
}