UI progress
This commit is contained in:
@@ -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 }
|
||||
|
||||
BIN
client_node/assets/fonts/Atkinson-Bold.ttf
Normal file
BIN
client_node/assets/fonts/Atkinson-Bold.ttf
Normal file
Binary file not shown.
BIN
client_node/assets/fonts/Atkinson-Regular.ttf
Normal file
BIN
client_node/assets/fonts/Atkinson-Regular.ttf
Normal file
Binary file not shown.
BIN
client_node/assets/fonts/FontAwesome-Solid.otf
Normal file
BIN
client_node/assets/fonts/FontAwesome-Solid.otf
Normal file
Binary file not shown.
BIN
client_node/assets/fonts/Inter-Bold.ttf
Normal file
BIN
client_node/assets/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
client_node/assets/fonts/Inter-Regular.ttf
Normal file
BIN
client_node/assets/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Self, String> { Ok(Self) }
|
||||
fn set_config(&self, _config: ()) {}
|
||||
fn process_capture_frame(&self, _frame: &mut Vec<Vec<f32>>) -> 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<f32>,
|
||||
mut loopback_prod: HeapProd<f32>,
|
||||
@@ -47,25 +53,28 @@ pub fn spawn_dsp_thread(
|
||||
mic_level_tx: watch::Sender<f32>,
|
||||
) {
|
||||
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]];
|
||||
|
||||
|
||||
@@ -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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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<bool>,
|
||||
/// Receiver for the current microphone RMS level (0.0–1.0).
|
||||
mic_level_rx: watch::Receiver<f32>,
|
||||
/// Shared flag to enable/disable the audio dumper.
|
||||
audio_dumper_flag: Arc<AtomicBool>,
|
||||
/// Shared mute flag (disables outgoing audio when true).
|
||||
mute_flag: Arc<AtomicBool>,
|
||||
/// Shared flag to enable/disable local audio loopback.
|
||||
loopback_flag: Arc<AtomicBool>,
|
||||
// ── 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<ChatMessage>,
|
||||
/// 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<f32>,
|
||||
mute_flag: Arc<AtomicBool>,
|
||||
audio_dumper_flag: Arc<AtomicBool>,
|
||||
|
||||
// UI Local State
|
||||
show_dev_settings: bool,
|
||||
}
|
||||
|
||||
impl VoiceApp {
|
||||
/// Creates a new `VoiceApp` instance.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
active_speaker_rx: watch::Receiver<bool>,
|
||||
_cc: &eframe::CreationContext<'_>,
|
||||
mic_level_rx: watch::Receiver<f32>,
|
||||
audio_dumper_flag: Arc<AtomicBool>,
|
||||
mute_flag: Arc<AtomicBool>,
|
||||
loopback_flag: Arc<AtomicBool>,
|
||||
audio_dumper_flag: Arc<AtomicBool>,
|
||||
) -> 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
client_node/src/ui/components/channel_tree.rs
Normal file
106
client_node/src/ui/components/channel_tree.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
client_node/src/ui/components/chat.rs
Normal file
118
client_node/src/ui/components/chat.rs
Normal file
@@ -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<ChatMessage>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
74
client_node/src/ui/components/chat_overlay.rs
Normal file
74
client_node/src/ui/components/chat_overlay.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
138
client_node/src/ui/components/controls.rs
Normal file
138
client_node/src/ui/components/controls.rs
Normal file
@@ -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<AtomicBool>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
9
client_node/src/ui/components/mod.rs
Normal file
9
client_node/src/ui/components/mod.rs
Normal file
@@ -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;
|
||||
77
client_node/src/ui/components/operator_card.rs
Normal file
77
client_node/src/ui/components/operator_card.rs
Normal file
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
255
client_node/src/ui/components/tactical_canvas.rs
Normal file
255
client_node/src/ui/components/tactical_canvas.rs
Normal file
@@ -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<Sector>,
|
||||
pub users: Vec<User>,
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
133
client_node/src/ui/components/top_ribbon.rs
Normal file
133
client_node/src/ui/components/top_ribbon.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
pub mod app;
|
||||
pub mod theme;
|
||||
pub mod components;
|
||||
|
||||
pub use app::VoiceApp;
|
||||
|
||||
110
client_node/src/ui/theme.rs
Normal file
110
client_node/src/ui/theme.rs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user