From 97aa5cb20d1f42cf11d919343bf83fda9add1294 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 29 Jun 2026 16:30:58 -0700 Subject: [PATCH] Color scheme and some ui cleanup --- engine/src/color.rs | 411 +++++++++++++++++++++++++++++++++++++++++++ engine/src/lib.rs | 5 +- life/src/main.rs | 413 ++++++++++++++++++++++++++------------------ scripts/run | 2 +- 4 files changed, 660 insertions(+), 171 deletions(-) create mode 100644 engine/src/color.rs diff --git a/engine/src/color.rs b/engine/src/color.rs new file mode 100644 index 0000000..e31c8d2 --- /dev/null +++ b/engine/src/color.rs @@ -0,0 +1,411 @@ +//! Theme color generator for Bevy Feathers. +//! +//! # Main entrypoint +//! +//! [`build`] takes [`Seeds`] and a [`Mode`] and returns a complete Feathers +//! [`ThemeProps`](bevy::feathers::theme::ThemeProps). +//! +//! ```rust,ignore +//! // Auto-assign roles from a 5-swatch Coolors palette (ranked by chroma). +//! let seeds = Seeds::from_coolors([color1, color2, color3, color4, color5]); +//! let theme = build(seeds, Mode::Dark); +//! // Hand `theme` to Feathers. +//! ``` +//! +//! Construct [`Seeds`] directly if you need manual role assignment. + +use bevy::color::{Alpha, Color, Oklcha}; +use bevy::feathers::theme::{ThemeProps, ThemeToken}; +use bevy::feathers::tokens as ft; +use bevy::platform::collections::HashMap; + +// --------------------------------------------------------------------------- +// Color math +// --------------------------------------------------------------------------- + +fn relative_luminance(c: Color) -> f32 { + let lin = c.to_linear(); + 0.2126 * lin.red + 0.7152 * lin.green + 0.0722 * lin.blue +} + +fn contrast_ratio(a: Color, b: Color) -> f32 { + let (la, lb) = (relative_luminance(a), relative_luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +/// Pick the softest foreground at the given hue/chroma that meets `target` +/// WCAG contrast on `bg`. Falls back to pure white/black if nothing passes. +fn on_color(bg: Color, hue: f32, chroma: f32, target: f32) -> Color { + let bg_lum = relative_luminance(bg); + let mut best: Option<(f32, Color)> = None; + for i in 0..=100 { + let l = i as f32 / 100.0; + let cand = Color::oklcha(l, chroma, hue, 1.0); + if contrast_ratio(cand, bg) >= target { + let d = (relative_luminance(cand) - bg_lum).abs(); + if best.map_or(true, |(bd, _)| d < bd) { + best = Some((d, cand)); + } + } + } + best.map(|(_, c)| c) + .unwrap_or_else(|| Color::oklcha(if bg_lum < 0.5 { 1.0 } else { 0.0 }, 0.0, hue, 1.0)) +} + +/// Lighter in dark mode, darker in light mode — keeps hue/chroma. +fn elevate(c: Color, mode: Mode, amount: f32) -> Color { + let o = Oklcha::from(c); + let l = match mode { + Mode::Dark => o.lightness + amount, + Mode::Light => o.lightness - amount, + }; + Color::from(o.with_lightness(l.clamp(0.0, 1.0))) +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + Dark, + Light, +} + +/// The three seed colors the generator needs. Use `from_coolors` to +/// auto-assign roles from a 5-swatch palette. +#[derive(Clone, Copy, Debug)] +pub struct Seeds { + pub primary: Color, + pub secondary: Color, + pub accent: Color, +} + +impl Seeds { + /// Assign roles from an unordered 5-swatch palette (e.g. from Coolors). + /// Most-chromatic swatch becomes accent, next primary, next secondary. + pub fn from_coolors(palette: [Color; 5]) -> Self { + let mut by_chroma: Vec = palette.iter().map(|c| Oklcha::from(*c)).collect(); + by_chroma.sort_by(|a, b| b.chroma.total_cmp(&a.chroma)); + let pick = |o: Oklcha| Color::from(o); + Seeds { + accent: pick(by_chroma[0]), + primary: pick(by_chroma[1]), + secondary: pick(by_chroma[2]), + } + } +} + +// --------------------------------------------------------------------------- +// Lightness anchors +// --------------------------------------------------------------------------- + +struct Anchors { + window: f32, + sunken: f32, + surface: f32, + raised: f32, + border: f32, + control: f32, + text: f32, + text_dim: f32, + text_disabled: f32, + accent: f32, + status: f32, +} + +impl Anchors { + fn for_mode(mode: Mode) -> Self { + match mode { + Mode::Dark => Anchors { + window: 0.15, + sunken: 0.12, + surface: 0.19, + raised: 0.24, + border: 0.32, + control: 0.27, + text: 0.96, + text_dim: 0.74, + text_disabled: 0.46, + accent: 0.62, + status: 0.62, + }, + Mode::Light => Anchors { + window: 0.965, + sunken: 0.93, + surface: 0.99, + raised: 1.00, + border: 0.85, + control: 0.96, + text: 0.22, + text_dim: 0.45, + text_disabled: 0.70, + accent: 0.55, + status: 0.52, + }, + } + } +} + +// --------------------------------------------------------------------------- +// Theme builder +// --------------------------------------------------------------------------- + +/// Build a complete Feathers `ThemeProps` from seed colors. +/// +/// All colors are derived in OKLCH so hue never drifts, neutrals carry a +/// whisper of the primary hue, and text colors are WCAG-contrast-checked. +pub fn build(seeds: Seeds, mode: Mode) -> ThemeProps { + let a = Anchors::for_mode(mode); + + let primary = Oklcha::from(seeds.primary); + let accent = Oklcha::from(seeds.accent); + + let neutral_hue = primary.hue; + let neutral = |l: f32| Color::oklcha(l, 0.012, neutral_hue, 1.0); + let shade = |seed: Oklcha, l: f32| Color::oklcha(l, seed.chroma, seed.hue, 1.0); + + let status_chroma = ((primary.chroma + accent.chroma) * 0.5).clamp(0.08, 0.16); + let status = |hue: f32| Color::oklcha(a.status, status_chroma, hue, 1.0); + + let control = neutral(a.control); + let sunken = neutral(a.sunken); + let border = neutral(a.border); + let text = neutral(a.text); + let text_dim = neutral(a.text_dim); + let text_disabled = neutral(a.text_disabled); + + let accent_bg = shade(accent, a.accent); + let accent_hover = elevate(accent_bg, mode, 0.05); + let accent_pressed = elevate(accent_bg, mode, 0.10); + let focus_ring = shade(accent, if mode == Mode::Dark { 0.70 } else { 0.55 }); + + let btn_text = on_color(control, neutral_hue, 0.0, 4.5); + let accent_text = on_color(accent_bg, accent.hue, 0.03, 4.5); + + let mut colors: HashMap = HashMap::new(); + macro_rules! set { + ($tok:expr, $color:expr) => { + colors.insert($tok.clone(), $color) + }; + } + + // Window / pane surfaces + set!(ft::WINDOW_BG, neutral(a.window)); + set!(ft::PANE_HEADER_BG, neutral(a.window)); + set!(ft::PANE_HEADER_BORDER, border); + set!(ft::PANE_HEADER_TEXT, text); + set!(ft::PANE_HEADER_DIVIDER, border); + set!(ft::PANE_BODY_BG, neutral(a.surface)); + set!(ft::SUBPANE_HEADER_BG, neutral(a.raised)); + set!(ft::SUBPANE_HEADER_BORDER, border); + set!(ft::SUBPANE_HEADER_TEXT, text); + set!(ft::SUBPANE_BODY_BG, neutral(a.surface)); + set!(ft::SUBPANE_BODY_BORDER, border); + set!(ft::GROUP_HEADER_BG, neutral(a.raised)); + set!(ft::GROUP_HEADER_BORDER, border); + set!(ft::GROUP_HEADER_TEXT, text); + set!(ft::GROUP_BODY_BG, neutral(a.raised)); + set!(ft::GROUP_BODY_BORDER, border); + + // Focus ring + set!(ft::FOCUS_RING, focus_ring); + + // Text + set!(ft::TEXT_MAIN, text); + set!(ft::TEXT_DIM, text_dim); + + // Normal buttons + set!(ft::BUTTON_BG, control); + set!(ft::BUTTON_BG_HOVER, elevate(control, mode, 0.04)); + set!(ft::BUTTON_BG_PRESSED, elevate(control, mode, 0.08)); + set!(ft::BUTTON_BG_DISABLED, control.with_alpha(0.4)); + set!(ft::BUTTON_TEXT, btn_text); + set!(ft::BUTTON_TEXT_DISABLED, btn_text.with_alpha(0.5)); + + // Primary (accent) buttons + set!(ft::BUTTON_PRIMARY_BG, accent_bg); + set!(ft::BUTTON_PRIMARY_BG_HOVER, accent_hover); + set!(ft::BUTTON_PRIMARY_BG_PRESSED, accent_pressed); + set!(ft::BUTTON_PRIMARY_BG_DISABLED, accent_bg.with_alpha(0.4)); + set!(ft::BUTTON_PRIMARY_TEXT, accent_text); + set!( + ft::BUTTON_PRIMARY_TEXT_DISABLED, + accent_text.with_alpha(0.5) + ); + + // Plain (transparent) buttons + set!(ft::BUTTON_PLAIN_BG, Color::NONE); + set!(ft::BUTTON_PLAIN_BG_HOVER, neutral(a.raised)); + set!(ft::BUTTON_PLAIN_BG_PRESSED, control); + set!(ft::BUTTON_PLAIN_BG_DISABLED, Color::NONE); + + // Sliders + set!(ft::SLIDER_BG, sunken); + set!(ft::SLIDER_BG_HOVER, elevate(sunken, mode, 0.03)); + set!(ft::SLIDER_BG_PRESSED, elevate(sunken, mode, 0.06)); + set!(ft::SLIDER_BG_DISABLED, sunken.with_alpha(0.5)); + set!(ft::SLIDER_BAR, accent_bg); + set!(ft::SLIDER_BAR_HOVER, accent_hover); + set!(ft::SLIDER_BAR_PRESSED, accent_pressed); + set!(ft::SLIDER_BAR_DISABLED, accent_bg.with_alpha(0.5)); + set!(ft::SLIDER_TEXT, on_color(sunken, neutral_hue, 0.0, 4.5)); + set!(ft::SLIDER_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + + // Scrollbar + set!(ft::SCROLLBAR_BG, sunken); + set!(ft::SCROLLBAR_THUMB, accent_bg); + set!(ft::SCROLLBAR_THUMB_HOVER, accent_hover); + + // Checkboxes + set!(ft::CHECKBOX_BG, control); + set!(ft::CHECKBOX_BG_HOVER, elevate(control, mode, 0.04)); + set!(ft::CHECKBOX_BG_PRESSED, elevate(control, mode, 0.08)); + set!(ft::CHECKBOX_BG_DISABLED, control.with_alpha(0.4)); + set!(ft::CHECKBOX_BG_CHECKED, accent_bg); + set!(ft::CHECKBOX_BG_CHECKED_HOVER, accent_hover); + set!(ft::CHECKBOX_BG_CHECKED_PRESSED, accent_pressed); + set!(ft::CHECKBOX_BG_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); + set!(ft::CHECKBOX_BORDER, border); + set!(ft::CHECKBOX_BORDER_HOVER, elevate(border, mode, 0.04)); + set!(ft::CHECKBOX_BORDER_PRESSED, elevate(border, mode, 0.08)); + set!(ft::CHECKBOX_BORDER_DISABLED, border.with_alpha(0.4)); + set!(ft::CHECKBOX_BORDER_CHECKED, accent_bg); + set!(ft::CHECKBOX_BORDER_CHECKED_HOVER, accent_hover); + set!(ft::CHECKBOX_BORDER_CHECKED_PRESSED, accent_pressed); + set!( + ft::CHECKBOX_BORDER_CHECKED_DISABLED, + accent_bg.with_alpha(0.4) + ); + set!(ft::CHECKBOX_MARK, accent_text); + set!(ft::CHECKBOX_MARK_DISABLED, text_disabled); + set!(ft::CHECKBOX_TEXT, text); + set!(ft::CHECKBOX_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + + // Radio buttons + set!(ft::RADIO_BORDER, border); + set!(ft::RADIO_BORDER_HOVER, elevate(border, mode, 0.04)); + set!(ft::RADIO_BORDER_PRESSED, elevate(border, mode, 0.08)); + set!(ft::RADIO_BORDER_DISABLED, border.with_alpha(0.4)); + set!(ft::RADIO_BORDER_CHECKED, accent_bg); + set!(ft::RADIO_BORDER_CHECKED_HOVER, accent_hover); + set!(ft::RADIO_BORDER_CHECKED_PRESSED, accent_pressed); + set!(ft::RADIO_BORDER_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); + set!(ft::RADIO_MARK, accent_bg); + set!(ft::RADIO_MARK_HOVER, accent_hover); + set!(ft::RADIO_MARK_PRESSED, accent_pressed); + set!(ft::RADIO_MARK_DISABLED, border.with_alpha(0.4)); + set!(ft::RADIO_TEXT, text); + set!(ft::RADIO_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + + // Toggle switches + let slide = text; // high-contrast nub that slides + set!(ft::SWITCH_BG, control); + set!(ft::SWITCH_BG_HOVER, elevate(control, mode, 0.04)); + set!(ft::SWITCH_BG_PRESSED, elevate(control, mode, 0.08)); + set!(ft::SWITCH_BG_DISABLED, control.with_alpha(0.4)); + set!(ft::SWITCH_BG_CHECKED, accent_bg); + set!(ft::SWITCH_BG_CHECKED_HOVER, accent_hover); + set!(ft::SWITCH_BG_CHECKED_PRESSED, accent_pressed); + set!(ft::SWITCH_BG_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); + set!(ft::SWITCH_BORDER, border); + set!(ft::SWITCH_BORDER_HOVER, elevate(border, mode, 0.04)); + set!(ft::SWITCH_BORDER_PRESSED, elevate(border, mode, 0.08)); + set!(ft::SWITCH_BORDER_DISABLED, border.with_alpha(0.4)); + set!(ft::SWITCH_BORDER_CHECKED, accent_bg); + set!(ft::SWITCH_BORDER_CHECKED_HOVER, accent_hover); + set!(ft::SWITCH_BORDER_CHECKED_PRESSED, accent_pressed); + set!( + ft::SWITCH_BORDER_CHECKED_DISABLED, + accent_bg.with_alpha(0.4) + ); + set!(ft::SWITCH_SLIDE_BG, slide); + set!(ft::SWITCH_SLIDE_BG_HOVER, slide); + set!(ft::SWITCH_SLIDE_BG_PRESSED, slide); + set!(ft::SWITCH_SLIDE_BG_DISABLED, text_disabled); + set!(ft::SWITCH_SLIDE_BG_CHECKED, slide); + set!(ft::SWITCH_SLIDE_BG_CHECKED_HOVER, slide); + set!(ft::SWITCH_SLIDE_BG_CHECKED_PRESSED, slide); + set!(ft::SWITCH_SLIDE_BG_CHECKED_DISABLED, text_disabled); + set!(ft::SWITCH_SLIDE_BORDER, slide); + set!(ft::SWITCH_SLIDE_BORDER_HOVER, slide); + set!(ft::SWITCH_SLIDE_BORDER_PRESSED, slide); + set!(ft::SWITCH_SLIDE_BORDER_DISABLED, text_disabled); + set!(ft::SWITCH_SLIDE_BORDER_CHECKED, slide); + set!(ft::SWITCH_SLIDE_BORDER_CHECKED_HOVER, slide); + set!(ft::SWITCH_SLIDE_BORDER_CHECKED_PRESSED, slide); + set!(ft::SWITCH_SLIDE_BORDER_CHECKED_DISABLED, text_disabled); + + // Color plane + set!(ft::COLOR_PLANE_BG, sunken); + + // Menus + let menu_bg = neutral(a.surface); + set!(ft::MENU_BG, menu_bg); + set!(ft::MENU_BORDER, border); + set!(ft::MENUITEM_BG_HOVER, elevate(menu_bg, mode, 0.03)); + set!(ft::MENUITEM_BG_PRESSED, elevate(menu_bg, mode, 0.06)); + set!(ft::MENUITEM_BG_FOCUSED, elevate(menu_bg, mode, 0.06)); + set!(ft::MENUITEM_TEXT, text); + set!(ft::MENUITEM_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + + // Text input + set!(ft::TEXT_INPUT_BG, sunken); + set!(ft::TEXT_INPUT_LABEL_BG, control); + set!(ft::TEXT_INPUT_TEXT, text); + set!(ft::TEXT_INPUT_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + set!(ft::TEXT_INPUT_CURSOR, focus_ring); + set!(ft::TEXT_INPUT_SELECTION, accent_bg.with_alpha(0.5)); + set!(ft::TEXT_INPUT_SELECTION_UNFOCUSED, Color::NONE); + set!(ft::TEXT_INPUT_X_AXIS, status(27.0)); + set!(ft::TEXT_INPUT_Y_AXIS, status(150.0)); + set!(ft::TEXT_INPUT_Z_AXIS, status(270.0)); + + // Listview + set!(ft::LISTROW_BG, Color::NONE); + set!(ft::LISTROW_BG_HOVER, control.with_alpha(0.4)); + set!(ft::LISTROW_BG_SELECTED, control); + set!(ft::LISTROW_TEXT, text); + set!(ft::LISTROW_TEXT_DISABLED, text_disabled.with_alpha(0.5)); + + ThemeProps { color: colors } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bevy::color::Srgba; + use bevy::feathers::tokens as ft; + + fn hex(s: &str) -> Color { + Color::from(Srgba::hex(s).unwrap()) + } + + fn demo_seeds() -> Seeds { + Seeds::from_coolors([ + hex("264653"), + hex("2A9D8F"), + hex("E9C46A"), + hex("F4A261"), + hex("E76F51"), + ]) + } + + #[test] + fn text_meets_contrast_in_both_modes() { + for mode in [Mode::Dark, Mode::Light] { + let t = build(demo_seeds(), mode); + let get = |tok: &ThemeToken| *t.color.get(tok).unwrap(); + assert!(contrast_ratio(get(&ft::TEXT_MAIN), get(&ft::WINDOW_BG)) >= 4.5); + assert!( + contrast_ratio(get(&ft::BUTTON_PRIMARY_TEXT), get(&ft::BUTTON_PRIMARY_BG)) >= 4.5 + ); + assert!(contrast_ratio(get(&ft::BUTTON_TEXT), get(&ft::BUTTON_BG)) >= 4.5); + } + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 2607feb..be52f48 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,3 +1,6 @@ +pub mod color; +pub use color::*; + pub use std::time::Duration; pub use bevy::{ @@ -8,7 +11,7 @@ pub use bevy::{ FeathersPlugins, controls::*, dark_theme::create_dark_theme, - theme::{ThemedText, UiTheme}, + theme::{ThemeProps, ThemedText, UiTheme}, }, input::{ common_conditions::*, diff --git a/life/src/main.rs b/life/src/main.rs index a399929..5b13607 100644 --- a/life/src/main.rs +++ b/life/src/main.rs @@ -1,4 +1,8 @@ -use bevy::{feathers::theme::ThemeProps, ui::InteractionDisabled}; +use bevy::{ + feathers::{containers::*, display::*, theme::ThemeProps}, + ui::{Checked, InteractionDisabled}, + ui_widgets::{ActivateOnPress, ValueChange, checkbox_self_update, SliderStep, SliderPrecision, slider_self_update}, +}; use engine::*; const TILE: &str = "tileGrey_01.png"; @@ -52,6 +56,7 @@ fn main() { toggle_interactive::, // TODO Visualize simulation run/pause buttons enable/disable toggle_interactive::, + update_pitch_map_resource_ui.run_if(resource_changed::), ), ) .add_systems(Update, cell_lifecycle.run_if(on_message::)) @@ -69,7 +74,7 @@ enum AudioPlayback { Play, } -#[derive(Debug, Component, PartialEq, Default)] +#[derive(Debug, Component, PartialEq, Default, Clone, FromTemplate)] #[require(Visibility, Transform)] enum UiButton { Power, @@ -109,6 +114,13 @@ impl UiButton { } } } +fn ui_checkbox(text: &str) -> impl Scene { + bsn! { + @FeathersCheckbox { @caption: bsn! { Text(text) ThemedText } } + ActivateOnPress + on(checkbox_self_update) + } +} fn ui_button(button: UiButton) -> impl Scene { bsn! { @@ -130,8 +142,6 @@ fn ui_button(button: UiButton) -> impl Scene { image: HandleTemplate::Path(AssetPath::parse(button.icon())), } ] - on(responsive_button_over) - on(responsive_button_out) } } @@ -141,140 +151,197 @@ fn the_camera() -> impl Scene { } } +fn update_pitch_map_resource_ui( + query: Query<(Entity, &Note), With>, + pitch_map: Res, + mut commands: Commands, +) { + query.iter().for_each(|(e, n)| { + if pitch_map.notes.contains(n) { + commands.entity(e).insert(Checked); + } else { + commands.entity(e).remove::(); + } + }); +} + fn the_ui() -> impl Scene { + fn toggle_note( + this: On, + notes: Query<&Note, With>, + mut pitch_map: ResMut, + ) { + let this_note = notes.get(this.entity).unwrap(); + pitch_map.toggle(this_note); + } + bsn! { Node { top: Val::Px(0.0), left: Val::Px(0.0), - height: Val::Px(40.0), - width: Val::Percent(100.0), + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Start, + } Transform + pane() Children [ - ui_button(UiButton::Power) - on(|_: On, mut writer: MessageWriter| { - writer.write(AppExit::Success); - }), - ui_button(UiButton::Pause) - SimulationPlayback::Pause - on(|_: On, mut next: ResMut>| { - // Set simulation state - next.set(SimulationPlayback::Pause); - }), - ui_button(UiButton::Play) - SimulationPlayback::Run - on(|_: On, mut next: ResMut>| { - // Set simulation state - next.set(SimulationPlayback::Run); - }), - ui_button(UiButton::Step) - on(|_: On, mut next: ResMut>| { - next.set(SimulationPlayback::Step); - }), - ui_button(UiButton::ZoomOut) - on(|_: On, mut projection: Query<&mut Projection>| { - projection.iter_mut().for_each(|mut p| match *p { - Projection::Orthographic(ref mut o) => { - o.scale /= 0.5; - } - _ => todo!(), - }); - }), - ui_button(UiButton::ZoomIn) - on(|_: On, mut projection: Query<&mut Projection>| { - projection.iter_mut().for_each(|mut p| match *p { - Projection::Orthographic(ref mut o) => { - o.scale *= 0.5; + pane_body() + Children [ + subpane() + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Stretch, + justify_content: JustifyContent::Start, + + } + Children [ + subpane_header() Children [ + Text("Nav") ThemedText + ] + subpane_body() + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Stretch, + justify_content: JustifyContent::Start, + } + Children [ + ui_button(UiButton::Power) + on(|_: On, mut writer: MessageWriter| { + writer.write(AppExit::Success); + }), + ui_button(UiButton::Pause) + SimulationPlayback::Pause + on(|_: On, mut next: ResMut>| { + // Set simulation state + next.set(SimulationPlayback::Pause); + }), + ui_button(UiButton::Play) + SimulationPlayback::Run + on(|_: On, mut next: ResMut>| { + // Set simulation state + next.set(SimulationPlayback::Run); + }), + ui_button(UiButton::Step) + on(|_: On, mut next: ResMut>| { + next.set(SimulationPlayback::Step); + }), + ui_button(UiButton::ZoomOut) + on(|_: On, mut projection: Query<&mut Projection>| { + projection.iter_mut().for_each(|mut p| match *p { + Projection::Orthographic(ref mut o) => { + o.scale /= 0.5; + } + _ => todo!(), + }); + }), + ui_button(UiButton::ZoomIn) + on(|_: On, mut projection: Query<&mut Projection>| { + projection.iter_mut().for_each(|mut p| match *p { + Projection::Orthographic(ref mut o) => { + o.scale *= 0.5; + } + _ => todo!(), + }); + }), + ui_button(UiButton::MuteAudio) + AudioPlayback::Mute + on(|_: On, mut next: ResMut>| { + next.set(AudioPlayback::Mute); + }), + ui_button(UiButton::PlayAudio) + AudioPlayback::Play + on(|_: On, mut next: ResMut>| { + next.set(AudioPlayback::Play); + }), + ] + ], + subpane() + Children [ + subpane_header() Children [ + Text("Notes") ThemedText + ] + subpane_body() + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Stretch, + justify_content: JustifyContent::Start, + } + Children [ + ui_checkbox("A") + Note::A + on(toggle_note), + ui_checkbox("A#") + Note::ASharp + on(toggle_note), + ui_checkbox("B") + Note::B + on(toggle_note), + ui_checkbox("C") + Note::C + on(toggle_note), + ui_checkbox("C#") + Note::CSharp + on(toggle_note), + ui_checkbox("D") + Note::D + on(toggle_note), + ui_checkbox("D#") + Note::DSharp + on(toggle_note), + ui_checkbox("E") + Note::E + on(toggle_note), + ui_checkbox("F") + Note::F + on(toggle_note), + ui_checkbox("F#") + Note::FSharp + on(toggle_note), + ui_checkbox("G") + Note::G + on(toggle_note), + ui_checkbox("G#") + Note::GSharp + on(toggle_note), + ] + ], + subpane() + subpane_header() Children [ + Text("Octaves") ThemedText + ] + subpane_body() + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Stretch, + justify_content: JustifyContent::Start, } - _ => todo!(), - }); - }), - ui_button(UiButton::MuteAudio) - AudioPlayback::Mute - on(|_: On, mut next: ResMut>| { - next.set(AudioPlayback::Mute); - }), - ui_button(UiButton::PlayAudio) - AudioPlayback::Play - on(|_: On, mut next: ResMut>| { - next.set(AudioPlayback::Play); - }), - ui_button(UiButton::Note) - Note::A - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::ASharp - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::B - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::C - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::CSharp - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::D - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::DSharp - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::E - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::F - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::FSharp - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::G - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::Note) - Note::GSharp - on(|_: On, pitch_map: ResMut| { - warn!("TODO") - }), - ui_button(UiButton::AddLowOctave) - on(|_: On| { - warn!("TODO") - }), - ui_button(UiButton::SubLowOctave) - on(|_: On| { - warn!("TODO") - }), - ui_button(UiButton::AddHighOctave) - on(|_: On| { - warn!("TODO") - }), - ui_button(UiButton::SubHighOctave) - on(|_: On| { - warn!("TODO") - }), + Children [ + @FeathersSlider { @max: 5., @value: 3., @min: 1. } + SliderStep(1.) + SliderPrecision(0) + on(|value_change: On>, mut pitch_map: ResMut| { + if value_change.is_final { + pitch_map.octaves_up = value_change.value as usize; + } + }) + on(slider_self_update), + @FeathersSlider { @max: 5., @value: 3., @min: 1. } + SliderStep(1.) + SliderPrecision(0) + on(|value_change: On>, mut pitch_map: ResMut| { + if value_change.is_final { + pitch_map.octaves_down = value_change.value as usize; + } + }) + on(slider_self_update), + ], + ] ] } } @@ -519,16 +586,6 @@ fn simulation_step( }); } -fn responsive_button_over(trigger: On>, mut bg: Query<&mut BackgroundColor>) { - let mut background_color = bg.get_mut(trigger.entity).unwrap(); - background_color.0 = WHITE.into(); -} - -fn responsive_button_out(trigger: On>, mut bg: Query<&mut BackgroundColor>) { - let mut background_color = bg.get_mut(trigger.entity).unwrap(); - background_color.0 = GRAY.into(); -} - /// Bring a cell to life, or kill it #[derive(Message)] enum Lifecycle { @@ -639,17 +696,34 @@ impl Note { fn as_frequency(&self) -> f32 { match self { Self::A => 440.0, - Self::ASharp => todo!(), - Self::B => todo!(), - Self::C => todo!(), - Self::CSharp => todo!(), - Self::D => todo!(), - Self::DSharp => todo!(), - Self::E => todo!(), - Self::F => todo!(), - Self::FSharp => todo!(), - Self::G => todo!(), - Self::GSharp => todo!(), + Self::ASharp => 466.1638, + Self::B => 493.8833, + Self::C => 523.2511, + Self::CSharp => 554.3653, + Self::D => 587.3295, + Self::DSharp => 622.254, + Self::E => 659.2551, + Self::F => 698.4565, + Self::FSharp => 739.9888, + Self::G => 783.9909, + Self::GSharp => 830.6094, + } + } + + fn _as_str(&self) -> &str { + match self { + Self::A => "A", + Self::ASharp => "A#", + Self::B => "B", + Self::C => "C", + Self::CSharp => "C#", + Self::D => "D", + Self::DSharp => "D#", + Self::E => "E", + Self::F => "F", + Self::FSharp => "F#", + Self::G => "G", + Self::GSharp => "G#", } } } @@ -806,25 +880,16 @@ fn is_panning(panning: Res) -> bool { // This may or may not be an eye sore fn create_light_theme() -> ThemeProps { - ThemeProps { - color: create_dark_theme() - .color - .iter() - .map(|(token, color)| { - let c = match color { - Color::Oklcha(oklcha) => Color::Oklcha(Oklcha { - lightness: oklcha.lightness + 0.1, - ..*oklcha - }), - x => { - info!("Passing along color theme {:?} {:?}", token, x); - *x - } - }; - (token.clone(), c) - }) - .collect(), - } + build( + Seeds::from_coolors([ + Srgba::hex("264653").unwrap().into(), + Srgba::hex("2A9D8F").unwrap().into(), + Srgba::hex("E9C46A").unwrap().into(), + Srgba::hex("F4A261").unwrap().into(), + Srgba::hex("E76F51").unwrap().into(), + ]), + Mode::Light, + ) } fn toggle_interactive( @@ -857,3 +922,13 @@ impl Default for PitchMap { } } } + +impl PitchMap { + fn toggle(&mut self, n: &Note) { + if let Some(idx) = self.notes.iter().position(|v| v == n) { + self.notes.swap_remove(idx); + } else { + self.notes.push(n.clone()); + } + } +} diff --git a/scripts/run b/scripts/run index f682f56..5961394 100755 --- a/scripts/run +++ b/scripts/run @@ -8,7 +8,7 @@ if [[ "${TARGET}" == prototypes/* ]]; then FLAGS="--bin $(basename "${TARGET}")" else cd "${TARGET}" - FLAGS="" + FLAGS="--features engine/dev" fi cargo run ${FLAGS}