diff --git a/Cargo.lock b/Cargo.lock index 5b8f52a..3f8a817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1068,6 +1068,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "glow", "glutin", "glutin-winit", "image", diff --git a/client_node/Cargo.toml b/client_node/Cargo.toml index f0ac548..4c4a64e 100644 --- a/client_node/Cargo.toml +++ b/client_node/Cargo.toml @@ -3,11 +3,15 @@ name = "client_node" version = "0.1.0" edition = "2024" +[features] +default = ["webrtc"] +webrtc = ["dep:webrtc-audio-processing", "dep:webrtc-audio-processing-config"] + [dependencies] anyhow = "1.0.102" core_protocol = { version = "0.1.0", path = "../core_protocol" } cpal = "0.17.3" -eframe = "0.34.1" +eframe = { version = "0.34.1", features = ["glow", "default_fonts", "wayland", "x11"] } egui = "0.34.1" futures = "0.3.32" hound = "3.5.1" @@ -18,5 +22,5 @@ tokio-serde = { version = "0.9.0", features = ["bincode"] } tokio-util = { version = "0.7.18", features = ["codec"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" -webrtc-audio-processing = { version = "2.0.4", features = ["bundled"] } -webrtc-audio-processing-config = "2.0.4" +webrtc-audio-processing = { version = "2.0.4", features = ["bundled"], optional = true } +webrtc-audio-processing-config = { version = "2.0.4", optional = true } diff --git a/client_node/assets/fonts/Atkinson-Bold.ttf b/client_node/assets/fonts/Atkinson-Bold.ttf new file mode 100644 index 0000000..ca3f613 Binary files /dev/null and b/client_node/assets/fonts/Atkinson-Bold.ttf differ diff --git a/client_node/assets/fonts/Atkinson-Regular.ttf b/client_node/assets/fonts/Atkinson-Regular.ttf new file mode 100644 index 0000000..b3cc420 Binary files /dev/null and b/client_node/assets/fonts/Atkinson-Regular.ttf differ diff --git a/client_node/assets/fonts/FontAwesome-Solid.otf b/client_node/assets/fonts/FontAwesome-Solid.otf new file mode 100644 index 0000000..ddfe47f Binary files /dev/null and b/client_node/assets/fonts/FontAwesome-Solid.otf differ diff --git a/client_node/assets/fonts/Inter-Bold.ttf b/client_node/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..6fcafd9 Binary files /dev/null and b/client_node/assets/fonts/Inter-Bold.ttf differ diff --git a/client_node/assets/fonts/Inter-Regular.ttf b/client_node/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..6fcafd9 Binary files /dev/null and b/client_node/assets/fonts/Inter-Regular.ttf differ diff --git a/client_node/build.rs b/client_node/build.rs index 6cdc285..7bf4f8d 100644 --- a/client_node/build.rs +++ b/client_node/build.rs @@ -1,14 +1,17 @@ fn main() { - // Force linking of additional abseil libraries that webrtc-audio-processing-sys - // might miss depending on the system's abseil version. - // - // The bundled webrtc C++ code references symbols from abseil's string - // formatting and number-to-string utilities. On newer Fedora (abseil 2026+), - // these live in separate shared libraries that the crate's build script - // does not automatically link. - println!("cargo:rustc-link-lib=absl_strings_internal"); - println!("cargo:rustc-link-lib=absl_str_format_internal"); - println!("cargo:rustc-link-lib=absl_string_view"); - println!("cargo:rustc-link-lib=absl_int128"); - println!("cargo:rustc-link-lib=absl_throw_delegate"); + #[cfg(feature = "webrtc")] + { + // On Fedora/Bazzite, we need to explicitly link the system's Abseil libraries + // because the 'bundled' WebRTC build might still reference them if they are + // found on the system during the build process, or if the crate's build + // script is incorrectly configured for this distro. + + println!("cargo:rustc-link-search=native=/usr/lib64"); + + // We link the core string and number utilities where the missing symbols live. + println!("cargo:rustc-link-lib=absl_strings"); + println!("cargo:rustc-link-lib=absl_str_format_internal"); + println!("cargo:rustc-link-lib=absl_int128"); + println!("cargo:rustc-link-lib=absl_throw_delegate"); + } } diff --git a/client_node/src/audio/dsp.rs b/client_node/src/audio/dsp.rs index b39426d..47eb1bf 100644 --- a/client_node/src/audio/dsp.rs +++ b/client_node/src/audio/dsp.rs @@ -17,26 +17,32 @@ use ringbuf::{HeapCons, HeapProd}; use ringbuf::traits::{Consumer, Observer, Producer}; use tokio::sync::watch; use tracing::info; +#[cfg(feature = "webrtc")] use webrtc_audio_processing::Processor; +#[cfg(feature = "webrtc")] use webrtc_audio_processing_config::{ Config, EchoCanceller, NoiseSuppression, NoiseSuppressionLevel, }; use super::SAMPLE_RATE; +/// Mock processor for when WebRTC is disabled. +#[cfg(not(feature = "webrtc"))] +struct Processor; +#[cfg(not(feature = "webrtc"))] +impl Processor { + fn new(_rate: i32) -> Result { Ok(Self) } + fn set_config(&self, _config: ()) {} + fn process_capture_frame(&self, _frame: &mut Vec>) -> Result<(), String> { Ok(()) } +} + /// RMS threshold below which a frame is considered silence. -/// This provides a simple amplitude-based VAD since the WebRTC v2 API -/// removed the standalone voice detection configuration. const VAD_RMS_THRESHOLD: f32 = 0.01; /// WebRTC strictly requires 10ms frames (480 samples at 48kHz). const DSP_FRAME_SIZE: usize = 480; /// Spawns the dedicated background DSP thread. -/// -/// Reads 480-sample frames (10ms at 48kHz) from the ringbuffer, applies -/// WebRTC noise suppression + echo cancellation, and updates the active -/// speaker state and mic level via the provided watch channels. pub fn spawn_dsp_thread( mut consumer: HeapCons, mut loopback_prod: HeapProd, @@ -47,25 +53,28 @@ pub fn spawn_dsp_thread( mic_level_tx: watch::Sender, ) { thread::spawn(move || { - info!("DSP thread started."); + info!("DSP thread started (WebRTC: {}).", cfg!(feature = "webrtc")); - let ap = match Processor::new(SAMPLE_RATE) { + let ap = match Processor::new(SAMPLE_RATE as i32) { Ok(ap) => ap, Err(e) => { - tracing::error!("Failed to initialize WebRTC APM: {:?}", e); + tracing::error!("Failed to initialize Processor: {:?}", e); return; } }; - let config = Config { - echo_canceller: Some(EchoCanceller::default()), - noise_suppression: Some(NoiseSuppression { - level: NoiseSuppressionLevel::High, - analyze_linear_aec_output: false, - }), - ..Default::default() - }; - ap.set_config(config); + #[cfg(feature = "webrtc")] + { + let config = Config { + echo_canceller: Some(EchoCanceller::default()), + noise_suppression: Some(NoiseSuppression { + level: NoiseSuppressionLevel::High, + analyze_linear_aec_output: false, + }), + ..Default::default() + }; + ap.set_config(config); + } let mut frame_buf = vec![vec![0.0f32; DSP_FRAME_SIZE]]; diff --git a/client_node/src/main.rs b/client_node/src/main.rs index 887804f..de994c3 100644 --- a/client_node/src/main.rs +++ b/client_node/src/main.rs @@ -63,7 +63,7 @@ fn main() -> Result<()> { }); // ── Spawn Global Hotkey listener ── - hotkey::spawn_hotkey_listener(ptt_flag); + hotkey::spawn_hotkey_listener(ptt_flag.clone()); // ── Spawn custom tokio runtime for network background tasks ── std::thread::spawn(move || { @@ -86,19 +86,19 @@ fn main() -> Result<()> { viewport: egui::ViewportBuilder::default() .with_inner_size([960.0, 640.0]) .with_min_inner_size([640.0, 400.0]), + renderer: eframe::Renderer::Glow, ..Default::default() }; eframe::run_native( "Voice App", options, - Box::new(|_cc| { + Box::new(|cc| { Ok(Box::new(ui::VoiceApp::new( - active_speaker_rx, + cc, mic_level_rx, - audio_dumper_flag, mute_flag, - loopback_flag, + audio_dumper_flag, ))) }), ) diff --git a/client_node/src/ui/app.rs b/client_node/src/ui/app.rs index b98e4ee..a2a5865 100644 --- a/client_node/src/ui/app.rs +++ b/client_node/src/ui/app.rs @@ -1,540 +1,88 @@ -//! The core application state for the eframe UI. -//! -//! This module defines the `VoiceApp` struct which implements `eframe::App`. -//! It listens to background events via `tokio::sync::watch` and draws the -//! classic TeamSpeak-style layout: channel tree on the left, text chat in the -//! centre, and a control bar at the bottom. - use eframe::egui; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use tokio::sync::watch; +use crate::ui::theme::Theme; +use crate::ui::components::{TopRibbon, TacticalCanvas, OperatorCard, ChatOverlay}; -// ── Hardcoded channel list (Milestone 2 spec: "tree view of hardcoded channels") ── - -/// A channel in the server channel tree. -struct Channel { - name: &'static str, - children: &'static [Channel], -} - -/// Static channel tree for Milestone 2. -const CHANNEL_TREE: &[Channel] = &[ - Channel { - name: "General", - children: &[ - Channel { - name: "Lobby", - children: &[], - }, - Channel { - name: "Meeting Room", - children: &[], - }, - ], - }, - Channel { - name: "Gaming", - children: &[ - Channel { - name: "Competitive", - children: &[], - }, - Channel { - name: "Casual", - children: &[], - }, - ], - }, - Channel { - name: "AFK", - children: &[], - }, -]; - -// ── Colour palette ── - -const BG_DARK: egui::Color32 = egui::Color32::from_rgb(30, 30, 46); -const PANEL_BG: egui::Color32 = egui::Color32::from_rgb(36, 36, 54); -const ACCENT: egui::Color32 = egui::Color32::from_rgb(98, 114, 248); -const ACCENT_DIM: egui::Color32 = egui::Color32::from_rgb(68, 78, 160); -const TEXT_PRIMARY: egui::Color32 = egui::Color32::from_rgb(205, 214, 244); -const TEXT_MUTED: egui::Color32 = egui::Color32::from_rgb(127, 132, 156); -const GREEN: egui::Color32 = egui::Color32::from_rgb(100, 220, 130); -const RED: egui::Color32 = egui::Color32::from_rgb(235, 100, 100); -const YELLOW: egui::Color32 = egui::Color32::from_rgb(250, 200, 80); -const SEPARATOR: egui::Color32 = egui::Color32::from_rgb(55, 55, 75); - -// ── Application state ── - -/// A single chat message. -struct ChatMessage { - author: String, - body: String, -} - -/// The central state for the eframe UI. pub struct VoiceApp { - // ── Cross-thread channels ── - /// Receiver for the active speaker state, updated by the DSP thread. - active_speaker_rx: watch::Receiver, - /// Receiver for the current microphone RMS level (0.0–1.0). - mic_level_rx: watch::Receiver, - /// Shared flag to enable/disable the audio dumper. - audio_dumper_flag: Arc, - /// Shared mute flag (disables outgoing audio when true). - mute_flag: Arc, - /// Shared flag to enable/disable local audio loopback. - loopback_flag: Arc, + // ── Components ── + top_ribbon: TopRibbon, + tactical_canvas: TacticalCanvas, + operator_card: OperatorCard, + chat_overlay: ChatOverlay, - // ── Local UI state ── - /// The currently selected channel name. - selected_channel: String, - /// Whether the user has deafened themselves. - is_deafened: bool, - /// Chat messages in the current channel. - chat_messages: Vec, - /// The current text input in the chat compose box. - chat_input: String, - /// Whether the developer settings panel is visible. + // ── Shared State ── + mic_level_rx: watch::Receiver, + mute_flag: Arc, + audio_dumper_flag: Arc, + + // UI Local State show_dev_settings: bool, } impl VoiceApp { - /// Creates a new `VoiceApp` instance. - #[must_use] pub fn new( - active_speaker_rx: watch::Receiver, + _cc: &eframe::CreationContext<'_>, mic_level_rx: watch::Receiver, - audio_dumper_flag: Arc, mute_flag: Arc, - loopback_flag: Arc, + audio_dumper_flag: Arc, ) -> Self { + Theme::apply(&_cc.egui_ctx); + Self { - active_speaker_rx, + top_ribbon: TopRibbon::new(), + tactical_canvas: TacticalCanvas::new(), + operator_card: OperatorCard::new(), + chat_overlay: ChatOverlay::new(), + mic_level_rx, - audio_dumper_flag, mute_flag, - loopback_flag, - selected_channel: "Lobby".to_string(), - is_deafened: false, - chat_messages: vec![ - ChatMessage { - author: "System".into(), - body: "Welcome to Voice App!".into(), - }, - ChatMessage { - author: "System".into(), - body: "Press 'V' to talk. Use the controls below to mute/deafen.".into(), - }, - ], - chat_input: String::new(), + audio_dumper_flag, show_dev_settings: false, } } - - /// Applies the dark catppuccin-inspired colour scheme to egui. - fn apply_theme(ctx: &egui::Context) { - let mut style = (*ctx.global_style()).clone(); - let visuals = &mut style.visuals; - - visuals.dark_mode = true; - visuals.override_text_color = Some(TEXT_PRIMARY); - visuals.panel_fill = BG_DARK; - visuals.window_fill = PANEL_BG; - visuals.extreme_bg_color = egui::Color32::from_rgb(24, 24, 37); - - visuals.widgets.noninteractive.bg_fill = PANEL_BG; - visuals.widgets.inactive.bg_fill = egui::Color32::from_rgb(45, 45, 65); - visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(55, 55, 80); - visuals.widgets.active.bg_fill = ACCENT; - - visuals.selection.bg_fill = ACCENT_DIM; - visuals.selection.stroke = egui::Stroke::new(1.0, ACCENT); - - visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, TEXT_MUTED); - visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, TEXT_PRIMARY); - visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, TEXT_PRIMARY); - visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); - - style.spacing.item_spacing = egui::vec2(8.0, 6.0); - - ctx.set_global_style(style); - } - - // ── Sub-drawing functions ── - - /// Draws the left-hand channel tree panel. - fn draw_channel_tree(&mut self, ui: &mut egui::Ui) { - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("⚡").size(18.0).color(ACCENT)); - ui.label( - egui::RichText::new("Voice App Server") - .size(15.0) - .strong() - .color(TEXT_PRIMARY), - ); - }); - ui.add_space(4.0); - ui.separator(); - ui.add_space(4.0); - - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - for channel in CHANNEL_TREE { - self.draw_channel_entry(ui, channel, 0); - } - }); - } - - /// Recursively draws a single channel entry (with indent for children). - fn draw_channel_entry(&mut self, ui: &mut egui::Ui, channel: &Channel, depth: usize) { - #[allow(clippy::cast_precision_loss)] // Channel depth is always tiny. - let indent = depth as f32 * 16.0; - let is_selected = self.selected_channel == channel.name; - let has_children = !channel.children.is_empty(); - - ui.horizontal(|ui| { - ui.add_space(indent); - - let icon = if has_children { "📁" } else { "🔊" }; - let text_color = if is_selected { ACCENT } else { TEXT_PRIMARY }; - - let label = egui::RichText::new(format!("{icon} {}", channel.name)) - .size(13.0) - .color(text_color); - - let response = ui.selectable_label(is_selected, label); - if response.clicked() { - self.selected_channel = channel.name.to_string(); - } - }); - - if has_children { - for child in channel.children { - self.draw_channel_entry(ui, child, depth + 1); - } - } - } - - /// Draws the central chat panel. - fn draw_chat_panel(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!("# {}", self.selected_channel)) - .size(16.0) - .strong() - .color(TEXT_PRIMARY), - ); - }); - ui.separator(); - - // Chat message area (takes all remaining space minus the input box). - let available = ui.available_height() - 40.0; - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .max_height(available) - .stick_to_bottom(true) - .show(ui, |ui| { - for msg in &self.chat_messages { - ui.horizontal_wrapped(|ui| { - ui.label( - egui::RichText::new(&msg.author) - .strong() - .color(ACCENT) - .size(13.0), - ); - ui.label( - egui::RichText::new(&msg.body) - .color(TEXT_PRIMARY) - .size(13.0), - ); - }); - ui.add_space(2.0); - } - }); - - ui.separator(); - - // Chat input bar. - ui.horizontal(|ui| { - let response = ui.add_sized( - [ui.available_width() - 60.0, 28.0], - egui::TextEdit::singleline(&mut self.chat_input) - .hint_text("Type a message…") - .desired_width(f32::INFINITY), - ); - - if ui.button("Send").clicked() - || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - { - let text = self.chat_input.trim().to_string(); - if !text.is_empty() { - self.chat_messages.push(ChatMessage { - author: "You".into(), - body: text, - }); - self.chat_input.clear(); - } - response.request_focus(); - } - }); - } - - /// Draws the bottom control bar (mute, deafen, PTT status, mic level). - fn draw_control_bar(&mut self, ui: &mut egui::Ui) { - let is_speaking = *self.active_speaker_rx.borrow(); - let mic_level = *self.mic_level_rx.borrow(); - let is_muted = self.mute_flag.load(Ordering::Relaxed); - - ui.horizontal(|ui| { - ui.add_space(8.0); - - // ── User info + speaking indicator ── - let dot_color = if is_speaking { - GREEN - } else if is_muted || self.is_deafened { - RED - } else { - TEXT_MUTED - }; - ui.label(egui::RichText::new("●").size(16.0).color(dot_color)); - - let status = if is_speaking { - "Speaking" - } else if is_muted { - "Muted" - } else if self.is_deafened { - "Deafened" - } else { - "Idle" - }; - ui.label( - egui::RichText::new(format!("TestUser • {status}")) - .size(13.0) - .color(TEXT_PRIMARY), - ); - - ui.add_space(16.0); - - // ── Mic level meter ── - ui.label(egui::RichText::new("Mic").size(11.0).color(TEXT_MUTED)); - let meter_width = 80.0; - let (rect, _response) = - ui.allocate_exact_size(egui::vec2(meter_width, 12.0), egui::Sense::hover()); - let painter = ui.painter(); - painter.rect_filled(rect, 3.0, egui::Color32::from_rgb(40, 40, 58)); - - // Clamp and scale the level for visual feedback. - let clamped = mic_level.clamp(0.0, 0.5) * 2.0; // normalize 0.0–0.5 → 0.0–1.0 - let fill_width = clamped * meter_width; - let meter_color = if clamped > 0.8 { - RED - } else if clamped > 0.4 { - YELLOW - } else { - GREEN - }; - if fill_width > 0.5 { - let fill_rect = egui::Rect::from_min_size(rect.min, egui::vec2(fill_width, 12.0)); - painter.rect_filled(fill_rect, 3.0, meter_color); - } - - ui.add_space(16.0); - - // ── Mute / Deafen / Settings buttons ── - let mute_label = if is_muted { "🔇 Unmute" } else { "🎤 Mute" }; - let mute_color = if is_muted { RED } else { TEXT_PRIMARY }; - if ui - .add(egui::Button::new( - egui::RichText::new(mute_label).size(12.0).color(mute_color), - )) - .clicked() - { - self.mute_flag.store(!is_muted, Ordering::Relaxed); - } - - let deafen_label = if self.is_deafened { - "🔇 Undeafen" - } else { - "🎧 Deafen" - }; - let deafen_color = if self.is_deafened { RED } else { TEXT_PRIMARY }; - if ui - .add(egui::Button::new( - egui::RichText::new(deafen_label) - .size(12.0) - .color(deafen_color), - )) - .clicked() - { - self.is_deafened = !self.is_deafened; - // Deafening also mutes outgoing audio. - if self.is_deafened { - self.mute_flag.store(true, Ordering::Relaxed); - } - } - - // ── Dev settings toggle ── - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add(egui::Button::new( - egui::RichText::new("⚙").size(16.0).color(TEXT_MUTED), - )) - .on_hover_text("Developer Settings") - .clicked() - { - self.show_dev_settings = !self.show_dev_settings; - } - }); - }); - } - - /// Draws the developer settings side panel (audio dumper toggle, etc.). - fn draw_dev_settings(&mut self, ui: &mut egui::Ui) { - ui.add_space(4.0); - ui.label( - egui::RichText::new("Developer Settings") - .size(14.0) - .strong() - .color(ACCENT), - ); - ui.add_space(4.0); - ui.separator(); - ui.add_space(8.0); - - let mut dumper_enabled = self.audio_dumper_flag.load(Ordering::Relaxed); - if ui - .checkbox(&mut dumper_enabled, "Enable Audio Dumper (.wav)") - .changed() - { - self.audio_dumper_flag - .store(dumper_enabled, Ordering::Relaxed); - } - - let mut loopback_enabled = self.loopback_flag.load(Ordering::Relaxed); - if ui - .checkbox(&mut loopback_enabled, "Enable Mic Loopback") - .changed() - { - self.loopback_flag.store(loopback_enabled, Ordering::Relaxed); - } - ui.label( - egui::RichText::new("Writes raw_mic.wav and post_dsp.wav to the working directory.") - .size(11.0) - .color(TEXT_MUTED) - .italics(), - ); - - ui.add_space(12.0); - ui.label( - egui::RichText::new("Audio Pipeline") - .size(13.0) - .strong() - .color(TEXT_PRIMARY), - ); - ui.add_space(4.0); - - let mic_level = *self.mic_level_rx.borrow(); - ui.label( - egui::RichText::new(format!("RMS Level: {mic_level:.4}")) - .size(12.0) - .monospace() - .color(TEXT_MUTED), - ); - ui.label( - egui::RichText::new(format!("VAD Threshold: {VAD_RMS_THRESHOLD:.4}")) - .size(12.0) - .monospace() - .color(TEXT_MUTED), - ); - - let is_speaking = *self.active_speaker_rx.borrow(); - let vad_label = if is_speaking { "ACTIVE" } else { "SILENT" }; - let vad_color = if is_speaking { GREEN } else { TEXT_MUTED }; - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("VAD: ") - .size(12.0) - .monospace() - .color(TEXT_MUTED), - ); - ui.label( - egui::RichText::new(vad_label) - .size(12.0) - .monospace() - .strong() - .color(vad_color), - ); - }); - } } -/// Reference to the DSP module's VAD threshold for display in the dev panel. -const VAD_RMS_THRESHOLD: f32 = 0.01; - impl eframe::App for VoiceApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - // Apply theme once per frame (cheap — just pointer comparisons internally). - Self::apply_theme(ui.ctx()); - - // ── Left panel: Channel tree ── - egui::Panel::left("channel_tree") - .default_size(200.0) - .resizable(true) - .frame( - egui::Frame::new() - .fill(PANEL_BG) - .inner_margin(egui::Margin::same(8)) - .stroke(egui::Stroke::new(1.0, SEPARATOR)), - ) - .show_inside(ui, |ui| { - self.draw_channel_tree(ui); - }); - - // ── Right panel: Dev settings (toggled) ── - if self.show_dev_settings { - egui::Panel::right("dev_settings") - .default_size(220.0) - .resizable(true) - .frame( - egui::Frame::new() - .fill(PANEL_BG) - .inner_margin(egui::Margin::same(8)) - .stroke(egui::Stroke::new(1.0, SEPARATOR)), - ) - .show_inside(ui, |ui| { - self.draw_dev_settings(ui); - }); - } - - // ── Bottom panel: Control bar ── - egui::Panel::bottom("controls") - .exact_size(40.0) - .frame( - egui::Frame::new() - .fill(PANEL_BG) - .inner_margin(egui::Margin::same(6)) - .stroke(egui::Stroke::new(1.0, SEPARATOR)), - ) - .show_inside(ui, |ui| { - self.draw_control_bar(ui); - }); - - // ── Central panel: Chat ── - egui::CentralPanel::default() - .frame( - egui::Frame::new() - .fill(BG_DARK) - .inner_margin(egui::Margin::same(12)), - ) - .show_inside(ui, |ui| { - self.draw_chat_panel(ui); - }); - - // Force continuous repaint so the watch channels reflect immediately. + self.draw_ui(ui); + + // Forced refresh for animations ui.ctx().request_repaint(); } } + +impl VoiceApp { + fn draw_ui(&mut self, ui: &mut egui::Ui) { + // Update local mute state from operator card + self.mute_flag.store(self.operator_card.is_muted, std::sync::atomic::Ordering::Relaxed); + + // 1. Top Ribbon (Always Visible) + egui::Panel::top("top_ribbon_panel") + .frame(egui::Frame::NONE) + .show_inside(ui, |ui| { + self.top_ribbon.show(ui); + }); + + // 2. Main 3D Tactical Canvas + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(Theme::BASE)) + .show_inside(ui, |ui| { + self.tactical_canvas.show(ui); + }); + + // 3. Floating Overlays + self.operator_card.show(ui.ctx()); + self.chat_overlay.show(ui.ctx()); + + // 4. Developer Settings (Optional) + if self.show_dev_settings { + egui::Window::new("Telemetry Console") + .show(ui.ctx(), |ui| { + ui.label(format!("Mic RMS: {:.4}", *self.mic_level_rx.borrow())); + ui.checkbox(&mut self.operator_card.is_muted, "Manual Mute"); + }); + } + } +} diff --git a/client_node/src/ui/components/channel_tree.rs b/client_node/src/ui/components/channel_tree.rs new file mode 100644 index 0000000..20a68cf --- /dev/null +++ b/client_node/src/ui/components/channel_tree.rs @@ -0,0 +1,106 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +pub struct Channel { + pub name: &'static str, + pub children: &'static [Channel], +} + +pub const CHANNEL_TREE: &[Channel] = &[ + Channel { + name: "General", + children: &[ + Channel { name: "Lobby", children: &[] }, + Channel { name: "Meeting Room", children: &[] }, + ], + }, + Channel { + name: "Gaming", + children: &[ + Channel { name: "Competitive", children: &[] }, + Channel { name: "Casual", children: &[] }, + ], + }, + Channel { name: "AFK", children: &[] }, +]; + +pub struct ChannelTreeView { + pub selected_channel: String, +} + +impl ChannelTreeView { + pub fn new() -> Self { + Self { + selected_channel: "Lobby".to_string(), + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("✦").size(20.0).color(Theme::ACCENT_VIBRANT)); + ui.label( + egui::RichText::new("SERVER BEYOND") + .size(14.0) + .strong() + .extra_letter_spacing(1.2) + .color(Theme::TEXT_PRIMARY), + ); + }); + ui.add_space(12.0); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for channel in CHANNEL_TREE { + self.draw_channel_entry(ui, channel, 0); + } + }); + }); + } + + fn draw_channel_entry(&mut self, ui: &mut egui::Ui, channel: &Channel, depth: usize) { + let indent = depth as f32 * 18.0; + let is_selected = self.selected_channel == channel.name; + let has_children = !channel.children.is_empty(); + + ui.horizontal(|ui| { + ui.add_space(indent); + + let icon = if has_children { "📁" } else { "🔊" }; + + let text_color = if is_selected { Theme::ACCENT } else { Theme::TEXT_PRIMARY }; + let bg_color = if is_selected { Theme::ACCENT.linear_multiply(0.1) } else { egui::Color32::TRANSPARENT }; + + let frame = egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(6.0) + .fill(bg_color); + + let response = frame.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(icon).size(14.0).color(text_color)); + ui.add_space(4.0); + ui.label(egui::RichText::new(channel.name).size(13.0).color(text_color)); + }) + }).response; + + let response = ui.interact(response.rect, ui.id().with(channel.name), egui::Sense::click()); + + if response.clicked() { + self.selected_channel = channel.name.to_string(); + } + + if response.hovered() && !is_selected { + ui.painter().rect_filled(response.rect, 6.0, egui::Color32::from_white_alpha(5)); + } + }); + + if has_children { + for child in channel.children { + self.draw_channel_entry(ui, child, depth + 1); + } + } + } +} diff --git a/client_node/src/ui/components/chat.rs b/client_node/src/ui/components/chat.rs new file mode 100644 index 0000000..f9508e6 --- /dev/null +++ b/client_node/src/ui/components/chat.rs @@ -0,0 +1,118 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +pub struct ChatMessage { + pub author: String, + pub body: String, + pub timestamp: String, +} + +pub struct ChatView { + pub messages: Vec, + pub input: String, +} + +impl ChatView { + pub fn new() -> Self { + Self { + messages: vec![ + ChatMessage { + author: "System".into(), + body: "Secure connection established.".into(), + timestamp: "12:00".into(), + }, + ], + input: String::new(), + } + } + + pub fn show(&mut self, ui: &mut egui::Ui, channel_name: &str) { + ui.vertical(|ui| { + // Header + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("#").size(18.0).color(Theme::TEXT_DIM)); + ui.label( + egui::RichText::new(channel_name) + .size(16.0) + .strong() + .color(Theme::TEXT_PRIMARY), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new("Online: 12").size(12.0).color(Theme::TEXT_DIM)); + }); + }); + ui.add_space(8.0); + ui.separator(); + + // Messages Area + let available_height = ui.available_height() - 60.0; + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(available_height) + .stick_to_bottom(true) + .show(ui, |ui| { + ui.add_space(8.0); + for msg in &self.messages { + self.draw_message(ui, msg); + } + }); + + ui.add_space(ui.available_height() - 60.0); // Simple replacement for spacer + + // Input Area + ui.horizontal(|ui| { + let text_edit = egui::TextEdit::singleline(&mut self.input) + .hint_text("Message # ".to_owned() + channel_name) + .margin(egui::Margin::symmetric(12, 8)) + .desired_width(f32::INFINITY); + + let response = ui.add(text_edit); + + if (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) || ui.button("󰒍").clicked() { + let text = self.input.trim().to_string(); + if !text.is_empty() { + self.messages.push(ChatMessage { + author: "You".into(), + body: text, + timestamp: "Now".into(), + }); + self.input.clear(); + } + response.request_focus(); + } + }); + ui.add_space(8.0); + }); + } + + fn draw_message(&self, ui: &mut egui::Ui, msg: &ChatMessage) { + ui.horizontal_top(|ui| { + let initial = msg.author.chars().next().unwrap_or('?'); + let avatar_color = if msg.author == "System" { Theme::TEXT_DIM } else { Theme::ACCENT_VIBRANT }; + + // Avatar + let (rect, _response) = ui.allocate_exact_size(egui::vec2(32.0, 32.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 16.0, avatar_color.linear_multiply(0.2)); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + initial.to_string(), + egui::FontId::proportional(14.0), + avatar_color, + ); + + ui.add_space(8.0); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&msg.author).strong().size(13.0).color(Theme::TEXT_PRIMARY)); + ui.add_space(4.0); + ui.label(egui::RichText::new(&msg.timestamp).size(10.0).color(Theme::TEXT_DIM)); + }); + ui.label(egui::RichText::new(&msg.body).size(13.0).color(Theme::TEXT_PRIMARY)); + }); + }); + ui.add_space(12.0); + } +} diff --git a/client_node/src/ui/components/chat_overlay.rs b/client_node/src/ui/components/chat_overlay.rs new file mode 100644 index 0000000..2e6a05c --- /dev/null +++ b/client_node/src/ui/components/chat_overlay.rs @@ -0,0 +1,74 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +pub struct ChatOverlay { + pub messages: Vec<(String, String)>, + pub input: String, +} + +impl ChatOverlay { + pub fn new() -> Self { + Self { + messages: vec![ + ("HQ".to_string(), "Tactical Command Center online.".to_string()), + ("Samuel".to_string(), "All units, check in.".to_string()), + ], + input: String::new(), + } + } + + pub fn show(&mut self, ctx: &egui::Context) { + egui::Window::new("COMMUNICATION OVERLAY") + .id(egui::Id::new("chat_overlay")) + .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-24.0, -24.0)) + .resizable(true) + .default_width(400.0) + .frame(Theme::glass_frame()) + .title_bar(false) + .show(ctx, |ui| { + ui.vertical(|ui| { + // Header + ui.horizontal(|ui| { + ui.label(egui::RichText::new("󰭹").size(16.0).color(Theme::COBALT_BLUE)); + ui.label(egui::RichText::new("OPERATIONAL CHAT").size(11.0).strong().color(Theme::TEXT_SECONDARY)); + }); + ui.add_space(8.0); + ui.separator(); + + // Message Area + egui::ScrollArea::vertical() + .max_height(200.0) + .stick_to_bottom(true) + .show(ui, |ui| { + for (sender, msg) in &self.messages { + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(sender).size(10.0).strong().color(Theme::COBALT_BLUE)); + ui.label(egui::RichText::new(msg).size(13.0).color(Theme::TEXT_PRIMARY)); + }); + } + }); + + ui.add_space(12.0); + + // Input + ui.horizontal(|ui| { + let response = ui.add( + egui::TextEdit::singleline(&mut self.input) + .hint_text("Transmit command...") + .frame(egui::Frame::NONE) + .margin(egui::Margin::same(8)) + .desired_width(f32::INFINITY) + ); + + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if !self.input.is_empty() { + self.messages.push(("Samuel".to_string(), self.input.clone())); + self.input.clear(); + } + } + }); + }); + }); + } +} diff --git a/client_node/src/ui/components/controls.rs b/client_node/src/ui/components/controls.rs new file mode 100644 index 0000000..44e10a5 --- /dev/null +++ b/client_node/src/ui/components/controls.rs @@ -0,0 +1,138 @@ +use eframe::egui; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use crate::ui::theme::Theme; + +pub struct ControlBarView { + pub is_deafened: bool, +} + +impl ControlBarView { + pub fn new() -> Self { + Self { + is_deafened: false, + } + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + is_speaking: bool, + mic_level: f32, + mute_flag: &Arc, + show_dev_settings: &mut bool, + ) { + let is_muted = mute_flag.load(Ordering::Relaxed); + + ui.horizontal(|ui| { + ui.add_space(8.0); + + // User Profile Section + ui.horizontal(|ui| { + let dot_color = if is_speaking { + Theme::GREEN + } else if is_muted || self.is_deafened { + Theme::RED + } else { + Theme::TEXT_DIM + }; + + // Pulsing animation for speaking indicator + let glow_alpha = if is_speaking { + (ui.input(|i| i.time * 4.0).sin() * 0.5 + 0.5) as f32 * 0.5 + } else { + 0.0 + }; + + let (rect, _response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + if is_speaking { + ui.painter().circle_filled(rect.center(), 10.0, Theme::GREEN.linear_multiply(glow_alpha)); + } + ui.painter().circle_filled(rect.center(), 5.0, dot_color); + + ui.add_space(4.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new("Samuel").strong().size(13.0).color(Theme::TEXT_PRIMARY)); + let status_text = if is_speaking { "Speaking" } else if is_muted { "Muted" } else { "Connected" }; + ui.label(egui::RichText::new(status_text).size(10.0).color(Theme::TEXT_DIM)); + }); + }); + + ui.add_space(24.0); + ui.separator(); + ui.add_space(24.0); + + // Mic Level Section (High-Tech Meter) + ui.vertical(|ui| { + ui.add_space(4.0); + ui.label(egui::RichText::new("MICROPHONE").size(9.0).strong().color(Theme::TEXT_DIM).extra_letter_spacing(1.0)); + + let meter_width = 120.0; + let meter_height = 6.0; + let (rect, _response) = ui.allocate_exact_size(egui::vec2(meter_width, meter_height), egui::Sense::hover()); + + // Background + ui.painter().rect_filled(rect, 3.0, Theme::BG_DEEP); + + // Segments + let segments = 20; + let gap = 1.0; + let seg_width = (meter_width - (segments as f32 - 1.0) * gap) / segments as f32; + + let level = (mic_level * 5.0).clamp(0.0, 1.0); // Amplify for visualization + + for i in 0..segments { + let progress = i as f32 / segments as f32; + let x = rect.min.x + i as f32 * (seg_width + gap); + let seg_rect = egui::Rect::from_min_size(egui::pos2(x, rect.min.y), egui::vec2(seg_width, meter_height)); + + let color = if progress < level { + if progress > 0.8 { Theme::RED } + else if progress > 0.6 { Theme::YELLOW } + else { Theme::ACCENT } + } else { + Theme::BG_PANEL + }; + + ui.painter().rect_filled(seg_rect, 1.0, color); + } + }); + + ui.add_space(24.0); + ui.add_space(ui.available_width() - 200.0); + + // Control Buttons + ui.horizontal(|ui| { + let mute_icon = if is_muted { "🔇" } else { "🎤" }; + if self.draw_control_button(ui, mute_icon, is_muted).clicked() { + mute_flag.store(!is_muted, Ordering::Relaxed); + } + + let deafen_icon = if self.is_deafened { "🔇" } else { "🎧" }; + if self.draw_control_button(ui, deafen_icon, self.is_deafened).clicked() { + self.is_deafened = !self.is_deafened; + if self.is_deafened { + mute_flag.store(true, Ordering::Relaxed); + } + } + + if self.draw_control_button(ui, "⚙", *show_dev_settings).clicked() { + *show_dev_settings = !*show_dev_settings; + } + }); + ui.add_space(8.0); + }); + } + + fn draw_control_button(&self, ui: &mut egui::Ui, icon: &str, active: bool) -> egui::Response { + let bg = if active { Theme::RED.linear_multiply(0.2) } else { Theme::BG_INNER }; + let fg = if active { Theme::RED } else { Theme::TEXT_PRIMARY }; + + let button = egui::Button::new(egui::RichText::new(icon).size(16.0).color(fg)) + .fill(bg) + .corner_radius(8.0) + .min_size(egui::vec2(36.0, 36.0)); + + ui.add(button) + } +} diff --git a/client_node/src/ui/components/mod.rs b/client_node/src/ui/components/mod.rs new file mode 100644 index 0000000..c5565ee --- /dev/null +++ b/client_node/src/ui/components/mod.rs @@ -0,0 +1,9 @@ +pub mod tactical_canvas; +pub mod top_ribbon; +pub mod operator_card; +pub mod chat_overlay; + +pub use tactical_canvas::TacticalCanvas; +pub use top_ribbon::TopRibbon; +pub use operator_card::OperatorCard; +pub use chat_overlay::ChatOverlay; diff --git a/client_node/src/ui/components/operator_card.rs b/client_node/src/ui/components/operator_card.rs new file mode 100644 index 0000000..9762089 --- /dev/null +++ b/client_node/src/ui/components/operator_card.rs @@ -0,0 +1,77 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +pub struct OperatorCard { + pub is_muted: bool, +} + +impl OperatorCard { + pub fn new() -> Self { + Self { is_muted: false } + } + + pub fn show(&mut self, ctx: &egui::Context) { + let screen_rect = ctx.content_rect(); + let width = 280.0; + + // Floating at top-right + egui::Area::new(egui::Id::new("operator_card")) + .fixed_pos(egui::pos2(screen_rect.max.x - width - 24.0, 88.0)) + .show(ctx, |ui| { + Theme::glass_frame().show(ui, |ui| { + ui.set_width(width); + + // Header + ui.horizontal(|ui| { + // Avatar Placeholder + let (rect, _) = ui.allocate_exact_size(egui::vec2(48.0, 48.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 24.0, Theme::COBALT_BLUE); + + ui.vertical(|ui| { + ui.label(egui::RichText::new("SAMUEL").size(16.0).strong().color(Theme::TEXT_PRIMARY)); + ui.label(egui::RichText::new("CONNECTED").size(10.0).color(Theme::SIGNAL_GREEN)); + }); + }); + + ui.add_space(16.0); + ui.separator(); + ui.add_space(16.0); + + // Telemetry Section (3-column) + ui.columns(3, |columns| { + Self::telemetry_item(&mut columns[0], "VERSION", "v1.0.4-T"); + Self::telemetry_item(&mut columns[1], "UPTIME", "04:22:15"); + Self::telemetry_item(&mut columns[2], "LATENCY", "24ms"); + }); + + ui.add_space(24.0); + + // Primary Action: Large Mute Toggle + let mute_color = if self.is_muted { Theme::CRIMSON_ALERT } else { Theme::COBALT_BLUE }; + let btn_text = if self.is_muted { "󰍭" } else { "󰍬" }; + + ui.vertical_centered(|ui| { + if ui.add_sized( + [200.0, 56.0], + egui::Button::new(egui::RichText::new(btn_text).size(32.0)) + .fill(mute_color.linear_multiply(0.2)) + .stroke(egui::Stroke::new(1.0, mute_color)) + .corner_radius(4.0) + ).clicked() { + self.is_muted = !self.is_muted; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(if self.is_muted { "UNMUTE UNIT" } else { "MUTE UNIT" }) + .size(10.0).strong().color(mute_color)); + }); + }); + }); + } + + fn telemetry_item(ui: &mut egui::Ui, label: &str, value: &str) { + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(label).size(8.0).color(Theme::TEXT_SECONDARY).strong()); + ui.label(egui::RichText::new(value).size(11.0).color(Theme::TEXT_PRIMARY).strong()); + }); + } +} diff --git a/client_node/src/ui/components/tactical_canvas.rs b/client_node/src/ui/components/tactical_canvas.rs new file mode 100644 index 0000000..82dda2f --- /dev/null +++ b/client_node/src/ui/components/tactical_canvas.rs @@ -0,0 +1,255 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +#[derive(Clone, Copy)] +pub enum Rank { Guest, Member, Leadership } + +pub struct User { + pub id: u64, + pub name: String, + pub rank: Rank, + pub is_speaking: bool, + pub is_muted: bool, + pub pos: egui::Pos2, // World X, Y (Floor coordinates) + pub current_pos: egui::Pos2, // Interpolated pos +} + +pub struct Sector { + pub id: String, + pub name: String, + pub pos: egui::Pos2, // Center in World X, Y +} + +pub struct TacticalCanvas { + pub zoom: f32, + pub offset: egui::Vec2, // Panning offset in world coordinates + pub sectors: Vec, + pub users: Vec, + + // 3D Camera Settings (Fixed Angle) + camera_pitch: f32, // Fixed downward angle + camera_dist: f32, // Base distance (for perspective) +} + +impl TacticalCanvas { + pub fn new() -> Self { + let mut sectors = Vec::new(); + // Create a basic tactical grid + sectors.push(Sector { id: "lobby".into(), name: "LOBBY".into(), pos: egui::pos2(0.0, 0.0) }); + sectors.push(Sector { id: "ops1".into(), name: "OPS-1".into(), pos: egui::pos2(200.0, 200.0) }); + sectors.push(Sector { id: "ops2".into(), name: "OPS-2".into(), pos: egui::pos2(-200.0, 200.0) }); + sectors.push(Sector { id: "debrief".into(), name: "DEBRIEF".into(), pos: egui::pos2(0.0, 400.0) }); + + let mut users = Vec::new(); + users.push(User { + id: 1, + name: "Samuel".into(), + rank: Rank::Leadership, + is_speaking: false, + is_muted: false, + pos: egui::pos2(0.0, 0.0), + current_pos: egui::pos2(0.0, 0.0), + }); + + Self { + zoom: 1.0, + offset: egui::Vec2::ZERO, + sectors, + users, + camera_pitch: 0.6, // ~35 degrees + camera_dist: 1000.0, + } + } + + /// Projects 3D World coordinates (X, Y, Z) to 2D Screen coordinates + fn project(&self, world_pos: egui::Pos2, z: f32, rect: egui::Rect) -> egui::Pos2 { + // 1. Apply Panning (Offset) + let rx = world_pos.x - self.offset.x; + let ry = world_pos.y - self.offset.y; + + // 2. Simple Fixed-Angle Perspective Projection + // We simulate a camera looking from above and behind. + // Y in world is "depth", X is "horizontal", Z is "height". + + let cos_p = self.camera_pitch.cos(); + let sin_p = self.camera_pitch.sin(); + + // Rotate around X axis for pitch + let dy = ry * cos_p - z * sin_p; + let dz = ry * sin_p + z * cos_p + self.camera_dist / self.zoom; + + // Project + let scale = self.camera_dist / dz; + let screen_x = rect.center().x + rx * scale; + let screen_y = rect.center().y + dy * scale; + + egui::pos2(screen_x, screen_y) + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + let (rect, response) = ui.allocate_at_least(ui.available_size(), egui::Sense::drag()); + + // Handle Panning + if response.dragged() { + // Adjust offset based on drag (inverse of projection) + self.offset -= response.drag_delta() / self.zoom; + } + + // Handle Zooming + let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y); + if scroll_delta != 0.0 { + self.zoom = (self.zoom + scroll_delta * 0.001).clamp(0.2, 5.0); + } + + let painter = ui.painter_at(rect); + + // ── 1. Draw Infinite Horizon Grid (Sub-Floor) ── + self.draw_horizon_grid(&painter, rect); + + // ── 2. Draw Volumetric Sectors ── + // Sort sectors by projected Y (depth) to handle occlusion + let mut sorted_sectors: Vec<_> = self.sectors.iter().collect(); + sorted_sectors.sort_by(|a, b| b.pos.y.partial_cmp(&a.pos.y).unwrap()); + + for sector in sorted_sectors { + self.draw_volumetric_sector(&painter, sector, rect); + } + + // ── 3. Draw Floating Users ── + // Update positions first + for user in &mut self.users { + user.current_pos.x += (user.pos.x - user.current_pos.x) * 0.1; + user.current_pos.y += (user.pos.y - user.current_pos.y) * 0.1; + } + + // Then draw + for i in 0..self.users.len() { + let user = &self.users[i]; + self.draw_floating_unit(&painter, user, rect); + } + } + + fn draw_horizon_grid(&self, painter: &egui::Painter, rect: egui::Rect) { + let grid_color = Theme::BORDER.linear_multiply(0.2); + let step = 100.0; + let range = 10; + + for i in -range..=range { + let p1 = self.project(egui::pos2(i as f32 * step, -range as f32 * step), 0.0, rect); + let p2 = self.project(egui::pos2(i as f32 * step, range as f32 * step), 0.0, rect); + painter.line_segment([p1, p2], egui::Stroke::new(1.0, grid_color)); + + let p3 = self.project(egui::pos2(-range as f32 * step, i as f32 * step), 0.0, rect); + let p4 = self.project(egui::pos2(range as f32 * step, i as f32 * step), 0.0, rect); + painter.line_segment([p3, p4], egui::Stroke::new(1.0, grid_color)); + } + } + + fn draw_volumetric_sector(&self, painter: &egui::Painter, sector: &Sector, rect: egui::Rect) { + let size = 80.0; + let height = 60.0; + + // Define 4 corners of the base + let corners_base = [ + egui::pos2(sector.pos.x - size, sector.pos.y - size), + egui::pos2(sector.pos.x + size, sector.pos.y - size), + egui::pos2(sector.pos.x + size, sector.pos.y + size), + egui::pos2(sector.pos.x - size, sector.pos.y + size), + ]; + + let projected_base: Vec<_> = corners_base.iter().map(|&p| self.project(p, 0.0, rect)).collect(); + let projected_top: Vec<_> = corners_base.iter().map(|&p| self.project(p, height, rect)).collect(); + + // 1. Draw Floor + painter.add(egui::Shape::convex_polygon( + projected_base.clone(), + Theme::BASE.linear_multiply(0.3), + egui::Stroke::new(1.0, Theme::BORDER), + )); + + // 2. Draw Walls (Transparent Glass) + let wall_color = Theme::COBALT_BLUE.linear_multiply(0.05); + let wall_stroke = egui::Stroke::new(0.5, Theme::BORDER); + + for i in 0..4 { + let next = (i + 1) % 4; + painter.add(egui::Shape::convex_polygon( + vec![projected_base[i], projected_base[next], projected_top[next], projected_top[i]], + wall_color, + wall_stroke, + )); + } + + // 3. Draw Ceiling Wireframe + painter.add(egui::Shape::closed_line( + projected_top, + egui::Stroke::new(1.0, Theme::BORDER.linear_multiply(0.5)), + )); + + // 4. Label (Floating above) + let label_pos = self.project(sector.pos, height + 10.0, rect); + painter.text( + label_pos, + egui::Align2::CENTER_BOTTOM, + §or.name, + egui::FontId::proportional(12.0), + Theme::TEXT_SECONDARY, + ); + } + + fn draw_floating_unit(&self, painter: &egui::Painter, user: &User, rect: egui::Rect) { + let float_height = 30.0; + let screen_pos = self.project(user.current_pos, float_height, rect); + let shadow_pos = self.project(user.current_pos, 0.0, rect); + + // 1. Tether Line + painter.line_segment( + [shadow_pos, screen_pos], + egui::Stroke::new(1.0, Theme::BORDER.linear_multiply(0.3)), + ); + + // 2. Drop Shadow + painter.circle_filled(shadow_pos, 8.0 * self.zoom, egui::Color32::from_black_alpha(100)); + + // 3. Floating Node + let node_color = if user.is_speaking { Theme::SIGNAL_GREEN } else { Theme::COBALT_BLUE }; + + // Rank-based shape + match user.rank { + Rank::Leadership => { + self.draw_hexagon(painter, screen_pos, 15.0 * self.zoom, node_color); + } + _ => { + painter.circle_filled(screen_pos, 12.0 * self.zoom, node_color); + } + } + + // 4. Name Tag + painter.text( + screen_pos + egui::vec2(0.0, -20.0 * self.zoom), + egui::Align2::CENTER_BOTTOM, + &user.name, + egui::FontId::proportional(14.0), + Theme::TEXT_PRIMARY, + ); + + // 5. Speaking Pulse + if user.is_speaking { + let t = (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 1000) as f32 / 1000.0; + painter.circle_stroke( + screen_pos, + (15.0 + t * 20.0) * self.zoom, + egui::Stroke::new(2.0 * (1.0 - t), Theme::SIGNAL_GREEN), + ); + } + } + + fn draw_hexagon(&self, painter: &egui::Painter, center: egui::Pos2, radius: f32, color: egui::Color32) { + let mut points = Vec::new(); + for i in 0..6 { + let angle = (i as f32 * 60.0).to_radians(); + points.push(center + egui::vec2(angle.cos() * radius, angle.sin() * radius)); + } + painter.add(egui::Shape::convex_polygon(points, color, egui::Stroke::new(1.0, Theme::TEXT_PRIMARY))); + } +} diff --git a/client_node/src/ui/components/top_ribbon.rs b/client_node/src/ui/components/top_ribbon.rs new file mode 100644 index 0000000..5e52f7a --- /dev/null +++ b/client_node/src/ui/components/top_ribbon.rs @@ -0,0 +1,133 @@ +use eframe::egui; +use crate::ui::theme::Theme; + +pub struct TopRibbon { + pub active_tab: String, +} + +impl TopRibbon { + pub fn new() -> Self { + Self { + active_tab: "CONNECTIONS".to_string(), + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + let frame = egui::Frame::NONE + .fill(Theme::BASE.linear_multiply(0.8)) + .stroke(egui::Stroke::new(1.0, Theme::BORDER)) + .inner_margin(egui::Margin::symmetric(16, 0)) + .corner_radius(egui::CornerRadius { + nw: 0, + ne: 0, + sw: 8, + se: 8, + }); + + frame.show(ui, |ui| { + ui.set_height(72.0); + ui.horizontal(|ui| { + // 1. Branding Section + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label( + egui::RichText::new(Theme::ICON_BRAND) + .size(32.0) + .color(Theme::TEXT_PRIMARY), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.add_space(18.0); + ui.label( + egui::RichText::new("TACTICAL") + .size(12.0) + .color(Theme::TEXT_SECONDARY) + .strong(), + ); + ui.label( + egui::RichText::new("COMMAND CENTER") + .size(18.0) + .color(Theme::TEXT_PRIMARY) + .strong() + .extra_letter_spacing(1.0), + ); + }); + }); + + ui.add_space(24.0); + self.draw_divider(ui); + ui.add_space(24.0); + + // 2. Action Group + self.draw_action_item(ui, Theme::ICON_CONNECTIONS, "CONNECTIONS"); + self.draw_action_item(ui, Theme::ICON_BOOKMARKS, "BOOKMARKS"); + self.draw_action_item(ui, Theme::ICON_SELF, "SELF"); + self.draw_action_item(ui, Theme::ICON_PERMISSIONS, "PERMISSIONS"); + self.draw_action_item(ui, Theme::ICON_TOOLS, "TOOLS"); + self.draw_action_item(ui, Theme::ICON_INFO, "INFO & HELP"); + + }); + }); + } + + fn draw_divider(&self, ui: &mut egui::Ui) { + let rect = ui.available_rect_before_wrap(); + let center_x = rect.min.x; + let top = rect.min.y + 16.0; + let bottom = rect.max.y - 16.0; + ui.painter().vline(center_x, top..=bottom, egui::Stroke::new(1.0, Theme::BORDER)); + } + + fn draw_action_item(&mut self, ui: &mut egui::Ui, icon: &str, label: &str) { + let is_active = self.active_tab == label; + let text_color = if is_active { Theme::SIGNAL_GREEN } else { Theme::TEXT_SECONDARY }; + + let (rect, response) = ui.allocate_at_least(egui::vec2(80.0, 72.0), egui::Sense::click()); + + if response.clicked() { + self.active_tab = label.to_string(); + } + + let painter = ui.painter(); + + if response.hovered() { + painter.rect_filled( + rect.expand(2.0), + egui::CornerRadius::same(4), + Theme::SIGNAL_GREEN.linear_multiply(0.05), + ); + } + + // Draw Icon + let icon_pos = rect.center() + egui::vec2(0.0, -10.0); + painter.text( + icon_pos, + egui::Align2::CENTER_CENTER, + icon, + egui::FontId::proportional(24.0), + text_color, + ); + + // Draw Label + let label_pos = rect.center() + egui::vec2(0.0, 14.0); + painter.text( + label_pos, + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(10.0), + text_color, + ); + + // Draw Underline + if is_active { + let underline_y = rect.max.y - 2.0; + painter.hline( + rect.min.x + 8.0..=rect.max.x - 8.0, + underline_y, + egui::Stroke::new(2.0, Theme::SIGNAL_GREEN), + ); + } + + ui.add_space(8.0); + } +} diff --git a/client_node/src/ui/mod.rs b/client_node/src/ui/mod.rs index 4a5dcfe..efa0949 100644 --- a/client_node/src/ui/mod.rs +++ b/client_node/src/ui/mod.rs @@ -7,5 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used)] pub mod app; +pub mod theme; +pub mod components; pub use app::VoiceApp; diff --git a/client_node/src/ui/theme.rs b/client_node/src/ui/theme.rs new file mode 100644 index 0000000..8e0cc6e --- /dev/null +++ b/client_node/src/ui/theme.rs @@ -0,0 +1,110 @@ +use eframe::egui; + +pub struct Theme; + +impl Theme { + // ── Global Design System (Tactical DNA) ── + pub const BASE: egui::Color32 = egui::Color32::from_rgb(15, 18, 21); // #0F1215 Deep Matte Slate + pub const PANEL_OPACITY: f32 = 0.4; + pub const BORDER: egui::Color32 = egui::Color32::from_rgba_premultiplied(255, 255, 255, 26); // #ffffff1a + + pub const SIGNAL_GREEN: egui::Color32 = egui::Color32::from_rgb(0, 255, 136); // #00FF88 + pub const COBALT_BLUE: egui::Color32 = egui::Color32::from_rgb(74, 144, 226); // #4A90E2 + pub const TACTICAL_AMBER: egui::Color32 = egui::Color32::from_rgb(255, 179, 0); // #FFB300 + pub const CRIMSON_ALERT: egui::Color32 = egui::Color32::from_rgb(255, 75, 75); // #FF4B4B + + pub const TEXT_PRIMARY: egui::Color32 = egui::Color32::from_rgb(224, 224, 224); // #E0E0E0 + pub const TEXT_SECONDARY: egui::Color32 = egui::Color32::from_rgb(138, 138, 138); // #8A8A8A + pub const TEXT_DIM: egui::Color32 = egui::Color32::from_rgb(60, 60, 70); + + // ── Icons (FontAwesome) ── + pub const ICON_BRAND: &'static str = "\u{f58f}"; // headset + pub const ICON_CONNECTIONS: &'static str = "\u{f0ac}"; // globe + pub const ICON_BOOKMARKS: &'static str = "\u{f005}"; // star + pub const ICON_SELF: &'static str = "\u{f4fe}"; // user-gear + pub const ICON_PERMISSIONS: &'static str = "\u{f3ed}"; // shield + pub const ICON_TOOLS: &'static str = "\u{f085}"; // gears + pub const ICON_INFO: &'static str = "\u{f05a}"; // info-circle + + pub fn apply(ctx: &egui::Context) { + let mut style = (*ctx.global_style()).clone(); + + style.visuals.dark_mode = true; + style.visuals.panel_fill = Self::BASE; + style.visuals.window_fill = Self::BASE.linear_multiply(Self::PANEL_OPACITY); + style.visuals.extreme_bg_color = egui::Color32::BLACK; + + style.visuals.widgets.noninteractive.bg_fill = Self::BASE; + style.visuals.widgets.inactive.bg_fill = egui::Color32::from_rgba_premultiplied(20, 20, 25, 100); + style.visuals.widgets.hovered.bg_fill = egui::Color32::from_rgba_premultiplied(40, 40, 50, 150); + style.visuals.widgets.active.bg_fill = Self::COBALT_BLUE; + + style.visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, Self::TEXT_SECONDARY); + style.visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, Self::TEXT_PRIMARY); + style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); + style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); + + style.visuals.selection.bg_fill = Self::COBALT_BLUE.linear_multiply(0.2); + style.visuals.selection.stroke = egui::Stroke::new(1.0, Self::COBALT_BLUE); + + style.visuals.window_corner_radius = egui::CornerRadius::same(4); + style.visuals.widgets.noninteractive.corner_radius = egui::CornerRadius::same(2); + style.visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(2); + style.visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(2); + style.visuals.widgets.active.corner_radius = egui::CornerRadius::same(2); + + style.spacing.item_spacing = egui::vec2(12.0, 10.0); + style.spacing.window_margin = egui::Margin::same(16); + + ctx.set_global_style(style); + + Self::setup_fonts(ctx); + } + + fn setup_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + // 1. Atkinson Hyperlegible (Primary) + if let Ok(regular_data) = std::fs::read("assets/fonts/Atkinson-Regular.ttf") { + fonts.font_data.insert( + "atkinson-regular".to_owned(), + egui::FontData::from_owned(regular_data).into(), + ); + } + + if let Ok(bold_data) = std::fs::read("assets/fonts/Atkinson-Bold.ttf") { + fonts.font_data.insert( + "atkinson-bold".to_owned(), + egui::FontData::from_owned(bold_data).into(), + ); + } + + // 2. FontAwesome (Icons) + if let Ok(icon_data) = std::fs::read("assets/fonts/FontAwesome-Solid.otf") { + fonts.font_data.insert( + "font-awesome".to_owned(), + egui::FontData::from_owned(icon_data).into(), + ); + } + + // Configure Proportional (Main text + Icons merged) + fonts.families.get_mut(&egui::FontFamily::Proportional).unwrap() + .insert(0, "atkinson-regular".to_owned()); + fonts.families.get_mut(&egui::FontFamily::Proportional).unwrap() + .push("font-awesome".to_owned()); + + // Configure Monospace + fonts.families.get_mut(&egui::FontFamily::Monospace).unwrap() + .push("atkinson-regular".to_owned()); + + ctx.set_fonts(fonts); + } + + pub fn glass_frame() -> egui::Frame { + egui::Frame::NONE + .fill(Self::BASE.linear_multiply(Self::PANEL_OPACITY)) + .stroke(egui::Stroke::new(1.0, Self::BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(12) + } +}