UI progress

This commit is contained in:
sam
2026-05-10 23:56:54 +02:00
parent 2679f0b063
commit 19f53559fd
21 changed files with 1138 additions and 551 deletions

1
Cargo.lock generated
View File

@@ -1068,6 +1068,7 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",

View File

@@ -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 }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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");
}
}

View File

@@ -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]];

View File

@@ -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,
)))
}),
)

View File

@@ -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.01.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.00.5 → 0.01.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");
});
}
}
}

View 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);
}
}
}
}

View 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);
}
}

View 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();
}
}
});
});
});
}
}

View 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)
}
}

View 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;

View 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());
});
}
}

View 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,
&sector.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)));
}
}

View 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);
}
}

View File

@@ -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
View 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)
}
}