//! 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: >k4::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( >k4::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 }