ringkernel_procint/gui/
widgets.rs

1//! Custom widgets for process intelligence GUI.
2
3use super::Theme;
4use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Stroke, Vec2};
5
6/// KPI display widget.
7pub fn kpi_card(ui: &mut egui::Ui, theme: &Theme, label: &str, value: &str, trend: Option<f32>) {
8    egui::Frame::none()
9        .fill(Color32::from_rgb(38, 38, 48))
10        .rounding(Rounding::same(6.0))
11        .inner_margin(egui::Margin::same(10.0))
12        .show(ui, |ui| {
13            ui.vertical(|ui| {
14                ui.label(
15                    egui::RichText::new(label)
16                        .size(11.0)
17                        .color(theme.text_muted),
18                );
19                ui.horizontal(|ui| {
20                    ui.label(egui::RichText::new(value).size(20.0).strong());
21                    if let Some(trend) = trend {
22                        let (icon, color) = if trend > 0.0 {
23                            ("↑", theme.success)
24                        } else if trend < 0.0 {
25                            ("↓", theme.error)
26                        } else {
27                            ("→", theme.text_muted)
28                        };
29                        ui.label(egui::RichText::new(icon).size(14.0).color(color));
30                    }
31                });
32            });
33        });
34}
35
36/// Progress bar with label.
37pub fn labeled_progress(
38    ui: &mut egui::Ui,
39    theme: &Theme,
40    label: &str,
41    progress: f32,
42    color: Option<Color32>,
43) {
44    ui.horizontal(|ui| {
45        ui.label(egui::RichText::new(label).size(12.0));
46        ui.add_space(8.0);
47        let bar_width = ui.available_width() - 50.0;
48        let (rect, _) = ui.allocate_exact_size(Vec2::new(bar_width, 16.0), egui::Sense::hover());
49
50        // Background
51        ui.painter()
52            .rect_filled(rect, Rounding::same(4.0), Color32::from_rgb(38, 38, 48));
53
54        // Fill
55        let fill_width = rect.width() * progress.clamp(0.0, 1.0);
56        let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height()));
57        ui.painter().rect_filled(
58            fill_rect,
59            Rounding::same(4.0),
60            color.unwrap_or(theme.accent),
61        );
62
63        ui.add_space(4.0);
64        ui.label(egui::RichText::new(format!("{:.0}%", progress * 100.0)).size(11.0));
65    });
66}
67
68/// Status indicator dot.
69pub fn status_dot(ui: &mut egui::Ui, color: Color32, pulsing: bool) {
70    let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), egui::Sense::hover());
71    let center = rect.center();
72    let radius = 4.0;
73
74    if pulsing {
75        let time = ui.ctx().input(|i| i.time);
76        let alpha = ((time * 3.0).sin() * 0.5 + 0.5) as f32;
77        let pulse_color = color.linear_multiply(0.3 + alpha * 0.7);
78        ui.painter()
79            .circle_filled(center, radius + 2.0, pulse_color.linear_multiply(0.3));
80    }
81
82    ui.painter().circle_filled(center, radius, color);
83}
84
85/// Mini sparkline chart.
86pub fn sparkline(ui: &mut egui::Ui, theme: &Theme, values: &[f32], width: f32, height: f32) {
87    if values.is_empty() {
88        return;
89    }
90
91    let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover());
92
93    let max_val = values.iter().copied().fold(f32::MIN, f32::max).max(1.0);
94    let min_val = values.iter().copied().fold(f32::MAX, f32::min);
95    let range = (max_val - min_val).max(1.0);
96
97    let points: Vec<Pos2> = values
98        .iter()
99        .enumerate()
100        .map(|(i, &v)| {
101            let x = rect.left() + (i as f32 / values.len().max(1) as f32) * rect.width();
102            let y = rect.bottom() - ((v - min_val) / range) * rect.height();
103            Pos2::new(x, y)
104        })
105        .collect();
106
107    // Draw line
108    if points.len() > 1 {
109        for window in points.windows(2) {
110            ui.painter()
111                .line_segment([window[0], window[1]], Stroke::new(1.5, theme.accent));
112        }
113    }
114
115    // Draw dots at endpoints
116    if let Some(first) = points.first() {
117        ui.painter().circle_filled(*first, 2.0, theme.accent);
118    }
119    if let Some(last) = points.last() {
120        ui.painter().circle_filled(*last, 3.0, theme.accent);
121    }
122}
123
124/// Pattern badge.
125pub fn pattern_badge(ui: &mut egui::Ui, pattern_type: &str, severity_color: Color32, count: u64) {
126    egui::Frame::none()
127        .fill(severity_color.linear_multiply(0.2))
128        .rounding(Rounding::same(4.0))
129        .inner_margin(egui::Margin::symmetric(8.0, 4.0))
130        .stroke(Stroke::new(1.0, severity_color))
131        .show(ui, |ui| {
132            ui.horizontal(|ui| {
133                ui.label(
134                    egui::RichText::new(pattern_type)
135                        .size(11.0)
136                        .color(severity_color),
137                );
138                if count > 1 {
139                    ui.label(
140                        egui::RichText::new(format!("×{}", count))
141                            .size(10.0)
142                            .color(severity_color.linear_multiply(0.7)),
143                    );
144                }
145            });
146        });
147}
148
149/// Sector selector dropdown.
150pub fn sector_selector(ui: &mut egui::Ui, current: &mut crate::fabric::SectorTemplate) -> bool {
151    use crate::fabric::{
152        FinanceConfig, HealthcareConfig, IncidentConfig, ManufacturingConfig, SectorTemplate,
153    };
154
155    let sectors = [
156        SectorTemplate::Healthcare(HealthcareConfig::default()),
157        SectorTemplate::Manufacturing(ManufacturingConfig::default()),
158        SectorTemplate::Finance(FinanceConfig::default()),
159        SectorTemplate::IncidentManagement(IncidentConfig::default()),
160    ];
161
162    let mut changed = false;
163
164    egui::ComboBox::from_label("Sector")
165        .selected_text(current.name())
166        .show_ui(ui, |ui| {
167            for sector in &sectors {
168                if ui
169                    .selectable_value(current, sector.clone(), sector.name())
170                    .clicked()
171                {
172                    changed = true;
173                }
174            }
175        });
176
177    changed
178}
179
180/// Conformance gauge (circular progress).
181pub fn conformance_gauge(ui: &mut egui::Ui, theme: &Theme, fitness: f32, size: f32) {
182    let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), egui::Sense::hover());
183    let center = rect.center();
184    let radius = size / 2.0 - 4.0;
185
186    // Background arc
187    let stroke_width = 6.0;
188    ui.painter().circle_stroke(
189        center,
190        radius,
191        Stroke::new(stroke_width, Color32::from_rgb(38, 38, 48)),
192    );
193
194    // Fitness arc
195    let color = theme.fitness_color(fitness);
196
197    // Draw arc segments
198    let segments = 32;
199    let start_angle = -std::f32::consts::FRAC_PI_2;
200    for i in 0..segments {
201        let t = i as f32 / segments as f32;
202        if t > fitness {
203            break;
204        }
205        let a1 = start_angle + t * std::f32::consts::TAU;
206        let a2 = start_angle + (i + 1) as f32 / segments as f32 * std::f32::consts::TAU;
207        let p1 = center + Vec2::new(a1.cos(), a1.sin()) * radius;
208        let p2 = center + Vec2::new(a2.cos(), a2.sin()) * radius;
209        ui.painter()
210            .line_segment([p1, p2], Stroke::new(stroke_width, color));
211    }
212
213    // Center text
214    let text = format!("{:.0}%", fitness * 100.0);
215    ui.painter().text(
216        center,
217        egui::Align2::CENTER_CENTER,
218        text,
219        egui::FontId::proportional(size / 4.0),
220        theme.text,
221    );
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_theme() {
230        let theme = Theme::dark();
231        assert!(theme.fitness_color(0.95) == theme.success);
232    }
233}