diff --git a/src/bin/vimit-gui.rs b/src/bin/vimit-gui.rs index 348a143..897a3b6 100644 --- a/src/bin/vimit-gui.rs +++ b/src/bin/vimit-gui.rs @@ -184,6 +184,68 @@ enum CreatureSound { Recovery, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreatureState { + Sleeping, + Awake, + Alert, + Critical, + Recovery, +} + +impl CreatureState { + fn as_str(self) -> &'static str { + match self { + Self::Sleeping => "sleeping", + Self::Awake => "awake", + Self::Alert => "alert", + Self::Critical => "critical", + Self::Recovery => "recovery", + } + } +} + +fn creature_state_for( + percent: f32, + credit_rate: f32, + reset_detected: bool, + samples_len: usize, +) -> CreatureState { + if reset_detected { + return CreatureState::Recovery; + } + if samples_len > 1 && credit_rate <= 0.05 { + return CreatureState::Sleeping; + } + if percent >= 90.0 { + CreatureState::Critical + } else if percent >= 75.0 { + CreatureState::Alert + } else { + CreatureState::Awake + } +} + +fn overlay_phase_step(state: CreatureState) -> f32 { + match state { + CreatureState::Sleeping => 2.0, + CreatureState::Awake => 7.0, + CreatureState::Alert => 9.0, + CreatureState::Critical => 13.0, + CreatureState::Recovery => 16.0, + } +} + +fn creature_state_from_str(value: &str) -> CreatureState { + match value { + "sleeping" => CreatureState::Sleeping, + "alert" => CreatureState::Alert, + "critical" => CreatureState::Critical, + "recovery" => CreatureState::Recovery, + _ => CreatureState::Awake, + } +} + fn level_rank(level: &str) -> u8 { match level { "danger" => 2, @@ -594,7 +656,8 @@ fn main() { let weak = app.as_weak(); pulse_timer.start(TimerMode::Repeated, Duration::from_millis(80), move || { if let Some(app) = weak.upgrade() { - let next = (app.get_overlay_pulse_phase() + 7.0) % 360.0; + let state = creature_state_from_str(app.get_overlay_creature_state().as_str()); + let next = (app.get_overlay_pulse_phase() + overlay_phase_step(state)) % 360.0; app.set_overlay_pulse_phase(next); let percent = app.get_overlay_creature_percent(); let skin = app.get_overlay_creature_skin(); @@ -1033,7 +1096,11 @@ struct OverlayUsagePoint { } impl OverlayHistory { - fn record_usage(&mut self, at: Instant, used: f64) -> f32 { + fn record_usage(&mut self, at: Instant, used: f64) -> (f32, bool) { + let reset_detected = self + .observations + .back() + .is_some_and(|latest| used < latest.used); self.observations.push_back(OverlayUsagePoint { at, used }); self.trim_observations(at); @@ -1042,7 +1109,7 @@ impl OverlayHistory { self.samples.pop_front(); } self.samples.push_back(rate); - rate + (rate, reset_detected) } fn average_credit_rate(&self, now: Instant) -> f32 { @@ -1082,6 +1149,7 @@ struct OverlayState { percent_hour_text: String, spark_data: Vec, creature_percent: f32, + creature_state: CreatureState, delta_text: String, delta_level: String, reset_label: String, @@ -1152,7 +1220,9 @@ fn apply_dashboard(app: &AppWindow, result: Result) .find(|window| window.key == "5h") .map(|window| window.level.as_str()) .unwrap_or("ok"); - maybe_play_creature_sound(creature_level); + if res.overlay.creature_state != CreatureState::Sleeping { + maybe_play_creature_sound(creature_level); + } app.set_status_text(dashboard.status.into()); app.set_source_text(dashboard.source.into()); app.set_agent_text(dashboard.agent.into()); @@ -1174,6 +1244,7 @@ fn apply_dashboard(app: &AppWindow, result: Result) app.set_overlay_reset_seconds(res.overlay.reset_seconds); app.set_overlay_reset_text(format_overlay_countdown(res.overlay.reset_seconds).into()); app.set_overlay_creature_percent(res.overlay.creature_percent); + app.set_overlay_creature_state(res.overlay.creature_state.as_str().into()); let skin = app.get_overlay_creature_skin(); app.set_overlay_creature_points(creature_node_count_for_skin( res.overlay.creature_percent, @@ -1527,7 +1598,7 @@ fn build_overlay_state( .unwrap_or(0.0); let mut history = history.lock().unwrap(); - let credit_rate = current_used + let (credit_rate, reset_detected) = current_used .map(|used| history.record_usage(now, used)) .unwrap_or_default(); @@ -1535,6 +1606,12 @@ fn build_overlay_state( let spark_data = scale_samples(&samples); let token_rate = parse_rate_value(token_rate_text).unwrap_or(credit_rate * 750.0); let creature_percent = five.map(|window| window.percent as f32).unwrap_or(0.0); + let creature_state = creature_state_for( + creature_percent, + credit_rate, + reset_detected, + history.samples.len(), + ); let percent_hour = if credit_limit > 0.0 { (credit_rate as f64 / credit_limit * 60.0 * 100.0) as f32 } else { @@ -1549,6 +1626,7 @@ fn build_overlay_state( percent_hour_text: format!("{}%/час", one_decimal_local(percent_hour)), spark_data, creature_percent, + creature_state, delta_text, delta_level, reset_label, @@ -1712,6 +1790,42 @@ mod tests { assert_eq!(overlay_logical_size(true), (260.0, 52.0)); } + #[test] + fn creature_state_uses_sleep_and_thresholds() { + assert_eq!( + creature_state_for(10.0, 0.0, false, 2), + CreatureState::Sleeping + ); + assert_eq!( + creature_state_for(40.0, 0.2, false, 2), + CreatureState::Awake + ); + assert_eq!( + creature_state_for(80.0, 0.2, false, 2), + CreatureState::Alert + ); + assert_eq!( + creature_state_for(92.0, 0.2, false, 2), + CreatureState::Critical + ); + assert_eq!( + creature_state_for(10.0, 0.0, true, 2), + CreatureState::Recovery + ); + } + + #[test] + fn creature_phase_step_slows_down_sleeping_state() { + assert!( + overlay_phase_step(CreatureState::Sleeping) < overlay_phase_step(CreatureState::Awake) + ); + assert!( + overlay_phase_step(CreatureState::Critical) > overlay_phase_step(CreatureState::Alert) + ); + assert_eq!(creature_state_from_str("sleeping"), CreatureState::Sleeping); + assert_eq!(creature_state_from_str("unknown"), CreatureState::Awake); + } + #[test] fn gui_runtime_config_keeps_auto_failover_setting() { let dotenv = HashMap::new(); @@ -1852,14 +1966,14 @@ mod tests { let mut history = OverlayHistory::default(); let now = Instant::now(); - assert_eq!(history.record_usage(now, 0.0), 0.0); + assert_eq!(history.record_usage(now, 0.0), (0.0, false)); assert_eq!( history.record_usage(now + Duration::from_secs(10), 1_000.0), - 200.0 + (200.0, false) ); assert_eq!( history.record_usage(now + Duration::from_secs(5 * 60), 1_500.0), - 300.0 + (300.0, false) ); } @@ -1875,4 +1989,15 @@ mod tests { assert_eq!(history.observations.len(), 1); assert_eq!(history.average_credit_rate(later), 0.0); } + + #[test] + fn overlay_history_detects_window_reset() { + let mut history = OverlayHistory::default(); + let now = Instant::now(); + + assert_eq!(history.record_usage(now, 80.0), (0.0, false)); + let (_, reset_detected) = history.record_usage(now + Duration::from_secs(60), 5.0); + + assert!(reset_detected); + } } diff --git a/ui/app.slint b/ui/app.slint index 076758f..630a96a 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -277,12 +277,15 @@ component DonutChart inherits Rectangle { component PulseOrganism inherits Rectangle { in property level; + in property creature-state; in property phase; in property commands; in property node-count; - private property accent: root.level == "danger" ? #f85149 : root.level == "warning" ? #d29922 : #3fb950; + private property accent: root.creature-state == "sleeping" ? #6f7f96 : root.creature-state == "recovery" ? #9be9a8 : root.level == "danger" ? #f85149 : root.level == "warning" ? #d29922 : #3fb950; private property has-dynamic-shape: root.commands != ""; + private property energy-scale: root.creature-state == "sleeping" ? 0.78 : root.creature-state == "recovery" ? 1.10 : root.creature-state == "critical" ? 1.06 : 1.0; + private property creature-opacity: root.creature-state == "sleeping" ? 0.58 : 0.88; private property r0: 34 + 4 * sin(root.phase * 1deg); private property r1: 32 + 5 * sin((root.phase + 48) * 1deg); private property r2: 35 + 3 * sin((root.phase + 116) * 1deg); @@ -301,14 +304,14 @@ component PulseOrganism inherits Rectangle { viewbox-height: 88; fill: root.accent; stroke: transparent; - opacity: 0.12; + opacity: root.creature-state == "sleeping" ? 0.08 : 0.12; visible: !root.has-dynamic-shape; - MoveTo { x: 52 + root.r0; y: 44; } - LineTo { x: 52 + root.r1 * 0.5; y: 44 + root.r1 * 0.866; } - LineTo { x: 52 - root.r2 * 0.5; y: 44 + root.r2 * 0.866; } - LineTo { x: 52 - root.r3; y: 44; } - LineTo { x: 52 - root.r4 * 0.5; y: 44 - root.r4 * 0.866; } - LineTo { x: 52 + root.r5 * 0.5; y: 44 - root.r5 * 0.866; } + MoveTo { x: 52 + root.r0 * root.energy-scale; y: 44; } + LineTo { x: 52 + root.r1 * root.energy-scale * 0.5; y: 44 + root.r1 * root.energy-scale * 0.866; } + LineTo { x: 52 - root.r2 * root.energy-scale * 0.5; y: 44 + root.r2 * root.energy-scale * 0.866; } + LineTo { x: 52 - root.r3 * root.energy-scale; y: 44; } + LineTo { x: 52 - root.r4 * root.energy-scale * 0.5; y: 44 - root.r4 * root.energy-scale * 0.866; } + LineTo { x: 52 + root.r5 * root.energy-scale * 0.5; y: 44 - root.r5 * root.energy-scale * 0.866; } Close {} } @@ -319,8 +322,8 @@ component PulseOrganism inherits Rectangle { viewbox-height: 88; fill: transparent; stroke: root.accent; - stroke-width: 2.2px; - opacity: 0.86; + stroke-width: root.creature-state == "sleeping" ? 1.4px : 2.2px; + opacity: root.creature-opacity; visible: !root.has-dynamic-shape; MoveTo { x: 52 + root.r0; y: 44; } LineTo { x: 52 + root.r1 * 0.5; y: 44 + root.r1 * 0.866; } @@ -356,7 +359,7 @@ component PulseOrganism inherits Rectangle { viewbox-height: 88; fill: root.accent; stroke: transparent; - opacity: 0.13; + opacity: root.creature-state == "sleeping" ? 0.08 : 0.13; visible: root.has-dynamic-shape; commands: root.commands; } @@ -368,8 +371,8 @@ component PulseOrganism inherits Rectangle { viewbox-height: 88; fill: transparent; stroke: root.accent; - stroke-width: root.node-count > 64 ? 1.5px : 2.1px; - opacity: 0.88; + stroke-width: root.creature-state == "sleeping" ? 1.2px : root.node-count > 64 ? 1.5px : 2.1px; + opacity: root.creature-opacity; visible: root.has-dynamic-shape; commands: root.commands; } @@ -381,7 +384,7 @@ component PulseOrganism inherits Rectangle { y: 35px; border-radius: 9px; background: root.accent; - opacity: 0.18 + 0.10 * sin(root.phase * 1deg); + opacity: root.creature-state == "sleeping" ? 0.10 + 0.04 * sin(root.phase * 1deg) : 0.18 + 0.10 * sin(root.phase * 1deg); } } @@ -444,6 +447,7 @@ export component AppWindow inherits Window { in property overlay-creature-path: ""; in property overlay-creature-points: 8; in property overlay-creature-percent: 0; + in property overlay-creature-state: "awake"; in-out property overlay-creature-skin: 0; in-out property overlay-pulse-phase: 0; in-out property stealth-mode: false; @@ -1288,7 +1292,7 @@ export component AppWindow inherits Window { spacing: 4px; HorizontalLayout { Text { - text: "живой расход · \{root.overlay-creature-points} точек"; + text: "живой расход · " + (root.overlay-creature-state == "sleeping" ? "спит" : root.overlay-creature-state == "alert" ? "насторожен" : root.overlay-creature-state == "critical" ? "тревога" : root.overlay-creature-state == "recovery" ? "выдох" : "бодрствует") + " · \{root.overlay-creature-points} точек"; font-size: 11px; color: #7d8590; vertical-alignment: center; @@ -1308,6 +1312,7 @@ export component AppWindow inherits Window { alignment: center; PulseOrganism { level: root.overlay-delta-level; + creature-state: root.overlay-creature-state; phase: root.overlay-pulse-phase; commands: root.overlay-creature-path; node-count: root.overlay-creature-points;