diff --git a/Cargo.lock b/Cargo.lock index c5aac2b..54d11f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,10 +4380,12 @@ dependencies = [ "moq-v4l", "moq-video", "mp4-atom", + "objc2-app-kit 0.3.2", "pipewire", "pollster 1.0.1", "qrcodegen", "raw-window-handle", + "rune-fx", "same-file", "serde", "tempfile", @@ -6461,6 +6463,30 @@ dependencies = [ "windowfunctions", ] +[[package]] +name = "rune-fx" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9#b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9" +dependencies = [ + "bytemuck", + "rune-motion", + "rune-staging", + "wgpu", +] + +[[package]] +name = "rune-motion" +version = "0.1.0" +source = "git+https://github.com/anomalyco/rune?rev=b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9#b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9" + +[[package]] +name = "rune-staging" +version = "0.0.1" +source = "git+https://github.com/anomalyco/rune?rev=b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9#b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9" +dependencies = [ + "wgpu", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 90774c0..3f8cecb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ bytes = "1" humantime = "2.4" humantime-serde = "1.1.1" pollster = "1.0" +# The effects the studio UI draws inside egui. Rune is pre-release and asks for +# a rev pin; its `dev` branch is never rewritten. +rune-fx = { git = "https://github.com/anomalyco/rune", rev = "b99c7b04e75c0b7587fe8d09ba6a679e11ede9c9" } serde = { version = "1", features = ["derive"] } serde_with = "3" tempfile = "3" diff --git a/README.md b/README.md index f9cde43..eb24151 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ Every panel is a tab you can drag, split, or stack. | Replay | Replay the last moments of the program or a source in a replay source, slowed down if you like, or save them as a clip. | | Hotkeys | Bind keys to cut, transition, preview a scene, replay, save a clip, and start or stop recording, and clear them (see [Hotkeys](#hotkeys)). | +While recording, a red dot in the Program panel says so. + A new source, from the Sources or Devices panel, is placed over whatever the Items panel shows. The **top layer** is drawn over program, and over the preview of a scene of the program's size, and stays on through a cut or @@ -971,14 +973,33 @@ with, such as a function key, a keypad key, or one with modifiers: bound. Without the portal, or if the dialog is declined, bound keys work only while the studio has focus, and the panel says so. -The bindings, the snap and grid settings, and the output **Listen** plays on -are kept per user in the app's settings, `~/Library/Application +The bindings, the snap and grid settings, the [effects](#effects) setting, and +the output **Listen** plays on are kept per user in the app's settings, `~/Library/Application Support/moq-studio` on macOS, `$XDG_DATA_HOME/moq-studio` (or `~/.local/share/moq-studio`) on Linux, and `%APPDATA%\moq-studio\data` on Windows, apart from the scene file, so a scene file moves between machines without one operator's keys. A key bound to a scene follows it when renamed in the studio. +## Effects + +The window draws effects of its own, which never reach the program, a +published scene, or a recording: + +- The Program monitor glows red while the studio is connected to a relay, + the recording dot pulses, and each cut or transition, from its button or a + hotkey, bursts on the monitor. +- The **Cut** button is polished metal that ripples under the pointer, the + Hotkeys panel shows each bound key on a keycap, each panel has a faint grain, + and first launch draws its screen over a soft gradient. +- The Health panel and its tab pulse amber while the worst problem is + degraded, and flicker with electricity while it is unhealthy. + +**View > Effects** sets how they move: **Full**, **Reduced**, which holds each +on one still pose, or **Off**. Until you pick one it follows the system's +reduced-motion setting: Reduce motion on macOS, animation effects on Windows, +and on Linux the desktop's, through its Settings portal. + ## Replay Add a `replay` source and place it like any other, such as full screen in a diff --git a/quest/m1/README.md b/quest/m1/README.md index 7b26452..715dd06 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,5 +25,4 @@ than opening an m2 for them. - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server - [Scene graph view](/quest/m1/scene-graph.md) - a node view of picture and audio scenes, their routing, volume, pan, and waveforms -- [Effects in the studio UI](/quest/m1/ui-effects.md) - rune-fx glow, bursts, metal and gradient chrome, and alert effects in the egui UI - [Rune UI spike](/quest/m1/rune-spike.md) - the monitors prototyped on Rune, with a verdict on moving the UI off egui diff --git a/quest/m1/rune-spike.md b/quest/m1/rune-spike.md index 19c2f31..0c71e8c 100644 --- a/quest/m1/rune-spike.md +++ b/quest/m1/rune-spike.md @@ -10,7 +10,8 @@ moving or staying. ## Plan Decided 2026-11-07 with the maintainer (/quest-plan), after choosing -[UI effects](/quest/m1/ui-effects.md) on rune-fx inside egui first: +[UI effects](https://github.com/moq-dev/studio/pull/85) on rune-fx inside +egui first, whose experience feeds the verdict: - ✅ **Prototype the Preview and Program monitors:** live compositor textures, drag-to-edit items, and the Cut button. They are the UI's hardest part, so @@ -23,7 +24,3 @@ Decided 2026-11-07 with the maintainer (/quest-plan), after choosing report. - The prototype stays off `main`: the PR deletes this quest and links the prototype branch. A move to Rune, if picked, is planned with /quest-plan. - -## Required - -- [Effects in the studio UI](/quest/m1/ui-effects.md) - rune-fx inside egui first, whose experience feeds the verdict diff --git a/quest/m1/ui-effects.md b/quest/m1/ui-effects.md deleted file mode 100644 index b832299..0000000 --- a/quest/m1/ui-effects.md +++ /dev/null @@ -1,50 +0,0 @@ -# [L] Effects in the studio UI - -## Goal - -The studio's own UI comes alive with GPU effects from -[rune-fx](https://github.com/anomalyco/rune/tree/dev/crates/rune-fx), drawn -inside egui: - -- **On-air state:** the Program monitor glows or carries an electric border - while the studio publishes, and recording shows a pulse. -- **Cut feedback:** a Cut or transition, from a button or a hotkey, fires a - one-shot burst (shockwave, sparkles, or a zap) on the Program monitor. -- **Buttons and chrome:** the Cut button, the Hotkeys panel's keycaps, and - panel and first-launch backgrounds get rune-fx fills (liquid metal, - plastic, mesh or grain gradients). -- **Health alerts:** a failing uplink or a poor grade shows on the Health - panel and its tab with electric flicker or a warning pulse, scaled to how - bad it is. - -UI effects never touch the program, a published scene, or any encode. They -are not scene effects and travel in no scene file or layout. - -## Plan - -Decided 2026-11-07 with the maintainer (/quest-plan): - -- ✅ **rune-fx inside egui.** Depend on `rune-fx` at a pinned rev of rune's - `dev` branch (Rune is pre-release and asks for a rev pin; `dev` is never - rewritten), declared once in the root `Cargo.toml`. Draw its layers through - `egui_wgpu` paint callbacks on the widgets above. rune-fx is on wgpu 30, as - the studio is. Rejected: moving the UI to Rune now, which - [the Rune spike](/quest/m1/rune-spike.md) evaluates instead. -- ✅ **All four areas** above are in scope. Rejected for now: scene-level - effects (animated background sources and item decorations), video - filters, and user-loadable shaders. -- ✅ **Turning effects down:** follow the OS reduced-motion setting by - default, plus an Effects setting (Full, Reduced, Off) kept with the other - editor settings. Reduced uses rune-fx's reduced mode, which settles one - pose and stops animating. -- Rune takes no pull requests; report a rune-fx bug as an issue there and - bump the rev once it is fixed. - -Recommendations: - -- Keep each effect quiet until its state changes: rune-fx skips a layer that - is idle, finished, or off screen, so an idle studio repaints no more than - today. -- Measure the UI's frame time and GPU cost with effects Full, Reduced, and - Off, and show the program's render time is unchanged. -- `README.md` documents the Effects setting. diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 6049f79..67ab667 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -63,7 +63,7 @@ moq-net = { workspace = true } # The guest room: its path convention, roster, and token claims. moq-room = { workspace = true, features = ["async"] } moq-scene = { workspace = true } -moq-stats = { workspace = true } +moq-stats = { workspace = true, features = ["async"] } moq-tokio = { workspace = true, default-features = false, features = ["net"] } moq-video = { workspace = true } pollster = { workspace = true } @@ -74,6 +74,8 @@ qrcodegen = "1.8" same-file = "1.0.6" # The guests removed from scenes, kept beside the scene file. serde = { workspace = true } +# Glows, pulses, bursts, and fills in the UI, drawn on its wgpu device. +rune-fx = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } toml = { workspace = true } @@ -87,9 +89,10 @@ usage-rs = { workspace = true, features = ["completions", "config"] } winit = { version = "0.30", default-features = false, features = ["serde"] } [target.'cfg(target_os = "linux")'.dependencies] -# The GlobalShortcuts portal for hotkeys on Wayland. 0.13, as moq-video's -# PipeWire capture uses, so the two share one D-Bus stack. -ashpd = { version = "0.13", default-features = false, features = ["global_shortcuts", "tokio"] } +# The GlobalShortcuts portal for hotkeys on Wayland, and the Settings portal's +# reduced motion for the effects. 0.13, as moq-video's PipeWire capture uses, +# so the two share one D-Bus stack. +ashpd = { version = "0.13", default-features = false, features = ["global_shortcuts", "settings", "tokio"] } futures-util = "0.3" # The virtual camera's v4l2loopback fallback. Pure Rust with its kernel bindings # checked in, and already in the graph through moq-video's capture. @@ -108,9 +111,15 @@ x11rb = { version = "0.13", features = ["xkb"] } # goes through the GlobalShortcuts portal. global-hotkey = "0.9" +[target.'cfg(target_os = "macos")'.dependencies] +# Whether the system asks apps to reduce motion, for the effects. eframe's +# version, already in the graph through winit. +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAccessibility", "NSWorkspace"] } + [target.'cfg(windows)'.dependencies] # Which virtual key the foreground window's layout puts on each key, so a -# hotkey registers the key pressed. +# hotkey registers the key pressed, and whether the system asks apps to +# reduce motion, for the effects. windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } [dev-dependencies] diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index c413eab..5ae01b2 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -99,6 +99,9 @@ pub struct State { /// How many times the collection was reloaded from its files, so the UI /// can let go of what a reload may have removed. pub reloads: u64, + /// How many times the preview was taken to program, by a cut or a + /// transition, so the UI can mark each however it was fired. + pub takes: u64, pub sources: Sources, pub compositor: Compositor, /// Each virtual camera on: the program's under `None`, and each published @@ -302,15 +305,18 @@ impl State { /// Put the preview on program at once; see [`Compositor::cut`]. pub fn cut(&mut self) -> anyhow::Result<()> { - Ok(self.compositor.cut(&self.collection, &self.sources)?) + self.compositor.cut(&self.collection, &self.sources)?; + self.takes += 1; + Ok(()) } /// Take the preview to program by `transition`, starting at `now`; see /// [`Compositor::transition`]. pub fn transition(&mut self, transition: moq_scene::Transition, now: Instant) -> anyhow::Result<()> { - Ok(self - .compositor - .transition(&self.collection, transition, &self.sources, now)?) + self.compositor + .transition(&self.collection, transition, &self.sources, now)?; + self.takes += 1; + Ok(()) } /// Restart the source called `name`, as an edit would, such as to reload a @@ -905,6 +911,7 @@ impl Engine { history, dropped, reloads: 0, + takes: 0, sources, compositor, cameras: BTreeMap::new(), diff --git a/rs/moq-studio/src/fx.rs b/rs/moq-studio/src/fx.rs new file mode 100644 index 0000000..120dfd7 --- /dev/null +++ b/rs/moq-studio/src/fx.rs @@ -0,0 +1,668 @@ +//! Effects in the studio's own window: glows, pulses, bursts, and fills from +//! rune-fx, drawn inside egui's render pass through paint callbacks. They +//! never reach the program, a published scene, or any encode. +//! +//! Panels mark where an effect goes as they draw ([`Mark`], [`Spot`]); the +//! effects are filled in once the frame knows what to show, and every layer +//! of a frame is uploaded once, by whichever of its callbacks egui prepares +//! first. + +use std::ops::Range; +use std::sync::{Arc, OnceLock}; + +use eframe::egui_wgpu; +use egui::{Color32, Rect, Ui}; +use moq_scene::wgpu; +use rune_fx::{ + ElectricParams, ElectricStyle, Fx as Effect, FxFrame, FxLayer, FxOptions, FxRenderer, GlowParams, GlowStyle, + GradientKind, GrainGradientParams, LiquidMetalParams, MeshGradientParams, PlasticParams, PulseParams, PulseStyle, + Rgb, Shape, SparkleParams, Stops, +}; + +use crate::health::Verdict; + +mod motion; + +/// How the window's effects move, an editor setting. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Effects { + /// Every effect, animated. + Full, + /// Every effect settles on one pose and stops moving. + Reduced, + /// No effects. + Off, +} + +impl Effects { + fn name(self) -> &'static str { + match self { + Self::Full => "Full", + Self::Reduced => "Reduced", + Self::Off => "Off", + } + } +} + +/// Where the picked setting is kept, beside the other editor settings. +const SETTING: &str = "effects"; + +/// The window's effects: the setting, the system's, and this frame's layers. +pub struct Fx { + /// The setting picked, or `None` to follow the system's reduced motion. + picked: Option, + system: motion::System, + /// What this frame shows, and when: seconds since the window opened, and + /// the layers' clock, which wraps. + effects: Effects, + time: f64, + clock: f32, + layers: Vec, + frame: Arc, + frames: u64, + look: Look, +} + +/// The layers of one frame, shared by its callbacks once the frame is done. +struct Frame { + id: u64, + layers: OnceLock>, +} + +impl Fx { + /// Draw on `render`'s device, with the setting `storage` keeps. + pub fn new( + render: &egui_wgpu::RenderState, + storage: Option<&dyn eframe::Storage>, + ctx: &egui::Context, + runtime: &tokio::runtime::Handle, + ) -> Self { + let mut fx = FxRenderer::new(&render.device, render.target_format, FxOptions { dither: true }); + // egui blends in gamma space on a non-sRGB target, as a Rune frame + // with sRGB blending does. + fx.set_srgb_blend(!render.target_format.is_srgb()); + render + .renderer + .write() + .callback_resources + .insert(Renderer { fx, prepared: None }); + Self { + picked: storage + .and_then(|storage| eframe::get_value(storage, SETTING)) + .flatten(), + system: motion::System::new(ctx, runtime), + effects: Effects::Full, + time: 0.0, + clock: 0.0, + layers: Vec::new(), + frame: Arc::new(Frame { + id: 0, + layers: OnceLock::new(), + }), + frames: 0, + look: Look::default(), + } + } + + pub fn save(&self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, SETTING, &self.picked); + } + + /// What the setting follows while none is picked. + fn system(&self) -> Effects { + match self.system.reduced() { + true => Effects::Reduced, + false => Effects::Full, + } + } + + /// Start a frame: what it shows, and when. + pub fn begin(&mut self, ctx: &egui::Context) { + self.effects = self.picked.unwrap_or_else(|| self.system()); + self.time = ctx.input(|input| input.time); + self.clock = (self.time % WRAP) as f32; + self.layers.clear(); + self.frames += 1; + self.frame = Arc::new(Frame { + id: self.frames, + layers: OnceLock::new(), + }); + } + + /// Hand the frame's layers to its callbacks. + pub fn finish(&mut self) { + let _ = self.frame.layers.set(std::mem::take(&mut self.layers)); + } + + /// Whether this frame draws effects, so widgets drawn over a fill can + /// leave their own background out. + pub fn on(&self) -> bool { + self.effects != Effects::Off + } + + /// View > Effects: the setting, or the system's. + pub fn menu(&mut self, ui: &mut Ui) { + ui.menu_button("Effects", |ui| { + let system = format!("Follow the system ({})", self.system().name()); + ui.radio_value(&mut self.picked, None, system) + .on_hover_text("Reduced while the system asks apps to reduce motion"); + for effects in [Effects::Full, Effects::Reduced, Effects::Off] { + ui.radio_value(&mut self.picked, Some(effects), effects.name()); + } + }); + } + + /// Draw `layers` at `at` in `painter`'s layer, clipped to it. + fn draw(&mut self, painter: &egui::Painter, at: egui::layers::ShapeIdx, layers: impl IntoIterator) { + if !self.on() { + return; + } + let reduced = self.effects == Effects::Reduced; + let start = self.layers.len(); + self.layers + .extend(layers.into_iter().map(|layer| layer.at(self.clock).reduced(reduced))); + if self.layers.len() == start { + return; + } + let callback = Draw { + frame: self.frame.clone(), + layers: start..self.layers.len(), + }; + painter.set( + at, + egui_wgpu::Callback::new_paint_callback(painter.clip_rect(), callback), + ); + } + + /// A first-launch screen's background. + pub fn backdrop(&mut self, spot: &Spot, visuals: &egui::Visuals) { + let base = visuals.panel_fill; + let params = MeshGradientParams { + colors: Stops::new(&[ + linear(base), + linear(mix(base, visuals.selection.bg_fill, 0.35)), + linear(mix(base, Color32::BLACK, 0.3)), + linear(mix(base, visuals.hyperlink_color, 0.2)), + ]), + // Still: the screen repaints only while setup probes the machine. + drift: 0.0, + ..MeshGradientParams::graphite() + }; + self.draw(&spot.painter, spot.under, [Effect::fill(params).on(spot.shape(0.0))]); + } + + /// A panel's background: a faint grain over its fill, which never moves. + pub fn panel(&mut self, spot: &Spot, visuals: &egui::Visuals) { + let base = visuals.panel_fill; + let lift = if visuals.dark_mode { + Color32::WHITE + } else { + Color32::BLACK + }; + let params = GrainGradientParams { + kind: GradientKind::Linear, + colors: Stops::new(&[linear(base), linear(mix(base, lift, 0.05))]), + angle: 1.2, + amount: 0.25, + size: 1.0, + mono: true, + fps: 0.0, + ..GrainGradientParams::ember() + }; + self.draw(&spot.painter, spot.under, [Effect::fill(params).on(spot.shape(0.0))]); + } + + /// A key's cap in the Hotkeys panel. + pub fn keycap(&mut self, spot: &Spot) { + let params = PlasticParams { + bevel: 3.0, + ..PlasticParams::dished() + }; + self.draw(&spot.painter, spot.under, [Effect::fill(params).on(spot.shape(4.0))]); + } + + /// The text color over a [`keycap`](Self::keycap) or a [`button`](Self::button). + pub const INK: Color32 = Color32::from_gray(24); + + /// A button of polished metal that ripples while the pointer is on it. + pub fn button(&mut self, spot: &Spot) { + let hovered = spot + .painter + .ctx() + .pointer_hover_pos() + .is_some_and(|pos| spot.rect.contains(pos)); + let ripple = &mut self.look.ripple; + ripple.set(self.time, hovered); + let params = LiquidMetalParams { + flow: 0.6 * ripple.at(self.time), + // Flat, so one reflection lies under the label rather than a + // horizon crossing it. + dome: 0.0, + bevel: 3.0, + ..LiquidMetalParams::chrome() + }; + self.draw(&spot.painter, spot.under, [Effect::fill(params).on(spot.shape(4.0))]); + } + + /// The Program monitor: a glow while on air, and a burst on each take. + /// Called every frame, shown or not, so the monitor shown again later + /// picks up where it would have been rather than bursting late. + pub fn program(&mut self, spot: Option<&Spot>, on_air: bool, takes: u64) { + let (time, clock, look) = (self.time, self.clock, &mut self.look); + look.on_air.set(time, on_air); + let sparkles = SparkleParams { + count: 12, + size: 9.0, + ..SparkleParams::default() + }; + look.take(time, takes, f64::from(sparkles.burst_life().max(TAKE))); + let Some(spot) = spot else { + return; + }; + // Kept off the controls above the monitor. + let mut clip = spot.painter.clip_rect(); + clip.min.y = clip.min.y.max(spot.rect.top() - 3.0); + let painter = spot.painter.with_clip_rect(clip); + let shape = spot.shape(2.0); + let glow = Effect::glow(GlowParams { + style: GlowStyle::Neon, + color: tally(), + core: rgb(0xffe2dc), + radius: 8.0, + occlusion: 0.0, + breathe: 0.25, + heat: look.on_air.at(time), + ..GlowParams::default() + }); + let center = spot.rect.center(); + let bursts: Vec = look + .bursts + .iter() + .flat_map(|&(start, seed)| { + // On the layers' clock, which only keeps a burst's age precise. + let start = clock - (time - start) as f32; + let ripple = Effect::pulse(PulseParams { + style: PulseStyle::Ripple { + at: [center.x, center.y], + }, + color: rgb(0x9cc8ff), + core: rgb(0xffffff), + reach: 0.0, + width: 2.4, + t0: start, + duration: TAKE, + intensity: 0.8, + ..PulseParams::default() + }); + let sparkles = Effect::sparkles(SparkleParams { + burst: Some(start), + ..sparkles + }); + [ripple.on(shape).over(), sparkles.on(shape).over()].map(|layer| layer.seed(seed)) + }) + .collect(); + self.draw(&painter, spot.under, [glow.on(shape)]); + self.draw(&painter, spot.over, bursts); + } + + /// The dot that says the studio records, which pulses. + pub fn recording(&mut self, spot: &Spot) { + let center = spot.rect.center(); + let pulse = Effect::pulse(PulseParams { + style: PulseStyle::Radar, + color: tally(), + core: rgb(0xffd0c8), + count: 2, + period: 1.6, + reach: 10.0, + width: 1.2, + occlusion: 0.0, + ..PulseParams::default() + }); + let shape = Shape::circle(center.x, center.y, spot.rect.width() / 2.0); + self.draw(&spot.painter, spot.under, [pulse.on(shape)]); + } + + /// The Health panel and its tab, by how bad the worst problem is: a + /// warning pulse while degraded, and electric flicker while unhealthy. + /// Called every frame, drawn or not, so an alert shown again later picks + /// up where it would have been. + pub fn health(&mut self, spots: &[&Spot], verdict: Verdict) { + let alerts = &mut self.look.alerts; + alerts.set(self.time, verdict); + for spot in spots { + let layers = self.look.alerts.layers(self.time, spot.shape(4.0)); + self.draw(&spot.painter, spot.over, layers); + } + } +} + +/// How long a take's ripple runs, in seconds. +const TAKE: f32 = 0.7; + +/// How many takes burst at once. +const BURSTS: usize = 4; + +/// When the layers' clock starts over, in seconds: often enough that an f32 +/// keeps it to a quarter millisecond, and rarely enough that the jump in a +/// breathing glow's phase goes unseen. +const WRAP: f64 = 3600.0; + +/// The red of a tally light on air. +fn tally() -> Rgb { + rgb(0xff2a1f) +} + +/// The effects' state across frames. +struct Look { + on_air: Light, + ripple: Light, + /// The take count last seen, and when each burst it fired started, with + /// its seed. + takes: Option, + bursts: Vec<(f64, u32)>, + alerts: Alerts, +} + +impl Look { + /// Burst for each take since the count last seen, and end the bursts + /// older than `life`. The first count seen is where counting starts. + fn take(&mut self, time: f64, takes: u64, life: f64) { + self.bursts.retain(|(start, _)| time - start < life); + if self.takes.replace(takes).is_some_and(|seen| seen != takes) { + // Overlapping takes each burst, the oldest giving way. + if self.bursts.len() == BURSTS { + self.bursts.remove(0); + } + self.bursts.push((time, takes as u32)); + } + } +} + +impl Default for Look { + fn default() -> Self { + Self { + on_air: Light::new(0.4, 1.2), + ripple: Light::new(0.25, 0.8), + takes: None, + bursts: Vec::new(), + alerts: Alerts::default(), + } + } +} + +/// The Health alerts, each fading in and out with its verdict. +struct Alerts { + degraded: Light, + unhealthy: Light, +} + +impl Default for Alerts { + fn default() -> Self { + Self { + degraded: Light::new(0.3, 0.8), + unhealthy: Light::new(0.15, 0.8), + } + } +} + +impl Alerts { + fn set(&mut self, t: f64, verdict: Verdict) { + self.degraded.set(t, verdict == Verdict::Degraded); + self.unhealthy.set(t, verdict == Verdict::Unhealthy); + } + + fn layers(&self, t: f64, shape: Shape) -> Vec { + let mut layers = Vec::new(); + let degraded = self.degraded.at(t); + if degraded > 0.0 { + let pulse = Effect::pulse(PulseParams { + style: PulseStyle::Breathe, + color: rgb(0xdcb43c), + core: rgb(0xfff1c4), + period: 1.8, + reach: 8.0, + occlusion: 0.0, + intensity: degraded, + ..PulseParams::default() + }); + layers.push(pulse.on(shape).over()); + } + let unhealthy = self.unhealthy.at(t); + if unhealthy > 0.0 { + let electric = Effect::electric(ElectricParams { + style: ElectricStyle::Border, + color: rgb(0xff4b3e), + core: rgb(0xfff0ea), + cool: rgb(0x9b1c5a), + amplitude: 2.5, + glow: 3.0, + strands: 1, + gap: 0.0, + forks: 0.25, + fork_len: 8.0, + occlusion: 0.0, + intensity: unhealthy, + ..ElectricParams::default() + }); + layers.push(electric.on(shape).over()); + } + layers + } +} + +/// A light that warms up over `attack` seconds when switched on and cools +/// over `cool` when off, rather than cutting, as rune-fx's `Heat` does on the +/// window's f64 clock. +struct Light { + attack: f32, + cool: f32, + /// When it last switched, how lit it was then, and whether it is on. + switched: f64, + from: f32, + on: bool, +} + +impl Light { + fn new(attack: f32, cool: f32) -> Self { + Self { + attack, + cool, + switched: 0.0, + from: 0.0, + on: false, + } + } + + fn set(&mut self, t: f64, on: bool) { + if on != self.on { + self.from = self.at(t); + self.switched = t; + self.on = on; + } + } + + /// How lit it is at `t`, from 0 to 1; 0 once it has cooled out of sight. + fn at(&self, t: f64) -> f32 { + let since = (t - self.switched).max(0.0) as f32; + if self.on { + let warmed = (since / self.attack).min(1.0); + self.from + (1.0 - self.from) * (1.0 - (1.0 - warmed).powi(3)) + } else { + let lit = self.from * (-since / self.cool).exp(); + if lit > 1.0 / 512.0 { lit } else { 0.0 } + } + } +} + +/// Where an effect will go under what a panel draws next, before it is +/// drawn. +pub struct Mark { + painter: egui::Painter, + under: egui::layers::ShapeIdx, +} + +impl Mark { + pub fn new(painter: &egui::Painter) -> Self { + Self { + painter: painter.clone(), + under: painter.add(egui::Shape::Noop), + } + } + + /// The spot around `rect`, once what goes over the effect is drawn. + pub fn around(self, rect: Rect) -> Spot { + let over = self.painter.add(egui::Shape::Noop); + Spot { + painter: self.painter, + under: self.under, + over, + rect, + } + } +} + +/// Where an effect goes: under and over what was drawn around `rect`. +#[derive(Clone)] +pub struct Spot { + painter: egui::Painter, + under: egui::layers::ShapeIdx, + over: egui::layers::ShapeIdx, + pub rect: Rect, +} + +impl Spot { + /// The same spot, around `rect` shrunk by `by` on each side. + pub fn inset(self, by: f32) -> Self { + Self { + rect: self.rect.shrink(by), + ..self + } + } + + fn shape(&self, radius: f32) -> Shape { + let rect = self.rect; + Shape::rounded(rect.min.x, rect.min.y, rect.width(), rect.height(), radius) + } +} + +/// What egui's callbacks share: the effects renderer, and the frame it last +/// uploaded. +struct Renderer { + fx: FxRenderer, + prepared: Option, +} + +/// Some of a frame's layers, drawn where egui painted the callback. +struct Draw { + frame: Arc, + layers: Range, +} + +impl egui_wgpu::CallbackTrait for Draw { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + screen: &egui_wgpu::ScreenDescriptor, + _encoder: &mut wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + let Some(renderer) = resources.get_mut::() else { + return Vec::new(); + }; + if renderer.prepared == Some(self.frame.id) { + return Vec::new(); + } + let Some(layers) = self.frame.layers.get() else { + return Vec::new(); + }; + renderer.fx.prepare( + device, + queue, + &FxFrame { + viewport: screen.size_in_pixels, + scale: screen.pixels_per_point, + layers, + backdrop: None, + sources: &[], + }, + ); + renderer.prepared = Some(self.frame.id); + Vec::new() + } + + fn paint( + &self, + info: egui::PaintCallbackInfo, + pass: &mut wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + let Some(renderer) = resources.get::() else { + return; + }; + if renderer.prepared != Some(self.frame.id) { + return; + } + // Effects are placed in window pixels; egui's scissor still clips + // them to the panel. + let [width, height] = info.screen_size_px; + pass.set_viewport(0.0, 0.0, width as f32, height as f32, 0.0, 1.0); + for layer in self.layers.clone() { + renderer.fx.draw_layer(pass, layer); + } + } +} + +/// An sRGB hex color, in the linear light effects take. +fn rgb(hex: u32) -> Rgb { + rune_fx::rgb(hex) +} + +/// An egui color, in the linear light effects take. +fn linear(color: Color32) -> Rgb { + let [r, g, b, _] = color.to_array(); + rgb(u32::from_be_bytes([0, r, g, b])) +} + +/// `from` moved `by` of the way toward `to`. +fn mix(from: Color32, to: Color32, by: f32) -> Color32 { + from.lerp_to_gamma(to, by) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A light switched off while still warming cools from where it got to, + /// rather than jumping, and is out once out of sight. + #[test] + fn a_light_cools_from_where_it_got() { + let mut light = Light::new(1.0, 1.0); + light.set(10.0, true); + let warm = light.at(10.5); + assert!(warm > 0.0 && warm < 1.0, "{warm}"); + light.set(10.5, false); + assert_eq!(light.at(10.5), warm); + assert!(light.at(11.0) < warm); + assert_eq!(light.at(20.0), 0.0); + } + + /// Opening the studio on a count already past zero bursts nothing; each + /// take after does, overlapping up to a limit, until it ends. + #[test] + fn each_take_bursts_once() { + let mut look = Look::default(); + look.take(0.0, 7, 1.0); + assert!(look.bursts.is_empty()); + look.take(0.1, 7, 1.0); + assert!(look.bursts.is_empty()); + for take in 8..8 + BURSTS as u64 + 1 { + look.take(0.2, take, 1.0); + } + assert_eq!(look.bursts.len(), BURSTS); + assert_eq!(look.bursts[0].1, 9, "the oldest gave way"); + look.take(1.3, 8 + BURSTS as u64, 1.0); + assert!(look.bursts.is_empty()); + } +} diff --git a/rs/moq-studio/src/fx/motion.rs b/rs/moq-studio/src/fx/motion.rs new file mode 100644 index 0000000..193f25e --- /dev/null +++ b/rs/moq-studio/src/fx/motion.rs @@ -0,0 +1,91 @@ +//! Whether the system asks apps to reduce motion, which the Effects setting +//! follows until one is picked. + +/// The system's reduced-motion preference, followed while the window is open. +pub struct System { + /// Set by the desktop portal's task. + #[cfg(target_os = "linux")] + reduced: std::sync::Arc, + #[cfg(target_os = "linux")] + task: tokio::task::AbortHandle, +} + +#[cfg(target_os = "linux")] +impl System { + /// Read the preference from the desktop portal, and follow its changes. + pub fn new(ctx: &egui::Context, runtime: &tokio::runtime::Handle) -> Self { + let reduced = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task = runtime.spawn(portal(reduced.clone(), ctx.clone())).abort_handle(); + Self { reduced, task } + } + + pub fn reduced(&self) -> bool { + self.reduced.load(std::sync::atomic::Ordering::Relaxed) + } +} + +#[cfg(target_os = "linux")] +impl Drop for System { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Keep `reduced` as the portal's Settings report it. A desktop without the +/// portal, or whose portal predates the setting, expresses no preference. +#[cfg(target_os = "linux")] +async fn portal(reduced: std::sync::Arc, ctx: egui::Context) { + use ashpd::desktop::settings::{ReducedMotion, Settings}; + use futures_util::StreamExt; + + let set = |motion: ReducedMotion| { + reduced.store( + motion == ReducedMotion::ReducedMotion, + std::sync::atomic::Ordering::Relaxed, + ); + ctx.request_repaint(); + }; + let followed = async { + let settings = Settings::new().await?; + let mut changes = settings.receive_reduced_motion_changed().await?; + set(settings.reduced_motion().await?); + while let Some(motion) = changes.next().await { + set(motion); + } + anyhow::Ok(()) + }; + if let Err(err) = followed.await { + tracing::debug!(error = format!("{err:#}"), "not following the system's reduced motion"); + } +} + +#[cfg(not(target_os = "linux"))] +impl System { + pub fn new(_ctx: &egui::Context, _runtime: &tokio::runtime::Handle) -> Self { + Self {} + } + + /// Read each frame: a system setting the OS keeps in memory. + #[cfg(target_os = "macos")] + pub fn reduced(&self) -> bool { + objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion() + } + + /// Read each frame: a system setting the OS keeps in memory. Windows + /// turns client-area animations off to reduce motion. + #[cfg(windows)] + pub fn reduced(&self) -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::{SPI_GETCLIENTAREAANIMATION, SystemParametersInfoW}; + + let mut animated: windows_sys::core::BOOL = 1; + // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL through the + // pointer, which points at one. + let read = unsafe { SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, (&raw mut animated).cast(), 0) }; + read != 0 && animated == 0 + } + + #[cfg(not(any(target_os = "macos", windows)))] + pub fn reduced(&self) -> bool { + false + } +} diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 9b3a97a..a1b8f0c 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -18,6 +18,7 @@ mod controls; mod devices; mod engine; mod feed; +mod fx; mod guests; mod health; mod hotkeys; @@ -234,6 +235,8 @@ struct Window { /// The engine once the studio is built, for the save on quit. started: Arc>>, keyboard: hotkeys::Keyboard, + /// The window's own effects, on the setup screen and in the studio. + fx: fx::Fx, } impl eframe::App for Window { @@ -244,9 +247,25 @@ impl eframe::App for Window { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.fx.begin(ui.ctx()); + self.show(ui, frame); + self.fx.finish(); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.fx.save(storage); + if let App::Studio(studio) = &mut self.app { + studio.save(storage); + } + } +} + +impl Window { + /// Draw setup or the studio, building the studio once its engine starts. + fn show(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let engine = match &mut self.app { - App::Studio(studio) => return studio.ui(ui, frame), - App::Setup(setup) => match setup.ui(ui) { + App::Studio(studio) => return studio.ui(ui, frame, &mut self.fx), + App::Setup(setup) => match setup.ui(ui, &mut self.fx) { Some(engine) => engine, None => return, }, @@ -262,12 +281,6 @@ impl eframe::App for Window { self.app = App::Studio(Box::new(studio)); ui.ctx().request_repaint(); } - - fn save(&mut self, storage: &mut dyn eframe::Storage) { - if let App::Studio(studio) = &mut self.app { - studio.save(storage); - } - } } /// eframe's handler for the window's events, which hears keys before egui, @@ -377,12 +390,14 @@ fn run( .wgpu_render_state .clone() .context("the window is not drawn with wgpu")?; + let fx = fx::Fx::new(&render, cc.storage, &cc.egui_ctx, &runtime); Ok(Box::new(Window { app, render, runtime, started, keyboard: window_keyboard, + fx, })) }), &event_loop, diff --git a/rs/moq-studio/src/setup.rs b/rs/moq-studio/src/setup.rs index 1c9c23d..ccc873c 100644 --- a/rs/moq-studio/src/setup.rs +++ b/rs/moq-studio/src/setup.rs @@ -18,6 +18,7 @@ use moq_scene::{Canvas, Collection, Draft, Edit, History, Item, Rect, Scene, wgp use crate::autosave::{self, Autosave}; use crate::devices::{self, Listing, Pick}; use crate::engine::{self, Engine, Launch}; +use crate::fx::{Fx, Mark}; use crate::health::rate; use crate::uplink; @@ -544,7 +545,7 @@ impl Setup { } /// Draw the screen; the engine once the creator has started the studio. - pub fn ui(&mut self, ui: &mut Ui) -> Option> { + pub fn ui(&mut self, ui: &mut Ui, fx: &mut Fx) -> Option> { self.advance(); if self.choices.is_none() || self.starting.is_some() { ui.ctx().request_repaint_after(Duration::from_millis(100)); @@ -563,6 +564,7 @@ impl Setup { let mut start = None; egui::CentralPanel::default().show(ui, |ui| { + let backdrop = fx.on().then(|| Mark::new(ui.painter())); egui::ScrollArea::vertical().show(ui, |ui| { ui.heading("Set up MoQ Studio"); ui.label( @@ -574,6 +576,9 @@ impl Setup { ui.separator(); start = self.proposal(ui); }); + if let Some(backdrop) = backdrop { + fx.backdrop(&backdrop.around(ui.clip_rect()), ui.visuals()); + } }); if let Some(empty) = start { self.start(empty); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 6270cf0..06f8792 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -20,6 +20,7 @@ use crate::autosave::Conflict; use crate::controls::{Action, Controls}; use crate::devices::{self, Listing, Pick}; use crate::engine::{Engine, State}; +use crate::fx::{Fx, Mark, Spot}; use crate::health::{self, Verdict}; use crate::hotkeys::{self, Bindings, Hotkeys, Reach}; use crate::interact::Interact; @@ -149,6 +150,8 @@ impl Studio { shown: None, message: (dropped > 0).then(|| Err(dropped_message(dropped))), reloads: 0, + effects: false, + spots: Spots::default(), }, } } @@ -176,8 +179,8 @@ impl Studio { } } -impl eframe::App for Studio { - fn save(&mut self, storage: &mut dyn eframe::Storage) { +impl Studio { + pub fn save(&mut self, storage: &mut dyn eframe::Storage) { let panels = &self.panels; eframe::set_value(storage, SPEAKER, &panels.speaker); eframe::set_value(storage, SNAPPING, &panels.snapping); @@ -187,7 +190,7 @@ impl eframe::App for Studio { } /// Run before each frame, and while the window is hidden, when `ui` is not. - fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + pub fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // A wait for a key ends once the operator leaves the window, such as // for a game, so the hotkeys are not held off while they play. let left = ctx.input(|input| { @@ -204,9 +207,11 @@ impl eframe::App for Studio { } } - fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { + /// Draw the window, with `fx` drawing its effects. + pub fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame, fx: &mut Fx) { let engine = self.panels.engine.clone(); let mut state = engine.state.lock().unwrap(); + let takes = state.takes; // The sizes the textures were drawn at, which an edit this frame, such // as an undo resizing the scene in preview, does not change until the // next. @@ -268,15 +273,17 @@ impl eframe::App for Studio { } self.panels.take_binding(ui.ctx()); self.panels.shortcuts(ui.ctx()); - egui::Panel::top("menu").show(ui, |ui| self.panels.menu(ui)); + egui::Panel::top("menu").show(ui, |ui| self.panels.menu(ui, fx)); if let Some(conflict) = self.panels.engine.autosave.conflict() { egui::Panel::top("conflict").show(ui, |ui| self.panels.conflict(ui, conflict)); } egui::Panel::bottom("status").show(ui, |ui| self.panels.status(ui)); self.panels.previewed = false; + self.panels.effects = fx.on(); DockArea::new(&mut self.dock) .style(Style::from_egui(ui.style())) .show_inside(ui, &mut self.panels); + self.panels.draw_effects(ui, fx, takes); // A hidden preview hears no input, so it lets go of the pages, as // turning Interact off does. if !self.panels.previewed { @@ -395,6 +402,24 @@ struct Panels { message: Option>, /// The engine's reloads seen so far. reloads: u64, + /// Whether this frame draws effects, which widgets on a fill leave their + /// own background out for. + effects: bool, + /// Where this frame's panels drew what effects go around. + spots: Spots, +} + +/// Where the panels drew what effects go around, this frame. +#[derive(Default)] +struct Spots { + /// Each panel's body, drawn on. + panels: Vec, + program: Option, + recording: Option, + cut: Option, + keycaps: Vec, + health: Option, + health_tab: Option, } impl TabViewer for Panels { @@ -413,6 +438,7 @@ impl TabViewer for Panels { } fn ui(&mut self, ui: &mut Ui, tab: &mut Tab) { + let body = self.effects.then(|| Mark::new(ui.painter())); match tab { Tab::Preview => self.preview(ui), Tab::Program => self.program(ui), @@ -430,6 +456,20 @@ impl TabViewer for Panels { Tab::Replay => self.replay(ui), Tab::Hotkeys => self.hotkeys(ui), } + if let Some(body) = body { + let spot = body.around(ui.clip_rect()); + if *tab == Tab::Health { + self.spots.health = Some(spot.clone().inset(4.0)); + } + self.spots.panels.push(spot); + } + } + + fn on_tab_button(&mut self, tab: &mut Tab, response: &egui::Response) { + if self.effects && *tab == Tab::Health { + let painter = egui::Painter::new(response.ctx.clone(), response.layer_id, response.rect.expand(12.0)); + self.spots.health_tab = Some(Mark::new(&painter).around(response.rect.shrink(2.0))); + } } } @@ -612,7 +652,7 @@ impl Panels { } } - fn menu(&mut self, ui: &mut Ui) { + fn menu(&mut self, ui: &mut Ui, fx: &mut Fx) { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Reload").clicked() { @@ -636,9 +676,44 @@ impl Panels { ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste); } }); + ui.menu_button("View", |ui| fx.menu(ui)); }); } + /// Draw the effects around what the panels drew this frame: the on-air + /// glow and take bursts on the Program monitor, the recording pulse, the + /// Cut button and keycaps, the panels' fills, and the Health alerts. + fn draw_effects(&mut self, ui: &Ui, fx: &mut Fx, takes: u64) { + let spots = std::mem::take(&mut self.spots); + for spot in &spots.panels { + fx.panel(spot, ui.visuals()); + } + for spot in &spots.keycaps { + fx.keycap(spot); + } + if let Some(spot) = &spots.cut { + fx.button(spot); + } + if let Some(spot) = &spots.recording { + fx.recording(spot); + } + let (on_air, verdict) = { + let report = self.engine.health.lock().unwrap(); + let on_air = report + .relays + .iter() + .any(|relay| relay.state == health::State::Connected); + let worst = report + .problems + .first() + .map_or(Verdict::Healthy, |problem| problem.verdict); + (on_air, worst) + }; + fx.program(spots.program.as_ref(), on_air, takes); + let health: Vec<&Spot> = [&spots.health, &spots.health_tab].into_iter().flatten().collect(); + fx.health(&health, verdict); + } + /// Report a reload, from File > Reload or an outside edit the autosave /// took, and let go of a selected item, a drag, and a scene rename under /// way, whose index it may have given to another. Called under every lock @@ -1157,8 +1232,19 @@ impl Panels { .collect(); let mut gesture = None; let mut fire = None; + let recording = self.engine.recorder.status().is_some_and(|status| !status.done); ui.horizontal(|ui| { - if ui.button("Cut").clicked() { + let cut = match self.effects { + true => { + let metal = Mark::new(ui.painter()); + let text = egui::RichText::new("Cut").color(Fx::INK); + let cut = ui.add(egui::Button::new(text).fill(Color32::TRANSPARENT)); + self.spots.cut = Some(metal.around(cut.rect)); + cut + } + false => ui.button("Cut"), + }; + if cut.clicked() { fire = Some(Action::Cut); } if ui @@ -1252,6 +1338,13 @@ impl Panels { if state.compositor.transitioning() { ui.weak("in transition"); } + if recording { + let (dot, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + let pulse = self.effects.then(|| Mark::new(ui.painter())); + ui.painter().circle_filled(dot.center(), 4.0, RECORDING); + self.spots.recording = pulse.map(|pulse| pulse.around(dot)); + ui.colored_label(RECORDING, "recording"); + } }); let result = if next == transition { Ok(()) @@ -1266,7 +1359,9 @@ impl Panels { let result = self.controls.fire(&action).map(drop); self.check(result); } - picture(ui, self.program.id, canvas, egui::Sense::hover()); + let monitor = self.effects.then(|| Mark::new(ui.painter())); + let program = picture(ui, self.program.id, canvas, egui::Sense::hover()); + self.spots.program = monitor.map(|monitor| monitor.around(program.rect)); } fn scenes(&mut self, ui: &mut Ui) { @@ -3086,7 +3181,10 @@ impl Panels { ui.strong("press a key, or Escape"); } else { match bound { - Some(bound) => ui.monospace(bound.shown(ui.ctx())), + Some(bound) => { + let keycaps = self.effects.then_some(&mut self.spots.keycaps); + keycap(ui, bound.shown(ui.ctx()), keycaps) + } None => ui.weak("unbound"), }; } @@ -3447,6 +3545,24 @@ fn repairs(ui: &mut Ui, repairs: &[record::Repair]) { ui.separator(); } +/// A bound key, on a cap whose spot joins `keycaps` while effects draw. +fn keycap(ui: &mut Ui, key: String, keycaps: Option<&mut Vec>) -> egui::Response { + let Some(keycaps) = keycaps else { + return ui.monospace(key); + }; + let cap = Mark::new(ui.painter()); + let text = egui::RichText::new(key).monospace().color(Fx::INK); + let response = egui::Frame::new() + .inner_margin(egui::Margin::symmetric(6, 1)) + .show(ui, |ui| ui.label(text)) + .response; + keycaps.push(cap.around(response.rect)); + response +} + +/// The red of the recording dot. +const RECORDING: Color32 = Color32::from_rgb(235, 50, 40); + const HEALTHY: Color32 = Color32::from_rgb(80, 200, 120); const DEGRADED: Color32 = Color32::from_rgb(220, 180, 60);