lesavka/client/src/launcher/ui_runtime.rs

458 lines
14 KiB
Rust
Raw Normal View History

use anyhow::Result;
use gtk::{glib, prelude::*};
use std::{
cell::RefCell,
path::{Path, PathBuf},
process::{Child, Command},
rc::Rc,
};
use super::{
LAUNCHER_FOCUS_SIGNAL_ENV,
device_test::{DeviceTestController, DeviceTestKind},
launcher_focus_signal_path,
preview::LauncherPreview,
runtime_env_vars,
state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle},
};
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
widgets
.summary
.relay_value
.set_text(if child_running || state.remote_active {
"Running"
} else {
"Stopped"
});
widgets
.summary
.routing_value
.set_text(&capitalize(routing_name(state.routing)));
widgets
.summary
.power_value
.set_text(&capture_power_label(&state.capture_power));
widgets.summary.displays_value.set_text(&format!(
"L {} / R {}",
state.display_surface(0).label(),
state.display_surface(1).label()
));
widgets
.summary
.shortcut_value
.set_text(&selected_toggle_key_label(&widgets.toggle_key_combo));
widgets
.power_detail
.set_text(&capture_power_detail(&state.capture_power));
widgets.start_button.set_sensitive(!child_running);
widgets.stop_button.set_sensitive(child_running);
widgets.clipboard_button.set_sensitive(child_running);
widgets.probe_button.set_sensitive(true);
widgets.input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Route Inputs To Local",
InputRouting::Local => "Route Inputs To Remote",
});
let power_available = state.capture_power.available;
widgets
.power_auto_button
.set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto"));
widgets.power_on_button.set_sensitive(
power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"),
);
widgets.power_off_button.set_sensitive(
power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"),
);
for monitor_id in 0..2 {
refresh_display_pane(
&widgets.display_panes[monitor_id],
state.display_surface(monitor_id),
);
}
}
pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) {
widgets
.camera_test_button
.set_label(if tests.is_running(DeviceTestKind::Camera) {
"Stop Preview"
} else {
"Start Preview"
});
widgets
.microphone_test_button
.set_label(if tests.is_running(DeviceTestKind::Microphone) {
"Stop Monitor"
} else {
"Monitor Mic"
});
widgets
.speaker_test_button
.set_label(if tests.is_running(DeviceTestKind::Speaker) {
"Stop Tone"
} else {
"Play Tone"
});
}
pub fn update_test_action_result(
widgets: &LauncherWidgets,
tests: &mut DeviceTestController,
result: Result<bool>,
start_msg: &str,
stop_msg: &str,
) {
match result {
Ok(true) => widgets.status_label.set_text(start_msg),
Ok(false) => widgets.status_label.set_text(stop_msg),
Err(err) => widgets
.status_label
.set_text(&format!("Device test failed: {err}")),
}
refresh_test_buttons(widgets, tests);
}
pub fn open_popout_window(
app: &gtk::Application,
preview: &LauncherPreview,
state: &Rc<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
widgets: &LauncherWidgets,
monitor_id: usize,
) {
if popouts.borrow()[monitor_id].is_some() {
return;
}
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
binding.set_enabled(false);
}
let window = gtk::ApplicationWindow::builder()
.application(app)
.title(&format!(
"Lesavka {}",
widgets.display_panes[monitor_id].title
))
.default_width(1280)
.default_height(760)
.build();
super::ui_components::install_css(&window);
window.maximize();
let root = gtk::Box::new(gtk::Orientation::Vertical, 10);
root.set_margin_start(14);
root.set_margin_end(14);
root.set_margin_top(14);
root.set_margin_bottom(14);
let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title));
title.add_css_class("title-3");
title.set_halign(gtk::Align::Center);
root.append(&title);
let picture = gtk::Picture::new();
picture.set_hexpand(true);
picture.set_vexpand(true);
picture.set_can_shrink(true);
root.append(&picture);
let stream_status = gtk::Label::new(Some("Waiting for stream..."));
stream_status.set_halign(gtk::Align::Start);
root.append(&stream_status);
let binding = preview
.install_on_picture(monitor_id, &picture, &stream_status)
.expect("preview binding for popout");
window.set_child(Some(&root));
let state_handle = Rc::clone(state);
let child_proc_handle = Rc::clone(child_proc);
let popouts_handle = Rc::clone(popouts);
let widgets_handle = widgets.clone();
let close_binding = binding.clone();
window.connect_close_request(move |_| {
let handle = {
let mut popouts = popouts_handle.borrow_mut();
popouts[monitor_id].take()
};
if let Some(handle) = handle {
handle.binding.close();
if let Some(preview_binding) = widgets_handle.display_panes[monitor_id]
.preview_binding
.as_ref()
{
preview_binding.set_enabled(true);
}
state_handle
.borrow_mut()
.set_display_surface(monitor_id, DisplaySurface::Preview);
refresh_launcher_ui(
&widgets_handle,
&state_handle.borrow(),
child_proc_handle.borrow().is_some(),
);
} else {
close_binding.close();
}
glib::Propagation::Proceed
});
state
.borrow_mut()
.set_display_surface(monitor_id, DisplaySurface::Window);
popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle {
window: window.clone(),
binding,
});
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
window.present();
}
pub fn dock_display_to_preview(
state: &Rc<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
widgets: &LauncherWidgets,
monitor_id: usize,
) {
let handle = {
let mut popouts = popouts.borrow_mut();
popouts[monitor_id].take()
};
if let Some(handle) = handle {
handle.binding.close();
handle.window.close();
}
if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() {
binding.set_enabled(true);
}
state
.borrow_mut()
.set_display_surface(monitor_id, DisplaySurface::Preview);
refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some());
}
pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) {
if let Some(binding) = pane.preview_binding.as_ref() {
binding.set_enabled(matches!(surface, DisplaySurface::Preview));
}
pane.action_button
.set_sensitive(pane.preview_binding.is_some());
match surface {
DisplaySurface::Preview => {
pane.stack.set_visible_child_name("preview");
pane.action_button.set_label("Break Out");
pane.placeholder.set_text(
"This feed is running in its own window.\nUse Return To Preview to dock it back here.",
);
if pane.preview_binding.is_none() {
pane.stream_status.set_text("Preview unavailable");
}
}
DisplaySurface::Window => {
pane.stack.set_visible_child_name("placeholder");
pane.action_button.set_label("Return To Preview");
pane.placeholder.set_text(&format!(
"{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.",
pane.title
));
pane.stream_status.set_text("Streaming in its own window");
}
}
}
pub fn capture_power_label(power: &CapturePowerStatus) -> String {
if !power.available {
return "Unavailable".to_string();
}
match power.mode.as_str() {
"forced-on" => "Forced On".to_string(),
"forced-off" => "Forced Off".to_string(),
_ => {
if power.enabled {
"Auto • Live".to_string()
} else {
"Auto • Standby".to_string()
}
}
}
}
pub fn capture_power_detail(power: &CapturePowerStatus) -> String {
if !power.available {
return format!("{} is unavailable: {}", power.unit, power.detail);
}
match power.mode.as_str() {
"forced-on" => format!(
"{} • manual override holding feeds up • {} • leases {}",
power.unit, power.detail, power.active_leases
),
"forced-off" => format!(
"{} • manual override holding feeds down • {} • leases {}",
power.unit, power.detail, power.active_leases
),
_ => format!(
"{} • automatic mode follows live previews and session demand • {} • leases {}",
power.unit, power.detail, power.active_leases
),
}
}
pub fn capitalize(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
}
pub fn selected_combo_value(combo: &gtk::ComboBoxText) -> Option<String> {
combo.active_text().and_then(|value| {
let value = value.to_string();
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
None
} else {
Some(trimmed.to_string())
}
})
}
pub fn selected_toggle_key(combo: &gtk::ComboBoxText) -> String {
combo
.active_id()
.map(|value| value.to_string())
.unwrap_or_else(|| "pause".to_string())
}
pub fn selected_toggle_key_label(combo: &gtk::ComboBoxText) -> String {
combo
.active_text()
.map(|value| value.to_string())
.unwrap_or_else(|| "Pause".to_string())
}
pub fn selected_server_addr(entry: &gtk::Entry, fallback: &str) -> String {
let current = entry.text();
let trimmed = current.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
pub fn input_control_path() -> PathBuf {
std::env::var(INPUT_CONTROL_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH))
}
pub fn input_state_path() -> PathBuf {
std::env::var(INPUT_STATE_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
}
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
Ok(())
}
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
let raw = std::fs::read_to_string(path).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"local" => Some(InputRouting::Local),
"remote" => Some(InputRouting::Remote),
_ => None,
}
}
pub fn routing_name(routing: InputRouting) -> &'static str {
match routing {
InputRouting::Local => "local",
InputRouting::Remote => "remote",
}
}
pub fn path_marker(path: &Path) -> u128 {
std::fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
pub fn set_combo_active_text(combo: &gtk::ComboBoxText, wanted: Option<&str>) {
let wanted = wanted.unwrap_or("auto");
if !combo.set_active_id(Some(wanted)) {
let _ = combo.set_active_id(Some("auto"));
}
}
pub fn spawn_client_process(
server_addr: &str,
state: &LauncherState,
input_toggle_key: &str,
input_control_path: &Path,
input_state_path: &Path,
) -> Result<Child> {
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command.env("LESAVKA_LAUNCHER_CHILD", "1");
command.env("LESAVKA_SERVER_ADDR", server_addr);
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher");
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
command.env(INPUT_CONTROL_ENV, input_control_path);
command.env(INPUT_STATE_ENV, input_state_path);
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
command.env("LESAVKA_CLIPBOARD_PASTE", "0");
for (key, value) in runtime_env_vars(state) {
command.env(key, value);
}
Ok(command.spawn()?)
}
pub fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<Child>>>) -> bool {
let mut slot = child_proc.borrow_mut();
match slot.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(_)) => {
*slot = None;
false
}
Ok(None) | Err(_) => true,
},
None => false,
}
}
pub fn next_input_routing(routing: InputRouting) -> InputRouting {
match routing {
InputRouting::Remote => InputRouting::Local,
InputRouting::Local => InputRouting::Remote,
}
}