Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 133 additions & 8 deletions src/bin/vimit-gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -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 {
Expand Down Expand Up @@ -1082,6 +1149,7 @@ struct OverlayState {
percent_hour_text: String,
spark_data: Vec<f32>,
creature_percent: f32,
creature_state: CreatureState,
delta_text: String,
delta_level: String,
reset_label: String,
Expand Down Expand Up @@ -1152,7 +1220,9 @@ fn apply_dashboard(app: &AppWindow, result: Result<GuiDashboardResult, String>)
.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());
Expand All @@ -1174,6 +1244,7 @@ fn apply_dashboard(app: &AppWindow, result: Result<GuiDashboardResult, String>)
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,
Expand Down Expand Up @@ -1527,14 +1598,20 @@ 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();

let samples: Vec<f32> = history.samples.iter().copied().collect();
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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
);
}

Expand All @@ -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);
}
}
35 changes: 20 additions & 15 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,15 @@ component DonutChart inherits Rectangle {

component PulseOrganism inherits Rectangle {
in property <string> level;
in property <string> creature-state;
in property <float> phase;
in property <string> commands;
in property <int> node-count;

private property <brush> accent: root.level == "danger" ? #f85149 : root.level == "warning" ? #d29922 : #3fb950;
private property <brush> accent: root.creature-state == "sleeping" ? #6f7f96 : root.creature-state == "recovery" ? #9be9a8 : root.level == "danger" ? #f85149 : root.level == "warning" ? #d29922 : #3fb950;
private property <bool> has-dynamic-shape: root.commands != "";
private property <float> energy-scale: root.creature-state == "sleeping" ? 0.78 : root.creature-state == "recovery" ? 1.10 : root.creature-state == "critical" ? 1.06 : 1.0;
private property <float> creature-opacity: root.creature-state == "sleeping" ? 0.58 : 0.88;
private property <float> r0: 34 + 4 * sin(root.phase * 1deg);
private property <float> r1: 32 + 5 * sin((root.phase + 48) * 1deg);
private property <float> r2: 35 + 3 * sin((root.phase + 116) * 1deg);
Expand All @@ -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 {}
}

Expand All @@ -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; }
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -444,6 +447,7 @@ export component AppWindow inherits Window {
in property <string> overlay-creature-path: "";
in property <int> overlay-creature-points: 8;
in property <float> overlay-creature-percent: 0;
in property <string> overlay-creature-state: "awake";
in-out property <int> overlay-creature-skin: 0;
in-out property <float> overlay-pulse-phase: 0;
in-out property <bool> stealth-mode: false;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading