shepherd-launcher/crates/shepherd-hud/src/app.rs
2025-12-28 08:36:54 -05:00

493 lines
15 KiB
Rust

//! HUD Application
//!
//! The main GTK4 application for the HUD overlay.
//! Uses gtk4-layer-shell to create an always-visible overlay.
use crate::battery::BatteryStatus;
use crate::state::{SessionState, SharedState};
use crate::time_display::TimeDisplay;
use crate::volume::VolumeStatus;
use gtk4::glib;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use shepherd_api::Command;
use shepherd_ipc::IpcClient;
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
use tokio::runtime::Runtime;
/// The HUD application
pub struct HudApp {
app: gtk4::Application,
socket_path: PathBuf,
anchor: String,
height: i32,
}
impl HudApp {
pub fn new(socket_path: PathBuf, anchor: String, height: i32) -> Self {
let app = gtk4::Application::builder()
.application_id("org.shepherd.hud")
.build();
Self {
app,
socket_path,
anchor,
height,
}
}
pub fn run(&self) -> i32 {
let socket_path = self.socket_path.clone();
let anchor = self.anchor.clone();
let height = self.height;
self.app.connect_activate(move |app| {
let state = SharedState::new();
let window = build_hud_window(app, &anchor, height, state.clone());
// Start the IPC event listener
let state_clone = state.clone();
let socket_clone = socket_path.clone();
std::thread::spawn(move || {
if let Err(e) = run_event_loop(socket_clone, state_clone) {
tracing::error!("Event loop error: {}", e);
}
});
// Start periodic updates for battery/volume
start_metrics_updates(state.clone());
// Subscribe to state changes
let window_clone = window.clone();
let state_clone = state.clone();
glib::timeout_add_local(Duration::from_millis(100), move || {
let session_state = state_clone.session_state();
let visible = session_state.is_visible();
window_clone.set_visible(visible);
glib::ControlFlow::Continue
});
window.present();
});
self.app.run().into()
}
}
fn build_hud_window(
app: &gtk4::Application,
anchor: &str,
height: i32,
state: SharedState,
) -> gtk4::ApplicationWindow {
let window = gtk4::ApplicationWindow::builder()
.application(app)
.default_height(height)
.decorated(false)
.build();
// Initialize layer shell
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_namespace("shepherd-hud");
// Remove all margins from the layer-shell surface
window.set_margin(Edge::Top, 0);
window.set_margin(Edge::Bottom, 0);
window.set_margin(Edge::Left, 0);
window.set_margin(Edge::Right, 0);
// Set anchors based on position
match anchor {
"bottom" => {
window.set_anchor(Edge::Bottom, true);
window.set_anchor(Edge::Left, true);
window.set_anchor(Edge::Right, true);
}
_ => {
// Default to top
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Left, true);
window.set_anchor(Edge::Right, true);
}
}
// Set exclusive zone so other windows don't overlap
window.set_exclusive_zone(height);
// Load CSS
load_css();
// Build the HUD content
let content = build_hud_content(state);
window.set_child(Some(&content));
window
}
fn build_hud_content(state: SharedState) -> gtk4::Box {
let container = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(16)
.hexpand(true)
.build();
container.add_css_class("hud-bar");
// Left section: App name and time
let left_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(12)
.hexpand(true)
.halign(gtk4::Align::Start)
.build();
let app_label = gtk4::Label::new(Some("No session"));
app_label.add_css_class("app-name");
left_box.append(&app_label);
let time_display = TimeDisplay::new();
left_box.append(&time_display);
container.append(&left_box);
// Center section: Warning banner (hidden by default)
let warning_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.halign(gtk4::Align::Center)
.visible(false)
.build();
let warning_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning_icon.set_pixel_size(20);
warning_box.append(&warning_icon);
let warning_label = gtk4::Label::new(Some("Time running out!"));
warning_label.add_css_class("warning-text");
warning_box.append(&warning_label);
warning_box.add_css_class("warning-banner");
container.append(&warning_box);
// Right section: System indicators and close button
let right_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.halign(gtk4::Align::End)
.build();
// Volume indicator
let volume_button = gtk4::Button::builder()
.icon_name("audio-volume-medium-symbolic")
.has_frame(false)
.build();
volume_button.add_css_class("indicator-button");
volume_button.connect_clicked(|_| {
if let Err(e) = VolumeStatus::toggle_mute() {
tracing::error!("Failed to toggle mute: {}", e);
}
});
right_box.append(&volume_button);
// Battery indicator
let battery_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.build();
let battery_icon = gtk4::Image::from_icon_name("battery-good-symbolic");
battery_icon.set_pixel_size(20);
battery_box.append(&battery_icon);
let battery_label = gtk4::Label::new(Some("--%"));
battery_label.add_css_class("battery-label");
battery_box.append(&battery_label);
right_box.append(&battery_box);
// Pause button
let pause_button = gtk4::Button::builder()
.icon_name("media-playback-pause-symbolic")
.has_frame(false)
.tooltip_text("Pause session")
.build();
pause_button.add_css_class("control-button");
let state_for_pause = state.clone();
pause_button.connect_clicked(move |btn| {
let session_state = state_for_pause.session_state();
if let Some(session_id) = session_state.session_id() {
// Toggle pause state - this would need to send command to daemon
// For now, just log
tracing::info!("Pause toggled for session {}", session_id);
}
// Toggle icon
let icon_name = btn.icon_name().unwrap_or_default();
if icon_name == "media-playback-pause-symbolic" {
btn.set_icon_name("media-playback-start-symbolic");
btn.set_tooltip_text(Some("Resume session"));
} else {
btn.set_icon_name("media-playback-pause-symbolic");
btn.set_tooltip_text(Some("Pause session"));
}
});
right_box.append(&pause_button);
// Close button
let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.has_frame(false)
.tooltip_text("End session")
.build();
close_button.add_css_class("close-button");
let state_for_close = state.clone();
close_button.connect_clicked(move |_| {
let session_state = state_for_close.session_state();
if let Some(session_id) = session_state.session_id() {
tracing::info!("Requesting end session for {}", session_id);
// Send StopCurrent command to daemon
let socket_path = std::env::var("SHEPHERD_SOCKET")
.unwrap_or_else(|_| "./dev-runtime/shepherd.sock".to_string());
std::thread::spawn(move || {
let rt = Runtime::new().expect("Failed to create runtime");
rt.block_on(async {
match IpcClient::connect(std::path::PathBuf::from(&socket_path)).await {
Ok(mut client) => {
let cmd = Command::StopCurrent {
mode: shepherd_api::StopMode::Graceful,
};
if let Err(e) = client.send(cmd).await {
tracing::error!("Failed to send StopCurrent: {}", e);
}
}
Err(e) => {
tracing::error!("Failed to connect to daemon: {}", e);
}
}
});
});
}
});
right_box.append(&close_button);
container.append(&right_box);
// Set up state updates
let app_label_clone = app_label.clone();
let time_display_clone = time_display.clone();
let warning_box_clone = warning_box.clone();
let warning_label_clone = warning_label.clone();
let battery_icon_clone = battery_icon.clone();
let battery_label_clone = battery_label.clone();
let volume_button_clone = volume_button.clone();
glib::timeout_add_local(Duration::from_millis(500), move || {
// Update session state
let session_state = state.session_state();
match &session_state {
SessionState::NoSession => {
app_label_clone.set_text("No session");
time_display_clone.set_remaining(None);
warning_box_clone.set_visible(false);
}
SessionState::Active {
entry_name,
started_at,
time_limit_secs,
paused,
..
} => {
app_label_clone.set_text(entry_name);
// Calculate remaining time based on elapsed time since session start
let remaining = time_limit_secs.map(|limit| {
let elapsed = started_at.elapsed().as_secs();
limit.saturating_sub(elapsed)
});
time_display_clone.set_remaining(remaining);
time_display_clone.set_paused(*paused);
warning_box_clone.set_visible(false);
}
SessionState::Warning {
entry_name,
warning_issued_at,
time_remaining_at_warning,
..
} => {
app_label_clone.set_text(entry_name);
// Calculate remaining time based on elapsed time since warning was issued
let elapsed = warning_issued_at.elapsed().as_secs();
let remaining = time_remaining_at_warning.saturating_sub(elapsed);
time_display_clone.set_remaining(Some(remaining));
warning_label_clone.set_text(&format!(
"Only {} seconds remaining!",
remaining
));
warning_box_clone.set_visible(true);
}
SessionState::Ending { reason, .. } => {
app_label_clone.set_text("Session ending...");
warning_label_clone.set_text(reason);
warning_box_clone.set_visible(true);
}
}
// Update battery
let battery = BatteryStatus::read();
battery_icon_clone.set_icon_name(Some(battery.icon_name()));
if let Some(percent) = battery.percent {
battery_label_clone.set_text(&format!("{}%", percent));
} else {
battery_label_clone.set_text("--%");
}
// Update volume
let volume = VolumeStatus::read();
volume_button_clone.set_icon_name(volume.icon_name());
glib::ControlFlow::Continue
});
container
}
fn load_css() {
let css = r#"
.hud-bar {
background-color: rgba(30, 30, 30, 0.95);
border: none;
margin: 0;
padding: 6px 12px;
}
.app-name {
font-weight: bold;
font-size: 14px;
color: white;
}
.time-display {
font-family: monospace;
font-size: 14px;
color: #88c0d0;
}
.time-display.time-warning {
color: #ebcb8b;
}
.time-display.time-critical {
color: #bf616a;
animation: blink 1s infinite;
}
@keyframes blink {
50% { opacity: 0.5; }
}
.warning-banner {
background-color: rgba(235, 203, 139, 0.2);
border-radius: 4px;
padding: 4px 12px;
}
.warning-text {
color: #ebcb8b;
font-weight: bold;
}
.indicator-button,
.control-button {
min-width: 32px;
min-height: 32px;
padding: 4px;
border-radius: 4px;
}
.indicator-button:hover,
.control-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.close-button {
min-width: 32px;
min-height: 32px;
padding: 4px;
border-radius: 4px;
color: #bf616a;
}
.close-button:hover {
background-color: rgba(191, 97, 106, 0.3);
}
.battery-label {
font-size: 12px;
color: #a3be8c;
}
"#;
let provider = gtk4::CssProvider::new();
provider.load_from_data(css);
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().expect("Could not get display"),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn run_event_loop(socket_path: PathBuf, state: SharedState) -> anyhow::Result<()> {
let rt = Runtime::new()?;
rt.block_on(async {
loop {
tracing::info!("Connecting to shepherdd at {:?}", socket_path);
match IpcClient::connect(&socket_path).await {
Ok(client) => {
tracing::info!("Connected to shepherdd");
let mut stream = match client.subscribe().await {
Ok(stream) => stream,
Err(e) => {
tracing::error!("Failed to subscribe: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
loop {
match stream.next().await {
Ok(event) => {
tracing::debug!("Received event: {:?}", event);
state.handle_event(&event);
}
Err(e) => {
tracing::error!("Event stream error: {}", e);
break;
}
}
}
}
Err(e) => {
tracing::warn!("Failed to connect to shepherdd: {}", e);
}
}
// Wait before reconnecting
tokio::time::sleep(Duration::from_secs(2)).await;
}
})
}
fn start_metrics_updates(_state: SharedState) {
// Battery and volume are now updated in the main UI loop
// This function could be used for more expensive operations
// that don't need to run as frequently
}