fix(ui-dioxus): class-toggle animation instead of inline opacity (Blitz compatible)

This commit is contained in:
Hibryda 2026-03-19 07:11:01 +01:00
parent 67ab77ebf4
commit 2f03cf0ef0
4 changed files with 66 additions and 153 deletions

View file

@ -1,10 +1,15 @@
/// PulsingDot — Blitz-compatible animated status indicator using dioxus-motion.
/// PulsingDot — Blitz-compatible animated status indicator.
///
/// Uses dioxus-motion's Tween with LoopMode::Alternate for infinite ping-pong.
/// Avoids CSS @keyframes entirely — no Blitz full-scene repaint loop.
/// Instead of animating opacity (Blitz doesn't reliably restyle inline style changes),
/// we toggle between "bright" and "dim" CSS classes using a simple OS thread timer.
/// This changes the `class` attribute which Blitz DOES process for restyling.
///
/// CPU cost: schedule_update fires every 1s (2 updates per cycle), not 30fps.
use dioxus::prelude::*;
use dioxus_motion::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone, PartialEq)]
pub enum DotState {
@ -18,44 +23,43 @@ pub enum DotState {
#[component]
pub fn PulsingDot(state: DotState, size: Option<String>) -> Element {
let should_pulse = matches!(state, DotState::Running | DotState::Stalled);
let mut opacity = use_motion(1.0f32);
let is_dim = use_hook(|| Arc::new(AtomicBool::new(false)));
// Infinite ping-pong tween: 1.0 → 0.4 → 1.0 → ...
if should_pulse {
use_effect(move || {
opacity.animate_to(
0.4,
AnimationConfig::new(AnimationMode::Tween(
Tween::new(Duration::from_millis(1000))
))
.with_loop(LoopMode::Alternate),
);
let dim = is_dim.clone();
let updater = use_hook(|| dioxus::dioxus_core::schedule_update());
use_hook(move || {
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(1000));
dim.fetch_xor(true, Ordering::Relaxed); // toggle
updater(); // trigger re-render
}
});
});
}
let (color, shadow) = match state {
DotState::Running => ("var(--ctp-green)", "0 0 6px var(--ctp-green)"),
DotState::Idle => ("var(--ctp-overlay0)", "none"),
DotState::Stalled => ("var(--ctp-peach)", "0 0 4px var(--ctp-peach)"),
DotState::Done => ("var(--ctp-blue)", "none"),
DotState::Error => ("var(--ctp-red)", "0 0 4px var(--ctp-red)"),
let base_class = match state {
DotState::Running => "dot-running",
DotState::Idle => "dot-idle",
DotState::Stalled => "dot-stalled",
DotState::Done => "dot-done",
DotState::Error => "dot-error",
};
let dim_class = if should_pulse && is_dim.load(Ordering::Relaxed) {
"dot-dim"
} else {
""
};
let sz = size.unwrap_or_else(|| "8px".to_string());
let op = if should_pulse { opacity.get_value() } else { 1.0 };
rsx! {
span {
style: "
display: inline-block;
width: {sz};
height: {sz};
border-radius: 50%;
background: {color};
box-shadow: {shadow};
opacity: {op};
flex-shrink: 0;
",
class: "pulsing-dot {base_class} {dim_class}",
style: "width: {sz}; height: {sz};",
}
}
}