//! Script-backed fullscreen visualizers. //! //! Rust owns the audio tap, script loading, sandboxed execution limits and //! terminal drawing primitives. Visual math lives in `.rhai` files: every //! script receives the same input map and returns the same list of draw //! commands. use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Instant, SystemTime}; use anyhow::{Context as _, Result}; use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::widgets::{Clear, Paragraph}; use rhai::{AST, Array, Dynamic, Engine, FLOAT, INT, Map, Scope}; use serde::{Deserialize, Serialize}; use crate::app::state::AppState; const PRIMARY_SCRIPT_ID: &str = "scope_spectrum"; const BUNDLE_VERSION: u32 = 5; struct BundledScript { id: &'static str, file_name: &'static str, display_name: &'static str, source: &'static str, } const BUNDLED_SCRIPTS: &[BundledScript] = &[ BundledScript { id: PRIMARY_SCRIPT_ID, file_name: "scope_spectrum.rhai", display_name: "Scope spectrum", source: include_str!("visualizations/scope_spectrum.rhai"), }, BundledScript { id: "pulsing_sphere", file_name: "pulsing_sphere.rhai", display_name: "Pulsing sphere", source: include_str!("visualizations/pulsing_sphere.rhai"), }, ]; #[derive(Debug, Clone)] pub struct VisualizerScript { pub id: String, pub name: String, pub path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VisualizerConfig { #[serde(default = "default_script_id")] pub selected: String, #[serde(default)] pub show_clock: bool, } impl Default for VisualizerConfig { fn default() -> Self { Self { selected: default_script_id(), show_clock: false, } } } pub struct VisualizerState { pub active: bool, pub started_at: Option, pub config: VisualizerConfig, pub scripts: Vec, pub last_error: Option, runtime: Mutex, } impl std::fmt::Debug for VisualizerState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VisualizerState") .field("active", &self.active) .field("started_at", &self.started_at) .field("config", &self.config) .field("scripts", &self.scripts) .field("last_error", &self.last_error) .finish_non_exhaustive() } } impl Default for VisualizerState { fn default() -> Self { Self { active: false, started_at: None, config: VisualizerConfig::default(), scripts: Vec::new(), last_error: None, runtime: Mutex::new(ScriptRuntime::default()), } } } impl VisualizerState { pub fn open(&mut self) { self.active = true; self.started_at = Some(Instant::now()); } pub fn close(&mut self) { self.active = false; self.started_at = None; } pub fn load_library(&mut self) -> Result<()> { ensure_bundled_scripts()?; self.config = load_config().unwrap_or_else(|err| { tracing::warn!(%err, "visualization settings failed to load; using defaults"); VisualizerConfig::default() }); self.refresh_scripts()?; self.ensure_selected_script(); self.save_config()?; Ok(()) } pub fn refresh_scripts(&mut self) -> Result<()> { let dir = visualizations_dir()?; let mut scripts = Vec::new(); for entry in std::fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("rhai") { continue; } let Some(id) = path.file_stem().and_then(|value| value.to_str()) else { continue; }; scripts.push(VisualizerScript { id: id.to_string(), name: script_name(&path, id), path: path.clone(), }); } scripts.sort_by(|left, right| { bundled_order(&left.id) .unwrap_or(usize::MAX) .cmp(&bundled_order(&right.id).unwrap_or(usize::MAX)) .then_with(|| left.name.cmp(&right.name)) }); self.scripts = scripts; self.ensure_selected_script(); Ok(()) } pub fn select_script(&mut self, index: usize) -> Result<()> { let Some(script) = self.scripts.get(index) else { anyhow::bail!("visualization script not found"); }; self.config.selected = script.id.clone(); self.save_config() } pub fn toggle_clock(&mut self) -> Result<()> { self.config.show_clock = !self.config.show_clock; self.save_config() } pub fn selected_script(&self) -> Option<&VisualizerScript> { self.scripts .iter() .find(|script| script.id == self.config.selected) .or_else(|| self.scripts.first()) } pub fn selected_script_index(&self) -> Option { self.scripts .iter() .position(|script| script.id == self.config.selected) } pub fn selected_script_path(&self) -> Option { self.selected_script().map(|script| script.path.clone()) } pub fn create_script(&mut self) -> Result { ensure_bundled_scripts()?; let dir = visualizations_dir()?; let mut number = 1; let path = loop { let candidate = dir.join(format!("custom_{number}.rhai")); if !candidate.exists() { break candidate; } number += 1; }; let starter = primary_bundled_script(); let content = starter.source.replacen( "// name: Scope spectrum", &format!("// name: Custom {number}"), 1, ); std::fs::write(&path, content).with_context(|| format!("creating {}", path.display()))?; self.refresh_scripts()?; let id = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or(PRIMARY_SCRIPT_ID) .to_string(); self.config.selected = id; self.save_config()?; Ok(path) } pub fn save_config(&self) -> Result<()> { let path = visualizer_config_path()?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, toml::to_string_pretty(&self.config)?) .with_context(|| format!("writing {}", path.display()))?; Ok(()) } fn ensure_selected_script(&mut self) { if self .scripts .iter() .any(|script| script.id == self.config.selected) { return; } self.config.selected = self .scripts .first() .map(|script| script.id.clone()) .unwrap_or_else(default_script_id); } } #[derive(Debug, Clone, Default)] pub struct AudioFeatures { pub elapsed_secs: f64, pub position_secs: f64, pub progress: f64, pub volume: f64, pub paused: bool, pub energy: f64, pub bass: f64, pub mid: f64, pub treble: f64, pub beat: f64, pub seed: f64, pub scope: Vec, } pub fn draw(frame: &mut Frame, state: &AppState) { let area = frame.area(); let Some(script) = state.visualizer.selected_script() else { frame.render_widget(Clear, area); draw_message(frame, area, "no visualization scripts found"); return; }; let input = input_map(area, state); let commands = { let mut runtime = state .visualizer .runtime .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); runtime.render(&script.path, input) }; match commands { Ok(commands) => render_commands(frame, area, &commands), Err(err) => { frame.render_widget(Clear, area); draw_message(frame, area, &format!("visualizer error: {err}")); } } } fn input_map(area: Rect, state: &AppState) -> Map { let features = features_from_state(state); let mut input = Map::new(); insert_int(&mut input, "width", i64::from(area.width)); insert_int(&mut input, "height", i64::from(area.height)); insert_float(&mut input, "time", features.elapsed_secs); insert_float(&mut input, "position", features.position_secs); insert_float(&mut input, "progress", features.progress); insert_float(&mut input, "volume", features.volume); input.insert("paused".into(), features.paused.into()); insert_float(&mut input, "energy", features.energy); insert_float(&mut input, "bass", features.bass); insert_float(&mut input, "mid", features.mid); insert_float(&mut input, "treble", features.treble); insert_float(&mut input, "beat", features.beat); insert_float(&mut input, "seed", features.seed); insert_int( &mut input, "analysis_sequence", state.player.audio_analysis.sequence as i64, ); input.insert( "samples".into(), features .scope .into_iter() .map(|sample| Dynamic::from_float(sample as FLOAT)) .collect::() .into(), ); input.insert( "show_clock".into(), state.visualizer.config.show_clock.into(), ); input.insert("clock".into(), clock_label().into()); let (title, artist) = state .player .current .as_ref() .map(|track| (track.title.clone(), track.artist_line())) .unwrap_or_else(|| ("".to_string(), "".to_string())); input.insert("track_title".into(), title.into()); input.insert("track_artist".into(), artist.into()); input } fn features_from_state(state: &AppState) -> AudioFeatures { let elapsed_secs = state .visualizer .started_at .map(|started| started.elapsed().as_secs_f64()) .unwrap_or_default(); let position_secs = state.player.position_secs; let duration_secs = state .player .current .as_ref() .map(|track| track.duration_seconds.max(0.0)) .unwrap_or_default(); let progress = if duration_secs > 0.0 { (position_secs / duration_secs).clamp(0.0, 1.0) } else { 0.0 }; let seed = state .player .current .as_ref() .map(track_seed) .unwrap_or(0.17); let analysis = &state.player.audio_analysis; let volume = f64::from(state.player.volume.min(100)) / 100.0; let output_scale = if state.player.volume == 0 { 0.0 } else { 0.35 + volume * 0.65 }; let active_scale = if state.player.paused { 0.12 } else { 1.0 }; let scale = output_scale * active_scale; AudioFeatures { elapsed_secs, position_secs, progress, volume, paused: state.player.paused, energy: (analysis.energy * scale).clamp(0.0, 1.0), bass: (analysis.bass * scale).clamp(0.0, 1.0), mid: (analysis.mid * scale).clamp(0.0, 1.0), treble: (analysis.treble * scale).clamp(0.0, 1.0), beat: if state.player.paused { 0.0 } else { (analysis.beat * output_scale).clamp(0.0, 1.0) }, seed, scope: analysis .scope .iter() .map(|sample| (sample * scale).clamp(-1.0, 1.0)) .collect(), } } fn track_seed(track: &crate::library::models::TrackItem) -> f64 { let mut hash = 0xcbf29ce484222325u64; let artist_line = track.artist_line(); for byte in track .title .bytes() .chain(track.release_title.bytes()) .chain(artist_line.bytes()) { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x100000001b3); } (hash % 10_000) as f64 / 10_000.0 } #[derive(Debug)] struct ScriptRuntime { engine: Engine, cached_path: Option, cached_modified: Option, ast: Option, } impl Default for ScriptRuntime { fn default() -> Self { let mut engine = Engine::new(); engine .set_max_operations(2_000_000) .set_max_call_levels(32) .set_max_variables(512) .set_max_functions(128) .set_max_modules(0) .set_max_expr_depths(64, 64) .set_max_string_size(1_000_000) .set_max_array_size(120_000) .set_max_map_size(1_000_000); engine.disable_symbol("import"); engine.disable_symbol("export"); engine.register_fn("sin", |value: FLOAT| value.sin()); engine.register_fn("cos", |value: FLOAT| value.cos()); engine.register_fn("tan", |value: FLOAT| value.tan()); engine.register_fn("sqrt", |value: FLOAT| value.sqrt()); engine.register_fn("abs", |value: FLOAT| value.abs()); engine.register_fn("pow", |value: FLOAT, power: FLOAT| value.powf(power)); Self { engine, cached_path: None, cached_modified: None, ast: None, } } } impl ScriptRuntime { fn render(&mut self, path: &Path, input: Map) -> std::result::Result, String> { self.load(path)?; let Some(ast) = &self.ast else { return Err("script did not compile".to_string()); }; let mut scope = Scope::new(); let output = self .engine .call_fn::(&mut scope, ast, "render", (input,)) .map_err(|err| err.to_string())?; let mut commands = Vec::with_capacity(output.len()); for (index, value) in output.into_iter().enumerate() { commands.push( DrawCommand::from_dynamic(value) .map_err(|err| format!("invalid draw command #{index}: {err}"))?, ); } Ok(commands) } fn load(&mut self, path: &Path) -> std::result::Result<(), String> { let modified = std::fs::metadata(path) .and_then(|metadata| metadata.modified()) .ok(); let cache_hit = self.cached_path.as_deref() == Some(path) && self.cached_modified == modified && self.ast.is_some(); if cache_hit { return Ok(()); } let source = std::fs::read_to_string(path).map_err(|err| format!("{err}"))?; let ast = self .engine .compile(&source) .map_err(|err| err.to_string())?; self.cached_path = Some(path.to_path_buf()); self.cached_modified = modified; self.ast = Some(ast); Ok(()) } } #[derive(Debug, Clone)] enum DrawCommand { Clear { bg: Color, }, Cell { x: u16, y: u16, ch: String, fg: Color, bg: Color, }, HLine { x: u16, y: u16, w: u16, ch: String, fg: Color, bg: Color, }, VLine { x: u16, y: u16, h: u16, ch: String, fg: Color, bg: Color, }, Rect { x: u16, y: u16, w: u16, h: u16, ch: String, fg: Color, bg: Color, }, Trace { x: u16, ys: Vec, ch: String, line_ch: String, fg: Color, line_fg: Color, bg: Color, }, Text { x: u16, y: u16, text: String, fg: Color, bg: Color, }, } impl DrawCommand { fn from_dynamic(value: Dynamic) -> std::result::Result { let Some(map) = value.try_cast::() else { return Err("expected object map".to_string()); }; let op = map_string(&map, "op") .or_else(|| map_string(&map, "type")) .ok_or_else(|| "missing op".to_string())?; let fg = map_color(&map, "fg").unwrap_or(Color::White); let bg = map_color(&map, "bg").unwrap_or(Color::Black); match op.as_str() { "clear" => Ok(DrawCommand::Clear { bg }), "cell" => Ok(DrawCommand::Cell { x: map_u16(&map, "x").ok_or_else(|| "cell.x must be an integer".to_string())?, y: map_u16(&map, "y").ok_or_else(|| "cell.y must be an integer".to_string())?, ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()), fg, bg, }), "hline" => Ok(DrawCommand::HLine { x: map_u16(&map, "x").ok_or_else(|| "hline.x must be an integer".to_string())?, y: map_u16(&map, "y").ok_or_else(|| "hline.y must be an integer".to_string())?, w: map_u16(&map, "w").ok_or_else(|| "hline.w must be an integer".to_string())?, ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()), fg, bg, }), "vline" => Ok(DrawCommand::VLine { x: map_u16(&map, "x").ok_or_else(|| "vline.x must be an integer".to_string())?, y: map_u16(&map, "y").ok_or_else(|| "vline.y must be an integer".to_string())?, h: map_u16(&map, "h").ok_or_else(|| "vline.h must be an integer".to_string())?, ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()), fg, bg, }), "rect" => Ok(DrawCommand::Rect { x: map_u16(&map, "x").ok_or_else(|| "rect.x must be an integer".to_string())?, y: map_u16(&map, "y").ok_or_else(|| "rect.y must be an integer".to_string())?, w: map_u16(&map, "w").ok_or_else(|| "rect.w must be an integer".to_string())?, h: map_u16(&map, "h").ok_or_else(|| "rect.h must be an integer".to_string())?, ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()), fg, bg, }), "trace" => Ok(DrawCommand::Trace { x: map_u16(&map, "x").unwrap_or(0), ys: map_u16_array(&map, "ys") .ok_or_else(|| "trace.ys must be an integer array".to_string())?, ch: map_string(&map, "ch").unwrap_or_else(|| "*".to_string()), line_ch: map_string(&map, "line_ch").unwrap_or_else(|| "|".to_string()), fg, line_fg: map_color(&map, "line_fg").unwrap_or(fg), bg, }), "text" => Ok(DrawCommand::Text { x: map_u16(&map, "x").ok_or_else(|| "text.x must be an integer".to_string())?, y: map_u16(&map, "y").ok_or_else(|| "text.y must be an integer".to_string())?, text: map_string(&map, "text").unwrap_or_default(), fg, bg, }), _ => Err(format!("unknown op {op:?}")), } } } fn render_commands(frame: &mut Frame, area: Rect, commands: &[DrawCommand]) { for command in commands { match command { DrawCommand::Clear { bg } => fill_rect(frame, area, " ", Style::new().bg(*bg)), DrawCommand::Cell { x, y, ch, fg, bg } => { write_cell(frame, area, *x, *y, ch, Style::new().fg(*fg).bg(*bg)); } DrawCommand::HLine { x, y, w, ch, fg, bg, } => { let style = Style::new().fg(*fg).bg(*bg); for offset in 0..*w { write_cell(frame, area, x.saturating_add(offset), *y, ch, style); } } DrawCommand::VLine { x, y, h, ch, fg, bg, } => { let style = Style::new().fg(*fg).bg(*bg); for offset in 0..*h { write_cell(frame, area, *x, y.saturating_add(offset), ch, style); } } DrawCommand::Rect { x, y, w, h, ch, fg, bg, } => { let style = Style::new().fg(*fg).bg(*bg); for row in 0..*h { for col in 0..*w { write_cell( frame, area, x.saturating_add(col), y.saturating_add(row), ch, style, ); } } } DrawCommand::Trace { x, ys, ch, line_ch, fg, line_fg, bg, } => { let line_style = Style::new().fg(*line_fg).bg(*bg); let point_style = Style::new().fg(*fg).bg(*bg); let mut previous_y: Option = None; for (index, y) in ys.iter().copied().enumerate() { let Ok(offset) = u16::try_from(index) else { break; }; let x = x.saturating_add(offset); if let Some(previous) = previous_y { let from = previous.min(y); let to = previous.max(y); for y in from..=to { write_cell(frame, area, x, y, line_ch, line_style); } } write_cell(frame, area, x, y, ch, point_style); previous_y = Some(y); } } DrawCommand::Text { x, y, text, fg, bg } => { for (offset, ch) in text.chars().enumerate() { let Ok(offset) = u16::try_from(offset) else { break; }; write_cell( frame, area, x.saturating_add(offset), *y, &ch.to_string(), Style::new().fg(*fg).bg(*bg), ); } } } } } fn fill_rect(frame: &mut Frame, area: Rect, symbol: &'static str, style: Style) { for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) { cell.set_symbol(symbol).set_style(style); } } } } fn write_cell(frame: &mut Frame, area: Rect, x: u16, y: u16, symbol: &str, style: Style) { if x >= area.width || y >= area.height { return; } if let Some(cell) = frame .buffer_mut() .cell_mut((area.x.saturating_add(x), area.y.saturating_add(y))) { cell.set_symbol(symbol).set_style(style); } } fn draw_message(frame: &mut Frame, area: Rect, message: &str) { frame.render_widget( Paragraph::new(message.to_string()).style(Style::new().fg(Color::Red).bg(Color::Black)), area, ); } fn map_string(map: &Map, key: &str) -> Option { map.get(key)?.clone().try_cast::() } fn map_u16(map: &Map, key: &str) -> Option { let value = map.get(key)?.clone().try_cast::()?; u16::try_from(value).ok() } fn map_u16_array(map: &Map, key: &str) -> Option> { map.get(key)? .clone() .try_cast::()? .into_iter() .map(|value| u16::try_from(value.try_cast::()?).ok()) .collect() } fn map_color(map: &Map, key: &str) -> Option { let value = map.get(key)?; if let Some(rgb) = value.clone().try_cast::() { return Some(rgb_color(rgb)); } let text = value.clone().try_cast::()?; parse_color(&text) } fn rgb_color(value: INT) -> Color { let value = value.clamp(0, 0x00ff_ffff) as u32; Color::Rgb( ((value >> 16) & 0xff) as u8, ((value >> 8) & 0xff) as u8, (value & 0xff) as u8, ) } fn parse_color(text: &str) -> Option { let hex = text.trim().trim_start_matches('#').trim_start_matches("0x"); let value = u32::from_str_radix(hex, 16).ok()?; Some(Color::Rgb( ((value >> 16) & 0xff) as u8, ((value >> 8) & 0xff) as u8, (value & 0xff) as u8, )) } fn insert_int(map: &mut Map, key: &str, value: i64) { map.insert(key.into(), Dynamic::from_int(value as INT)); } fn insert_float(map: &mut Map, key: &str, value: f64) { map.insert(key.into(), Dynamic::from_float(value as FLOAT)); } fn clock_label() -> String { let secs = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); format!( "{:02}:{:02}:{:02}", secs / 3600 % 24, secs / 60 % 60, secs % 60 ) } fn default_script_id() -> String { PRIMARY_SCRIPT_ID.to_string() } fn config_dir() -> Result { Ok(crate::config::project_dirs() .map(|dirs| dirs.config_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from("."))) } pub fn visualizations_dir() -> Result { Ok(config_dir()?.join("visualizations")) } fn visualizer_config_path() -> Result { Ok(config_dir()?.join("visualizations.toml")) } fn primary_bundled_script() -> &'static BundledScript { &BUNDLED_SCRIPTS[0] } fn bundled_order(id: &str) -> Option { BUNDLED_SCRIPTS.iter().position(|script| script.id == id) } fn ensure_bundled_scripts() -> Result<()> { let dir = visualizations_dir()?; std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; for script in BUNDLED_SCRIPTS { let path = dir.join(script.file_name); let write_default = match std::fs::read_to_string(&path) { Ok(existing) => should_replace_bundled_script(&existing, script), Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), }; if write_default { std::fs::write(&path, script.source) .with_context(|| format!("writing {}", path.display()))?; } } Ok(()) } fn should_replace_bundled_script(existing: &str, script: &BundledScript) -> bool { let name_marker = format!("// name: {}", script.display_name); let version_marker = format!("// bundle-version: {BUNDLE_VERSION}"); existing .lines() .take(3) .any(|line| line.trim() == name_marker) && !existing .lines() .take(8) .any(|line| line.trim() == version_marker) } fn load_config() -> Result { let path = visualizer_config_path()?; match std::fs::read_to_string(&path) { Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(VisualizerConfig::default()), Err(err) => Err(err).with_context(|| format!("reading {}", path.display())), } } fn script_name(path: &Path, fallback_id: &str) -> String { if let Ok(text) = std::fs::read_to_string(path) { for line in text.lines().take(12) { let Some(name) = line.trim().strip_prefix("// name:") else { continue; }; let name = name.trim(); if !name.is_empty() { return name.to_string(); } } } fallback_id .split(['_', '-']) .filter(|part| !part.is_empty()) .map(|part| { let mut chars = part.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), None => String::new(), } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; fn test_input() -> Map { let mut input = Map::new(); insert_int(&mut input, "width", 80); insert_int(&mut input, "height", 24); insert_float(&mut input, "time", 1.0); insert_float(&mut input, "position", 3.0); insert_float(&mut input, "progress", 0.25); insert_float(&mut input, "volume", 0.8); input.insert("paused".into(), false.into()); insert_float(&mut input, "energy", 0.7); insert_float(&mut input, "bass", 0.8); insert_float(&mut input, "mid", 0.5); insert_float(&mut input, "treble", 0.3); insert_float(&mut input, "beat", 0.4); insert_float(&mut input, "seed", 0.17); insert_int(&mut input, "analysis_sequence", 1); input.insert( "samples".into(), (0..128) .map(|index| { let sample = ((index as f64 / 128.0) * std::f64::consts::TAU).sin(); Dynamic::from_float(sample as FLOAT) }) .collect::() .into(), ); input.insert("show_clock".into(), true.into()); input.insert("clock".into(), "12:34:56".into()); input.insert("track_title".into(), "track".into()); input.insert("track_artist".into(), "artist".into()); input } #[test] fn bundled_scripts_render_commands() { for script in BUNDLED_SCRIPTS { let path = std::env::temp_dir().join(format!( "furumi-test-{}-{}", std::process::id(), script.file_name )); std::fs::write(&path, script.source).unwrap(); let commands = ScriptRuntime::default() .render(&path, test_input()) .unwrap(); let _ = std::fs::remove_file(&path); assert!(!commands.is_empty(), "{} returned no commands", script.id); assert!( commands.len() < 220, "{} returned {} commands", script.id, commands.len() ); assert!( commands.iter().any( |command| matches!(command, DrawCommand::Text { text, .. } if text.contains("12:34:56")) ), "{} did not render the configured clock", script.id ); if script.id == PRIMARY_SCRIPT_ID { assert!( commands.iter().any( |command| matches!(command, DrawCommand::Text { text, .. } if text.contains("artist - track")) ), "{} did not render track metadata", script.id ); } } } #[test] fn stale_default_script_is_migrated() { let scope = primary_bundled_script(); assert!(should_replace_bundled_script( "// name: Scope spectrum\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }", scope, )); assert!(should_replace_bundled_script( "// name: Scope spectrum\n// protocol: furumi-visualizer-v2\nfn rgb(r, g, b) { let rr = to_int(clamp(r, 0, 255)); }", scope, )); assert!(!should_replace_bundled_script(scope.source, scope)); assert!(!should_replace_bundled_script( "// name: Custom 1\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }", scope, )); } }