├── .gitignore
├── chordflow_audio
├── src
│ ├── lib.rs
│ └── audio.rs
├── assets
│ └── TimGM6mb.sf2
└── Cargo.toml
├── chordflow_desktop
├── src
│ ├── hooks.rs
│ ├── components
│ │ ├── header.rs
│ │ ├── practice_state.rs
│ │ ├── mode_selection.rs
│ │ ├── buttons.rs
│ │ ├── metronome.rs
│ │ ├── metronome_settings.rs
│ │ ├── play_controls.rs
│ │ └── config_state.rs
│ ├── components.rs
│ ├── hooks
│ │ └── use_metronome.rs
│ └── main.rs
├── package.json
├── assets
│ ├── favicon.ico
│ └── tailwind.css
├── icons
│ ├── AppIcon.icns
│ ├── favicon.ico
│ ├── icon-192.png
│ ├── icon-512.png
│ └── play_store_512.png
├── .gitignore
├── Dioxus.toml
├── input.css
├── Cargo.toml
├── README.md
└── tailwind.config.js
├── icons
├── web
│ ├── favicon.ico
│ ├── icon-192.png
│ ├── icon-512.png
│ ├── apple-touch-icon.png
│ ├── icon-192-maskable.png
│ ├── icon-512-maskable.png
│ └── README.txt
├── ios
│ ├── AppIcon-29.png
│ ├── AppIcon@2x.png
│ ├── AppIcon@3x.png
│ ├── AppIcon~ipad.png
│ ├── AppIcon-20@2x.png
│ ├── AppIcon-20@3x.png
│ ├── AppIcon-29@2x.png
│ ├── AppIcon-29@3x.png
│ ├── AppIcon-40@2x.png
│ ├── AppIcon-40@3x.png
│ ├── AppIcon-20~ipad.png
│ ├── AppIcon-29~ipad.png
│ ├── AppIcon-40~ipad.png
│ ├── AppIcon-60@2x~car.png
│ ├── AppIcon-60@3x~car.png
│ ├── AppIcon@2x~ipad.png
│ ├── AppIcon-20@2x~ipad.png
│ ├── AppIcon-29@2x~ipad.png
│ ├── AppIcon-40@2x~ipad.png
│ ├── AppIcon-83.5@2x~ipad.png
│ ├── AppIcon~ios-marketing.png
│ └── Contents.json
├── macos
│ └── AppIcon.icns
└── android
│ ├── play_store_512.png
│ └── res
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ └── mipmap-anydpi-v26
│ └── ic_launcher.xml
├── docs
└── chordflord_desktop.png
├── chordflow_tui
├── foo.log
├── Cargo.toml
└── src
│ ├── main.rs
│ ├── keymap.rs
│ └── ui.rs
├── chordflow_music_theory
├── src
│ ├── lib.rs
│ ├── util.rs
│ ├── accidental.rs
│ ├── chord.rs
│ ├── scale.rs
│ ├── interval.rs
│ ├── quality.rs
│ └── note.rs
└── Cargo.toml
├── Cross.toml
├── Cargo.toml
├── chordflow_shared
├── Cargo.toml
└── src
│ ├── cli.rs
│ ├── lib.rs
│ ├── mode.rs
│ ├── progression.rs
│ ├── metronome.rs
│ └── practice_state.rs
├── LICENSE
├── README.md
└── .github
└── workflows
└── build.yaml
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | .idea
3 | .DS_Store
--------------------------------------------------------------------------------
/chordflow_audio/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod audio;
2 |
--------------------------------------------------------------------------------
/chordflow_desktop/src/hooks.rs:
--------------------------------------------------------------------------------
1 | pub mod use_metronome;
2 |
--------------------------------------------------------------------------------
/icons/web/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/favicon.ico
--------------------------------------------------------------------------------
/icons/web/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-192.png
--------------------------------------------------------------------------------
/icons/web/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-512.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@2x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@3x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon~ipad.png
--------------------------------------------------------------------------------
/icons/macos/AppIcon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/macos/AppIcon.icns
--------------------------------------------------------------------------------
/chordflow_desktop/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "tailwindcss": "^3.4.17"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/docs/chordflord_desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/docs/chordflord_desktop.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@2x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@3x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@2x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@3x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@2x.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@3x.png
--------------------------------------------------------------------------------
/chordflow_tui/foo.log:
--------------------------------------------------------------------------------
1 | DEBUG - "/var/folders/m2/63j5gnr57yjg8qxvyq_m1pr00000gn/T/guitar_practice_soundfont.sf2"
2 |
--------------------------------------------------------------------------------
/icons/ios/AppIcon-20~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-29~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-40~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-60@2x~car.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-60@2x~car.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-60@3x~car.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-60@3x~car.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon@2x~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon@2x~ipad.png
--------------------------------------------------------------------------------
/icons/web/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/apple-touch-icon.png
--------------------------------------------------------------------------------
/icons/web/icon-192-maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-192-maskable.png
--------------------------------------------------------------------------------
/icons/web/icon-512-maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/web/icon-512-maskable.png
--------------------------------------------------------------------------------
/icons/android/play_store_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/play_store_512.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-20@2x~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-20@2x~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-29@2x~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-29@2x~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-40@2x~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-40@2x~ipad.png
--------------------------------------------------------------------------------
/chordflow_audio/assets/TimGM6mb.sf2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_audio/assets/TimGM6mb.sf2
--------------------------------------------------------------------------------
/chordflow_desktop/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/assets/favicon.ico
--------------------------------------------------------------------------------
/chordflow_desktop/icons/AppIcon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/AppIcon.icns
--------------------------------------------------------------------------------
/chordflow_desktop/icons/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/favicon.ico
--------------------------------------------------------------------------------
/chordflow_desktop/icons/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/icon-192.png
--------------------------------------------------------------------------------
/chordflow_desktop/icons/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/icon-512.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon-83.5@2x~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon-83.5@2x~ipad.png
--------------------------------------------------------------------------------
/icons/ios/AppIcon~ios-marketing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/ios/AppIcon~ios-marketing.png
--------------------------------------------------------------------------------
/chordflow_desktop/icons/play_store_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/chordflow_desktop/icons/play_store_512.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-hdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-hdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-hdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-mdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-mdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-mdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/icons/android/res/mipmap-xxxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timvancann/chordflow/HEAD/icons/android/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/chordflow_music_theory/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod accidental;
2 | pub mod chord;
3 | pub mod interval;
4 | pub mod note;
5 | pub mod quality;
6 | pub mod scale;
7 | pub mod util;
8 |
9 |
--------------------------------------------------------------------------------
/chordflow_desktop/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target
4 | .DS_Store
5 |
6 | # These are backup files generated by rustfmt
7 | **/*.rs.bk
8 | node_modules
9 |
--------------------------------------------------------------------------------
/chordflow_music_theory/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "chordflow_music_theory"
3 | version = "0.3.2"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | color-eyre = "0.6.3"
8 | itertools = "0.14.0"
9 | rand = "0.9.0"
10 | strum = { version = "0.27.0", features = ["derive"] }
11 | strum_macros = "0.27.0"
12 |
--------------------------------------------------------------------------------
/Cross.toml:
--------------------------------------------------------------------------------
1 | [target.x86_64-unknown-linux-musl]
2 | pre-build = [
3 | "dpkg --add-architecture $CROSS_DEB_ARCH",
4 | "apt-get update && apt-get --assume-yes install libasound2-dev"
5 | ]
6 | [target.x86_64-unknown-linux-musl.env]
7 | passthrough = [
8 | "RUSTFLAGS=-Ctarget-feature=-crt-static"
9 | ]
10 |
--------------------------------------------------------------------------------
/chordflow_desktop/src/components/header.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 |
3 | #[component]
4 | pub fn Header() -> Element {
5 | rsx! {
6 |
7 | div { class: "w-screen bg-tokyoNight-bg_highlight/70",
8 | p { class: "font-mono font-bold text-3xl p-3 text-tokyoNight-blue", "ChordFlow" }
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/icons/android/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
M|m|\-|aug|\+|dim|o|maj7|7|m7b5)?", 49 | ) 50 | .unwrap(); 51 | 52 | let mut results = vec![]; 53 | for res in re.captures_iter(&str) { 54 | results.push(( 55 | res.name("n").unwrap().as_str().parse::().unwrap(), 56 | res.name("l") 57 | .map_or(NoteLetter::C, |l| NoteLetter::from_string(l.as_str())), 58 | res.name("a") 59 | .map_or(Accidental::Natural, |a| Accidental::from_string(a.as_str())), 60 | res.name("q") 61 | .map_or(Quality::Major, |q| Quality::from_string(q.as_str())), 62 | )); 63 | } 64 | 65 | if results.is_empty() { 66 | return Err(anyhow::anyhow!("Invalid progression")); 67 | } 68 | Ok(results 69 | .into_iter() 70 | .map(|(n, note, accidental, quality)| { 71 | let note = Note::new(note, accidental.to_semitones()); 72 | ProgressionChord::new(Chord::new(note, quality), n) 73 | }) 74 | .collect()) 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | 82 | #[test] 83 | fn test_progression_chord_from_str() { 84 | let results = ProgressionChord::from_string("3C 2Bm 1F#aug".into()); 85 | assert!(results.is_ok()); 86 | assert_eq!( 87 | results.unwrap(), 88 | vec![ 89 | ProgressionChord::new(Chord::new(Note::new(NoteLetter::C, 0), Quality::Major), 3), 90 | ProgressionChord::new(Chord::new(Note::new(NoteLetter::B, 0), Quality::Minor), 2), 91 | ProgressionChord::new( 92 | Chord::new(Note::new(NoteLetter::F, 1), Quality::Augmented), 93 | 1 94 | ), 95 | ] 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /chordflow_desktop/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | sync::mpsc::{Receiver, Sender}, 4 | time::Instant, 5 | }; 6 | 7 | use chordflow_audio::audio::setup_audio; 8 | use chordflow_shared::{ 9 | metronome::{setup_metronome, MetronomeCommand, MetronomeEvent}, 10 | practice_state::{ConfigState, PracticState}, 11 | ModeOption, 12 | }; 13 | use components::{ 14 | config_state::ConfigStateDisplay, 15 | header::Header, 16 | metronome::MetronomeDisplay, 17 | metronome_settings::MetronomSettingsDisplay, 18 | mode_selection::ModeSelectionDisplay, 19 | play_controls::{restart, PlayControls}, 20 | practice_state::PracticeStateDisplay, 21 | }; 22 | use dioxus::{ 23 | desktop::{tao::platform::macos::WindowBuilderExtMacOS, Config, LogicalSize, WindowBuilder}, 24 | prelude::*, 25 | }; 26 | use hooks::use_metronome::use_metronome; 27 | 28 | mod components; 29 | mod hooks; 30 | 31 | const FAVICON: Asset = asset!("/assets/favicon.ico"); 32 | const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); 33 | 34 | fn main() { 35 | let window_builder = WindowBuilder::new() 36 | .with_transparent(true) 37 | .with_decorations(true) 38 | .with_focused(true) 39 | .with_resizable(true) 40 | .with_title("ChordFlow") 41 | .with_has_shadow(true) 42 | .with_movable_by_window_background(true) 43 | .with_inner_size(LogicalSize { 44 | height: 910, 45 | width: 1000, 46 | }) 47 | .with_always_on_top(false); 48 | 49 | let config = Config::default().with_window(window_builder); 50 | 51 | dioxus::LaunchBuilder::new().with_cfg(config).launch(App); 52 | } 53 | 54 | type MetronomeSignal = Signal<(Sender , Receiver )>; 55 | 56 | #[derive(PartialEq, Clone, Copy)] 57 | struct MetronomeState { 58 | bars_per_chord: usize, 59 | ticks_per_bar: usize, 60 | bpm: usize, 61 | current_bar: usize, 62 | current_tick: usize, 63 | } 64 | 65 | impl Display for MetronomeState { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | write!( 68 | f, 69 | "BPM: {}, Bar: {}/{} Tick: {}/{}", 70 | self.bpm, self.current_bar, self.bars_per_chord, self.current_tick, self.ticks_per_bar 71 | ) 72 | } 73 | } 74 | 75 | #[component] 76 | fn App() -> Element { 77 | let audio_tx = use_signal(|| setup_audio(None)); 78 | let metronome = use_signal(|| setup_metronome(100, 2, 4, Instant::now)); 79 | let practice_state = use_signal(PracticState::default); 80 | let selected_mode = use_signal(|| ModeOption::Fourths); 81 | let config_state = use_signal(ConfigState::default); 82 | let metronome_state = use_signal(|| MetronomeState { 83 | bars_per_chord: 2, 84 | ticks_per_bar: 4, 85 | bpm: 100, 86 | current_bar: 0, 87 | current_tick: 0, 88 | }); 89 | 90 | use_context_provider(|| audio_tx); 91 | use_context_provider(|| metronome); 92 | use_context_provider(|| practice_state); 93 | use_context_provider(|| selected_mode); 94 | use_context_provider(|| config_state); 95 | use_context_provider(|| metronome_state); 96 | 97 | use_metronome(metronome, metronome_state, practice_state, audio_tx); 98 | 99 | let mut initial_setup = use_signal(|| true); 100 | 101 | use_effect(move || { 102 | if !*initial_setup.read() { 103 | return; 104 | } 105 | restart(); 106 | initial_setup.set(false); 107 | }); 108 | 109 | rsx! { 110 | // Global app resources 111 | document::Link { rel: "icon", href: FAVICON } 112 | document::Link { rel: "stylesheet", href: TAILWIND_CSS } 113 | body { class: " bg-tokyoNight-bg text-tokyoNight-fg w-screen h-screen", 114 | 115 | Header {} 116 | div { class: "m-2 p-2 flex-col space-y-4", 117 | 118 | 119 | div { class: "flex space-x-4", 120 | div { class: " bg-tokyoNight-bg_highlight/70 p-4 rounded-md", 121 | ModeSelectionDisplay {} 122 | } 123 | div { class: " bg-tokyoNight-bg_highlight/70 flex-1 p-4 rounded-md flex-col space-y-4", 124 | div { class: "", MetronomeDisplay {} } 125 | div { class: "", MetronomSettingsDisplay {} } 126 | div { class: "", PracticeStateDisplay {} } 127 | div { class: "", PlayControls {} } 128 | } 129 | } 130 | div { class: "flex-1 space-x-4", 131 | div { class: " bg-tokyoNight-bg_highlight/70 p-4 rounded-md", ConfigStateDisplay {} } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest] 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v3 20 | 21 | - name: Install dependencies 22 | run: brew install cmake portaudio pkg-config 23 | 24 | - name: Build 25 | uses: actions-rs/cargo@v1 26 | with: 27 | use-cross: true 28 | command: test 29 | 30 | tui: 31 | needs: test 32 | name: TUI 33 | runs-on: ${{ matrix.os }} 34 | 35 | strategy: 36 | matrix: 37 | include: 38 | - os: macos-latest 39 | target: x86_64-apple-darwin 40 | - os: macos-latest 41 | target: aarch64-apple-darwin 42 | 43 | steps: 44 | - name: Clone repository 45 | uses: actions/checkout@v3 46 | 47 | - name: Install dependencies 48 | run: brew install cmake portaudio pkg-config 49 | 50 | - name: Install Rust 51 | uses: dtolnay/rust-toolchain@stable 52 | with: 53 | targets: ${{ matrix.target }} 54 | 55 | - name: Build 56 | uses: actions-rs/cargo@v1 57 | with: 58 | use-cross: true 59 | command: build 60 | args: --release --target ${{ matrix.target }} 61 | 62 | - name: Get Version 63 | shell: bash 64 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 65 | 66 | - name: Build Archive 67 | shell: bash 68 | run: | 69 | mkdir artifacts 70 | binary_name="chordflow_tui" 71 | app_name="${binary_name}-${{ env.VERSION }}-${{ matrix.target }}" 72 | tar -czf artifacts/${app_name}.tar.gz target/${{ matrix.target }}/release/${binary_name} 73 | 74 | - name: Upload Release Artifacts 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: binaries-tui-${{ matrix.target }} 78 | path: artifacts/ 79 | 80 | desktop: 81 | name: Desktop-MacOS 82 | needs: test 83 | runs-on: ${{ matrix.os }} 84 | 85 | strategy: 86 | matrix: 87 | include: 88 | - os: macos-latest 89 | target: x86_64-apple-darwin 90 | - os: macos-latest 91 | target: aarch64-apple-darwin 92 | 93 | steps: 94 | - name: Clone repository 95 | uses: actions/checkout@v3 96 | 97 | - name: Install dependencies 98 | run: brew install cmake portaudio pkg-config 99 | 100 | - name: Install Rust 101 | uses: dtolnay/rust-toolchain@stable 102 | with: 103 | targets: ${{ matrix.target }} 104 | 105 | - name: Install Dioxus-CLI 106 | shell: bash 107 | run: cargo install dioxus-cli 108 | 109 | - name: Get Version 110 | shell: bash 111 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 112 | 113 | - name: Build Archive 114 | shell: bash 115 | working-directory: ./chordflow_desktop 116 | run: | 117 | mkdir ../artifacts 118 | dx bundle --release 119 | dmg_name="ChordFlow-${{ env.VERSION }}-${{ matrix.target }}.dmg" 120 | mv ../target/dx/chordflow_desktop/bundle/macos/bundle/dmg/ChordflowDesktop_*.dmg ../artifacts/${dmg_name} 121 | 122 | - name: Upload Release Artifacts 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: binaries-desktop-macos-${{ matrix.target }} 126 | path: artifacts/ 127 | 128 | 129 | release: 130 | needs: [desktop, tui] 131 | runs-on: ubuntu-latest 132 | 133 | permissions: 134 | contents: write 135 | 136 | 137 | steps: 138 | - name: Get Version 139 | shell: bash 140 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 141 | 142 | - name: Download macOS Intel TUI Artifacts 143 | uses: actions/download-artifact@v4 144 | with: 145 | name: binaries-tui-x86_64-apple-darwin 146 | path: artifacts/ 147 | 148 | - name: Download macOS Arch TUI Artifacts 149 | uses: actions/download-artifact@v4 150 | with: 151 | name: binaries-tui-aarch64-apple-darwin 152 | path: artifacts/ 153 | 154 | - name: Download macOS Intel Desktop Artifacts 155 | uses: actions/download-artifact@v4 156 | with: 157 | name: binaries-desktop-macos-x86_64-apple-darwin 158 | path: artifacts/ 159 | 160 | - name: Download macOS Arch Desktop Artifacts 161 | uses: actions/download-artifact@v4 162 | with: 163 | name: binaries-desktop-macos-aarch64-apple-darwin 164 | path: artifacts/ 165 | 166 | - name: Show artifacts 167 | shell: bash 168 | run: ls -la artifacts 169 | 170 | - name: Create GitHub Release 171 | if: startsWith(github.ref, 'refs/tags/') 172 | uses: softprops/action-gh-release@v2 173 | with: 174 | files: | 175 | artifacts/chordflow_tui-${{ env.VERSION }}-x86_64-apple-darwin.tar.gz 176 | artifacts/chordflow_tui-${{ env.VERSION }}-aarch64-apple-darwin.tar.gz 177 | artifacts/ChordFlow-${{ env.VERSION }}-x86_64-apple-darwin.dmg 178 | artifacts/ChordFlow-${{ env.VERSION }}-aarch64-apple-darwin.dmg 179 | -------------------------------------------------------------------------------- /chordflow_music_theory/src/note.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | use strum::{AsRefStr, Display, EnumCount, EnumIter, FromRepr, IntoEnumIterator}; 5 | 6 | use super::{accidental::Accidental, interval::Interval}; 7 | 8 | #[derive( 9 | Default, Clone, Copy, Debug, EnumIter, AsRefStr, PartialEq, EnumCount, FromRepr, Eq, Display, 10 | )] 11 | pub enum NoteLetter { 12 | #[default] 13 | C, 14 | D, 15 | E, 16 | F, 17 | G, 18 | A, 19 | B, 20 | } 21 | 22 | impl NoteLetter { 23 | pub fn to_index(self) -> i32 { 24 | match self { 25 | NoteLetter::C => 0, 26 | NoteLetter::D => 1, 27 | NoteLetter::E => 2, 28 | NoteLetter::F => 3, 29 | NoteLetter::G => 4, 30 | NoteLetter::A => 5, 31 | NoteLetter::B => 6, 32 | } 33 | } 34 | pub fn from_letter_index(idx: i32) -> Self { 35 | match idx { 36 | 0 => NoteLetter::C, 37 | 1 => NoteLetter::D, 38 | 2 => NoteLetter::E, 39 | 3 => NoteLetter::F, 40 | 4 => NoteLetter::G, 41 | 5 => NoteLetter::A, 42 | 6 => NoteLetter::B, 43 | _ => panic!("Invalid note index"), 44 | } 45 | } 46 | pub fn to_semitones(self) -> i32 { 47 | match self { 48 | NoteLetter::C => 0, 49 | NoteLetter::D => 2, 50 | NoteLetter::E => 4, 51 | NoteLetter::F => 5, 52 | NoteLetter::G => 7, 53 | NoteLetter::A => 9, 54 | NoteLetter::B => 11, 55 | } 56 | } 57 | 58 | pub fn from_string(s: &str) -> NoteLetter { 59 | match s.to_uppercase().as_str() { 60 | "C" => NoteLetter::C, 61 | "D" => NoteLetter::D, 62 | "E" => NoteLetter::E, 63 | "F" => NoteLetter::F, 64 | "G" => NoteLetter::G, 65 | "A" => NoteLetter::A, 66 | "B" => NoteLetter::B, 67 | _ => panic!("Invalid note letter"), 68 | } 69 | } 70 | } 71 | 72 | #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] 73 | pub struct Note { 74 | pub letter: NoteLetter, 75 | pub accidentals: i32, 76 | } 77 | 78 | impl Display for Note { 79 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 80 | let mut accidendal = "".to_string(); 81 | if self.accidentals > 0 { 82 | accidendal = "#".repeat(self.accidentals as usize) 83 | } 84 | if self.accidentals < 0 { 85 | accidendal = "b".repeat(-self.accidentals as usize) 86 | }; 87 | write!(f, "{}{}", self.letter, accidendal) 88 | } 89 | } 90 | 91 | impl Note { 92 | pub fn new(letter: NoteLetter, accidentals: i32) -> Note { 93 | Note { 94 | letter, 95 | accidentals, 96 | } 97 | } 98 | pub fn to_semitones(self) -> i32 { 99 | self.letter.to_semitones() + self.accidentals 100 | } 101 | 102 | pub fn add_interval(&self, interval: Interval) -> Note { 103 | let new_semitones = 104 | (self.letter.to_semitones() + self.accidentals + interval.to_semitones()) % 12; 105 | let new_letter_index = (self.letter.to_index() + interval.to_index()) % 7; 106 | let new_letter = NoteLetter::from_letter_index(new_letter_index); 107 | 108 | let remaining_semitones = new_semitones - new_letter.to_semitones(); 109 | Note::new(new_letter, remaining_semitones) 110 | } 111 | } 112 | 113 | pub fn generate_all_roots() -> Vec { 114 | NoteLetter::iter() 115 | .cartesian_product(Accidental::iter()) 116 | .filter(|(note, accidental)| { 117 | let is_b_sharp = note == &NoteLetter::B && accidental == &Accidental::Sharp; 118 | let is_c_flat = note == &NoteLetter::C && accidental == &Accidental::Flat; 119 | let is_e_sharp = note == &NoteLetter::E && accidental == &Accidental::Sharp; 120 | let is_f_flat = note == &NoteLetter::F && accidental == &Accidental::Flat; 121 | !is_c_flat && !is_e_sharp && !is_b_sharp && !is_f_flat 122 | }) 123 | .map(|(note, accidental)| Note::new(note, accidental.to_semitones())) 124 | .collect() 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | 131 | #[test] 132 | fn test_add_interval() { 133 | let note = Note::new(NoteLetter::C, 0); 134 | let intervals = Interval::iter(); 135 | 136 | let actual_notes = vec![ 137 | Note::new(NoteLetter::C, 0), 138 | Note::new(NoteLetter::D, -1), 139 | Note::new(NoteLetter::D, 0), 140 | Note::new(NoteLetter::E, -1), 141 | Note::new(NoteLetter::E, 0), 142 | Note::new(NoteLetter::F, 0), 143 | Note::new(NoteLetter::F, 1), 144 | Note::new(NoteLetter::F, 1), 145 | Note::new(NoteLetter::G, -1), 146 | Note::new(NoteLetter::G, 0), 147 | Note::new(NoteLetter::A, -1), 148 | Note::new(NoteLetter::A, 0), 149 | Note::new(NoteLetter::B, -1), 150 | Note::new(NoteLetter::B, 0), 151 | ]; 152 | 153 | for (interval, actual) in intervals.zip(actual_notes) { 154 | let new_note = note.add_interval(interval); 155 | assert_eq!(new_note, actual); 156 | } 157 | assert_eq!( 158 | Note::new(NoteLetter::F, 1).add_interval(Interval::PerfectFifth), 159 | Note::new(NoteLetter::C, 1) 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /chordflow_audio/src/audio.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs::File, io::Write, path::PathBuf, sync::mpsc, thread, time::Duration}; 2 | 3 | use chordflow_music_theory::chord::Chord; 4 | use fluidlite::{Settings, Synth}; 5 | use log::debug; 6 | use rodio::{buffer::SamplesBuffer, OutputStream, Sink}; 7 | 8 | const SAMPLE_RATE: usize = 44100; 9 | 10 | pub struct Audio { 11 | _stream: OutputStream, 12 | pub synth: Synth, 13 | pub sink: Sink, 14 | } 15 | 16 | pub fn play_chord_with_ticks( 17 | synth: &mut Synth, 18 | notes: &[u32], 19 | chord_duration_ms: u64, 20 | ticks_per_bar: usize, 21 | ) -> Vec { 22 | let mut buffer = vec![0.0; chord_duration_ms as usize * SAMPLE_RATE * 2 / 1000]; 23 | 24 | // Play chord 25 | for note in notes { 26 | synth.note_on(0, *note, 100).unwrap(); 27 | } 28 | 29 | synth.write(buffer.as_mut_slice()).unwrap(); 30 | 31 | // Play metronome ticks (woodblock sound) every quarter note 32 | let tick_interval = chord_duration_ms / ticks_per_bar as u64; 33 | for i in 0..ticks_per_bar { 34 | let tick_time = i as u64 * tick_interval; 35 | play_tick(synth, tick_time, &mut buffer); 36 | } 37 | 38 | // Turn off chord 39 | for note in notes { 40 | synth.note_off(0, *note).unwrap(); 41 | } 42 | 43 | buffer 44 | } 45 | 46 | pub fn play_tick(synth: &mut Synth, tick_time: u64, buffer: &mut [f32]) { 47 | let tick_note = 76; // High Woodblock in General MIDI 48 | let velocity = 120; 49 | 50 | synth.note_on(9, tick_note, velocity).unwrap(); // Channel 9 = Percussion 51 | let mut tick_buffer = vec![0.0; SAMPLE_RATE * 2 / 10]; // Small buffer for the tick (~100ms) 52 | 53 | synth.write(tick_buffer.as_mut_slice()).unwrap(); 54 | synth.note_off(9, tick_note).unwrap(); 55 | 56 | // Mix tick buffer into the main buffer at the correct time 57 | let start_sample = (tick_time as usize * SAMPLE_RATE * 2 / 1000).min(buffer.len()); 58 | (0..tick_buffer.len()).for_each(|i| { 59 | let idx = start_sample + i; 60 | if idx < buffer.len() { 61 | buffer[idx] += tick_buffer[i]; 62 | } 63 | }); 64 | } 65 | 66 | #[derive(Clone, PartialEq)] 67 | pub enum AudioCommand { 68 | PlayChord((Chord, u64, usize)), 69 | Play, 70 | Pause, 71 | } 72 | 73 | pub fn setup_audio(soundfont_path: Option ) -> mpsc::Sender { 74 | let (tx, rx) = mpsc::channel(); 75 | let mut synth = create_synth(soundfont_path); 76 | 77 | thread::spawn(move || { 78 | let (_stream, stream_handle) = 79 | OutputStream::try_default().expect("Failed to create audio output stream"); 80 | let sink = Sink::try_new(&stream_handle).expect("Failed to create Rodio sink"); 81 | sink.play(); 82 | 83 | loop { 84 | while let Ok(command) = rx.try_recv() { 85 | match command { 86 | AudioCommand::PlayChord((chord, duration, ticks_per_bar)) => { 87 | sink.stop(); 88 | let notes = chord_to_midi(chord); 89 | let buffer = 90 | play_chord_with_ticks(&mut synth, ¬es, duration, ticks_per_bar); 91 | let source = SamplesBuffer::new(2, SAMPLE_RATE as u32, buffer); 92 | sink.append(source); 93 | sink.play(); 94 | } 95 | AudioCommand::Pause => { 96 | if !sink.is_paused() { 97 | sink.pause(); 98 | } 99 | } 100 | AudioCommand::Play => { 101 | if sink.is_paused() { 102 | sink.play(); 103 | } 104 | } 105 | } 106 | } 107 | thread::sleep(Duration::from_millis(1)); 108 | } 109 | }); 110 | 111 | tx 112 | } 113 | 114 | pub fn create_synth(soundfont_path: Option ) -> fluidlite::Synth { 115 | let settings = Settings::new().unwrap(); 116 | 117 | let synth = Synth::new(settings).expect("Failed to create synthesizer"); 118 | synth 119 | .sfload(soundfont_path.unwrap_or(extract_soundfont()), true) 120 | .unwrap(); 121 | synth 122 | } 123 | 124 | fn extract_soundfont() -> PathBuf { 125 | let mut path = env::temp_dir(); 126 | path.push("guitar_practice_soundfont.sf2"); // Use a fixed filename 127 | debug!("{:?}", path); 128 | 129 | if !path.exists() { 130 | // Load SoundFont bytes 131 | let soundfont_bytes = include_bytes!("../assets/TimGM6mb.sf2"); 132 | 133 | // Create and write file 134 | let mut file = File::create(&path).expect("Failed to create temp SoundFont file"); 135 | file.write_all(soundfont_bytes) 136 | .expect("Failed to write SoundFont file"); 137 | } 138 | 139 | path 140 | } 141 | 142 | pub fn create_audio_sink() -> (rodio::Sink, OutputStream) { 143 | let (_stream, stream_handle) = 144 | OutputStream::try_default().expect("Failed to create audio output stream"); 145 | ( 146 | Sink::try_new(&stream_handle).expect("Failed to create Rodio sink"), 147 | _stream, 148 | ) 149 | } 150 | 151 | pub fn note_to_midi(semitones_from_c: i32) -> u32 { 152 | ((semitones_from_c % 12) + 60) as u32 153 | } 154 | 155 | pub fn chord_to_midi(chord: Chord) -> Vec { 156 | chord 157 | .to_c_based_semitones() 158 | .into_iter() 159 | .map(note_to_midi) 160 | .collect() 161 | } 162 | -------------------------------------------------------------------------------- /chordflow_shared/src/metronome.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::mpsc, 3 | thread, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | pub struct Metronome { 8 | pub num_bars: usize, 9 | pub num_ticks: usize, 10 | pub bpm: usize, 11 | 12 | pub timer_source: Box Instant + Send>, 13 | pub last_tick_time: Instant, 14 | 15 | pub current_tick: usize, 16 | pub current_bar: usize, 17 | pub is_running: bool, 18 | pub pause_time: Option , 19 | } 20 | 21 | impl Metronome { 22 | pub fn new( 23 | bpm: usize, 24 | num_bars: usize, 25 | num_beats: usize, 26 | timer_source: impl Fn() -> Instant + 'static + Send, 27 | ) -> Self { 28 | Metronome { 29 | last_tick_time: timer_source(), 30 | timer_source: Box::new(timer_source), 31 | bpm, 32 | current_bar: 0, 33 | current_tick: 0, 34 | num_bars, 35 | num_ticks: num_beats, 36 | is_running: false, 37 | pause_time: None, 38 | } 39 | } 40 | 41 | pub fn start(&mut self) { 42 | self.is_running = true; 43 | self.last_tick_time = (self.timer_source)(); 44 | } 45 | 46 | pub fn has_bar_ended(&self) -> bool { 47 | self.current_tick % self.num_ticks == 0 48 | } 49 | 50 | pub fn has_cycle_ended(&self) -> bool { 51 | self.has_bar_ended() && self.current_bar % self.num_bars == 0 52 | } 53 | 54 | pub fn tick(&mut self) -> usize { 55 | self.current_tick += 1; 56 | if self.current_tick % self.num_ticks == 0 { 57 | self.current_bar = (self.current_bar + 1) % self.num_bars; 58 | self.current_tick = 0; 59 | } 60 | self.current_tick 61 | } 62 | 63 | pub fn increase_bpm(&mut self, delta: usize) { 64 | self.bpm = self.bpm.saturating_add(delta) 65 | } 66 | pub fn decrease_bpm(&mut self, delta: usize) { 67 | self.bpm = self.bpm.saturating_sub(delta) 68 | } 69 | 70 | pub fn get_tick_duration(&self) -> Duration { 71 | Duration::from_millis((60_000 / self.bpm) as u64) 72 | } 73 | 74 | pub fn reset(&mut self) { 75 | self.current_bar = 0; 76 | self.current_tick = 0; 77 | self.reset_timers(); 78 | } 79 | 80 | pub fn reset_timers(&mut self) { 81 | self.last_tick_time = (self.timer_source)(); 82 | } 83 | } 84 | 85 | pub struct NoteDuration { 86 | pub duration_per_quarter_note: u64, 87 | pub duration_per_bar: u64, 88 | } 89 | 90 | pub fn calculate_duration_per_bar(bpm: usize, ticks_per_bar: usize) -> NoteDuration { 91 | let duration_per_quarter_note = 60000u64 / bpm as u64; 92 | let duration_per_bar = duration_per_quarter_note * ticks_per_bar as u64; 93 | NoteDuration { 94 | duration_per_quarter_note, 95 | duration_per_bar, 96 | } 97 | } 98 | 99 | pub enum MetronomeCommand { 100 | DecreaseBpm(usize), 101 | IncreaseBpm(usize), 102 | Reset, 103 | SetBars(usize), 104 | Pause, 105 | Play, 106 | } 107 | 108 | pub enum MetronomeEvent { 109 | BarComplete(usize), 110 | CycleComplete, 111 | Tick(usize), 112 | } 113 | 114 | pub fn setup_metronome( 115 | bpm: usize, 116 | num_bars: usize, 117 | num_beats: usize, 118 | timer_source: impl Fn() -> Instant + 'static + Send + Copy, 119 | ) -> ( 120 | mpsc::Sender , 121 | mpsc::Receiver , 122 | ) { 123 | let (tx_command, rx_command) = mpsc::channel(); 124 | let (tx_event, rx_event) = mpsc::channel(); 125 | 126 | thread::spawn(move || { 127 | let mut metronome = Metronome::new(bpm, num_bars, num_beats, timer_source); 128 | let mut running = true; 129 | 130 | metronome.last_tick_time = timer_source(); 131 | loop { 132 | while let Ok(command) = rx_command.try_recv() { 133 | match command { 134 | MetronomeCommand::SetBars(n) => metronome.num_bars = n, 135 | MetronomeCommand::Reset => metronome.reset(), 136 | MetronomeCommand::IncreaseBpm(delta) => metronome.increase_bpm(delta), 137 | MetronomeCommand::DecreaseBpm(delta) => metronome.decrease_bpm(delta), 138 | MetronomeCommand::Pause => { 139 | if !running { 140 | continue; 141 | } 142 | running = false; 143 | metronome.pause_time = Some((timer_source)()) 144 | } 145 | MetronomeCommand::Play => { 146 | if let Some(paused_at) = metronome.pause_time.take() { 147 | let pause_duration = (timer_source)().duration_since(paused_at); 148 | metronome.last_tick_time += pause_duration; // Adjust for pause duration 149 | } 150 | running = true; 151 | } 152 | } 153 | } 154 | 155 | if !running { 156 | continue; 157 | } 158 | 159 | let tick_duration = metronome.get_tick_duration(); 160 | let elapsed = (timer_source)().duration_since(metronome.last_tick_time); 161 | 162 | if elapsed >= tick_duration { 163 | let current_tick = metronome.tick(); 164 | let _ = tx_event.send(MetronomeEvent::Tick(current_tick)); 165 | if metronome.has_cycle_ended() { 166 | let _ = tx_event.send(MetronomeEvent::CycleComplete); 167 | } 168 | if metronome.has_bar_ended() { 169 | let _ = tx_event.send(MetronomeEvent::BarComplete(metronome.current_bar)); 170 | } 171 | 172 | // Reset the timer 173 | metronome.last_tick_time = (timer_source)(); 174 | } else { 175 | // Sleep for a short duration to avoid busy waiting 176 | // But keep it short to maintain responsiveness 177 | let sleep_duration = std::cmp::min( 178 | tick_duration.saturating_sub(elapsed), 179 | Duration::from_millis(1), 180 | ); 181 | thread::sleep(sleep_duration); 182 | } 183 | } 184 | }); 185 | 186 | (tx_command, rx_event) 187 | } 188 | -------------------------------------------------------------------------------- /chordflow_tui/src/main.rs: -------------------------------------------------------------------------------- 1 | use chordflow_shared::cli::parse_cli; 2 | use chordflow_shared::metronome::{ 3 | calculate_duration_per_bar, setup_metronome, MetronomeCommand, MetronomeEvent, 4 | }; 5 | use chordflow_shared::practice_state::ConfigState; 6 | use log::LevelFilter; 7 | use log4rs::append::file::FileAppender; 8 | use log4rs::config::{Appender, Config, Root}; 9 | 10 | use log4rs::encode::pattern::PatternEncoder; 11 | use std::io; 12 | use std::sync::mpsc::{Receiver, Sender}; 13 | use std::time::{Duration, Instant}; 14 | 15 | use chordflow_audio::audio::{setup_audio, AudioCommand}; 16 | use chordflow_music_theory::quality::Quality; 17 | use chordflow_shared::{mode::Mode, practice_state::PracticState, DiatonicOption, ModeOption}; 18 | use strum::{AsRefStr, EnumCount, FromRepr, IntoEnumIterator}; 19 | 20 | mod keymap; 21 | mod ui; 22 | 23 | use crossterm::event::{self, Event}; 24 | use keymap::handle_keys; 25 | use ratatui::DefaultTerminal; 26 | use strum::Display; 27 | use strum_macros::EnumIter; 28 | use ui::render_ui; 29 | 30 | #[cfg(debug_assertions)] 31 | fn setup_logging() { 32 | let file_path = "tui.log"; 33 | let logfile = FileAppender::builder() 34 | // Pattern: https://docs.rs/log4rs/*/log4rs/encode/pattern/index.html 35 | .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) 36 | .build(file_path) 37 | .unwrap(); 38 | 39 | let config = Config::builder() 40 | .appender(Appender::builder().build("logfile", Box::new(logfile))) 41 | .build( 42 | Root::builder() 43 | .appender("logfile") 44 | .build(LevelFilter::Debug), 45 | ) 46 | .unwrap(); 47 | 48 | let _handle = log4rs::init_config(config); 49 | } 50 | 51 | fn main() -> io::Result<()> { 52 | let cli = parse_cli(); 53 | 54 | #[cfg(debug_assertions)] 55 | setup_logging(); 56 | 57 | let mut terminal = ratatui::init(); 58 | let tx_audio = setup_audio(cli.soundfont); 59 | let mut app = App::new(tx_audio, cli.bpm, cli.bars_per_chord, cli.ticks_per_bar); 60 | 61 | app.run(&mut terminal)?; 62 | ratatui::restore(); 63 | 64 | Ok(()) 65 | } 66 | #[derive(Clone, Copy, Debug, EnumIter, Display, AsRefStr, PartialEq, EnumCount, FromRepr)] 67 | enum AppTab { 68 | Mode, 69 | Config, 70 | Playback, 71 | } 72 | 73 | struct App { 74 | exit: bool, 75 | selected_tab: AppTab, 76 | selected_mode: ModeOption, 77 | 78 | bars_per_chord: usize, 79 | ticks_per_bar: usize, 80 | bpm: usize, 81 | current_bar: usize, 82 | current_tick: usize, 83 | 84 | config_state: ConfigState, 85 | 86 | random_qualities_cursor: Quality, 87 | custom_input_buffer: String, 88 | practice_state: PracticState, 89 | 90 | tx_audio: Sender , 91 | tx_metronome: Sender , 92 | rx_metronome: Receiver , 93 | } 94 | 95 | impl App { 96 | fn new( 97 | tx_audio: Sender , 98 | bpm: usize, 99 | bars_per_chord: usize, 100 | ticks_per_bar: usize, 101 | ) -> Self { 102 | let (tx_metronome, rx_metronome) = 103 | setup_metronome(bpm, bars_per_chord, ticks_per_bar, Instant::now); 104 | Self { 105 | exit: false, 106 | selected_tab: AppTab::Playback, 107 | selected_mode: ModeOption::Fourths, 108 | config_state: ConfigState::default(), 109 | random_qualities_cursor: Quality::Major, 110 | custom_input_buffer: String::new(), 111 | practice_state: PracticState::default(), 112 | bars_per_chord, 113 | ticks_per_bar, 114 | current_bar: 0, 115 | current_tick: 0, 116 | bpm, 117 | tx_audio, 118 | tx_metronome, 119 | rx_metronome, 120 | } 121 | } 122 | 123 | fn next_item (&mut self, current_item: T) -> usize 124 | where 125 | T: EnumCount + IntoEnumIterator + PartialEq, 126 | { 127 | let current_position = T::iter().position(|t| t == current_item).unwrap(); 128 | let next_position: usize = (current_position + 1) % T::COUNT; 129 | next_position 130 | } 131 | 132 | fn prev_item (&mut self, current_item: T) -> usize 133 | where 134 | T: EnumCount + IntoEnumIterator + PartialEq, 135 | { 136 | let current_position = T::iter().position(|t| t == current_item).unwrap(); 137 | let prev_position: usize = if current_position == 0 { 138 | T::COUNT - 1 139 | } else { 140 | current_position - 1 141 | }; 142 | prev_position 143 | } 144 | 145 | fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { 146 | let _ = self.tx_audio.send(AudioCommand::PlayChord(( 147 | self.practice_state.current_chord, 148 | calculate_duration_per_bar(self.bpm, self.ticks_per_bar).duration_per_bar, 149 | self.ticks_per_bar, 150 | ))); 151 | 152 | while !self.exit { 153 | terminal.draw(|f| render_ui(f, self))?; 154 | self.handle_events()?; 155 | self.update(); 156 | } 157 | Ok(()) 158 | } 159 | 160 | fn handle_events(&mut self) -> io::Result<()> { 161 | if event::poll(Duration::from_millis(10))? { 162 | if let Event::Key(key) = event::read()? { 163 | handle_keys(key, self); 164 | } 165 | } 166 | Ok(()) 167 | } 168 | 169 | fn update(&mut self) { 170 | while let Ok(event) = self.rx_metronome.try_recv() { 171 | match event { 172 | MetronomeEvent::CycleComplete => { 173 | if let Mode::Custom(Some(p)) = &self.practice_state.mode { 174 | self.bars_per_chord = 175 | p.chords[self.practice_state.next_progression_chord_idx].bars; 176 | } 177 | let _ = self 178 | .tx_metronome 179 | .send(MetronomeCommand::SetBars(self.bars_per_chord)); 180 | let _ = self.tx_metronome.send(MetronomeCommand::Reset); 181 | self.practice_state.next_chord(); 182 | self.current_bar = 0; 183 | self.current_tick = 0; 184 | } 185 | MetronomeEvent::BarComplete(b) => { 186 | let _ = self.tx_audio.send(AudioCommand::PlayChord(( 187 | self.practice_state.current_chord, 188 | calculate_duration_per_bar(self.bpm, self.ticks_per_bar).duration_per_bar, 189 | self.ticks_per_bar, 190 | ))); 191 | self.current_bar = b; 192 | self.current_tick = 0; 193 | } 194 | MetronomeEvent::Tick(t) => self.current_tick = t, 195 | }; 196 | } 197 | } 198 | } 199 | 200 | fn sync_metronome_bars(app: &mut App) { 201 | if let Mode::Custom(Some(p)) = &app.practice_state.mode { 202 | app.bars_per_chord = p.chords[app.practice_state.next_progression_chord_idx].bars; 203 | } 204 | let _ = app 205 | .tx_metronome 206 | .send(MetronomeCommand::SetBars(app.bars_per_chord)); 207 | } 208 | -------------------------------------------------------------------------------- /chordflow_desktop/src/components/config_state.rs: -------------------------------------------------------------------------------- 1 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 2 | use chordflow_shared::{ 3 | practice_state::ConfigState, 4 | progression::{Progression, ProgressionChord}, 5 | DiatonicOption, 6 | }; 7 | use dioxus::prelude::*; 8 | use strum::IntoEnumIterator; 9 | 10 | use crate::components::buttons::{Button, ToggleButton}; 11 | 12 | #[component] 13 | pub fn ConfigStateDisplay() -> Element { 14 | let mut config_state: Signal = use_context(); 15 | let mut progression_input: Signal = use_signal(|| "".to_string()); 16 | let mut progression_error: Signal = use_signal(|| "".to_string()); 17 | let mut config_state: Signal = use_context(); 18 | 19 | rsx! { 20 | div { class: "flex-col space-y-4", 21 | SingleConfigStateDisplay { 22 | title: "Circle of Fourths", 23 | children: rsx! { 24 | div { class: "flex space-x-2 text-sm", 25 | for q in Quality::iter() { 26 | ToggleButton { 27 | onclick: move |_| { 28 | config_state.write().fourths_selected_quality = q; 29 | }, 30 | is_selected: q == config_state.read().fourths_selected_quality, 31 | text: q.name(), 32 | } 33 | } 34 | } 35 | }, 36 | } 37 | SingleConfigStateDisplay { 38 | title: "Diatonic Progression", 39 | children: rsx! { 40 | div { class: "flex space-x-4 text-sm items-center", 41 | div { class: "flex space-x-2", 42 | for q in DiatonicOption::iter() { 43 | ToggleButton { 44 | onclick: move |_| { 45 | config_state.write().diatonic_option = q; 46 | }, 47 | is_selected: q == config_state.read().diatonic_option, 48 | text: q.to_string(), 49 | } 50 | } 51 | } 52 | span { " | " } 53 | select { 54 | class: "select h-9", 55 | onchange: move |e| { 56 | let index = e.value().parse:: ().unwrap(); 57 | config_state.write().diatonic_root = generate_all_roots()[index]; 58 | }, 59 | for (i , root) in generate_all_roots().into_iter().enumerate() { 60 | option { 61 | label: root.to_string(), 62 | value: i, 63 | selected: root == config_state.read().diatonic_root, 64 | } 65 | } 66 | } 67 | } 68 | }, 69 | } 70 | 71 | SingleConfigStateDisplay { 72 | title: "Random Chords", 73 | children: rsx! { 74 | div { class: "flex space-x-2 text-sm", 75 | for q in Quality::iter() { 76 | ToggleButton { 77 | onclick: move |_| { 78 | if config_state.read().random_selected_qualities.contains(&q) { 79 | config_state.write().random_selected_qualities.retain(|s| *s != q); 80 | } else { 81 | config_state.write().random_selected_qualities.push(q); 82 | } 83 | }, 84 | is_selected: config_state.read().random_selected_qualities.contains(&q), 85 | text: q.name(), 86 | } 87 | } 88 | } 89 | }, 90 | } 91 | SingleConfigStateDisplay { 92 | title: "Custom Progression", 93 | children: rsx! { 94 | div { 95 | span { "Format: " } 96 | span { class: "text-tokyoNight-magenta", "[bars]" } 97 | span { class: "text-tokyoNight-yellow", "[note]" } 98 | span { class: "text-tokyoNight-teal", "[quality]" } 99 | span { " | Example: " } 100 | span { class: "text-tokyoNight-magenta", "3C " } 101 | span { class: "text-tokyoNight-yellow", "2Bm " } 102 | span { class: "text-tokyoNight-teal", "1F#+ " } 103 | } 104 | div { class: "flex space-x-2 text-sm items-center", 105 | input { 106 | class: "border-[1px] border-tokyoNight-comment shadow-lg text-tokyoNight-blue p-2 bg-tokyoNight-bg", 107 | value: "{progression_input}", 108 | oninput: move |event| progression_input.set(event.value()), 109 | } 110 | Button { 111 | onclick: move |_| { 112 | let progression = ProgressionChord::from_string( 113 | progression_input.read().to_string(), 114 | ); 115 | if let Ok(p) = progression { 116 | config_state.write().progression = Some(Progression { chords: p }); 117 | progression_error.set("".to_string()); 118 | } else { 119 | progression_error 120 | .set(format!("Failed to parse {}", progression_input.read())) 121 | } 122 | }, 123 | text: "Parse", 124 | } 125 | span { 126 | class: match config_state.read().progression { 127 | Some(_) => "text-tokyoNight-magenta font-bold tracking-wide", 128 | None => "", 129 | }, 130 | { 131 | if let Some(p) = &config_state.read().progression { 132 | p.to_string() 133 | } else { 134 | "No valid progression".to_string() 135 | } 136 | } 137 | } 138 | span { class: "text-tokyoNight-magenta2", "{progression_error}" } 139 | } 140 | }, 141 | } 142 | } 143 | } 144 | } 145 | 146 | #[component] 147 | pub fn SingleConfigStateDisplay(title: String, children: Element) -> Element { 148 | rsx! { 149 | div { class: "flex-col bg-tokyoNight-bg p-2 space-y-2 rounded-md", 150 | div { class: "font-semibold tracking-wide", {title} } 151 | {children} 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /chordflow_tui/src/keymap.rs: -------------------------------------------------------------------------------- 1 | use chordflow_audio::audio::AudioCommand; 2 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 3 | use chordflow_shared::{ 4 | metronome::{calculate_duration_per_bar, MetronomeCommand}, 5 | mode::update_mode_from_state, 6 | progression::{Progression, ProgressionChord}, 7 | DiatonicOption, ModeOption, 8 | }; 9 | use crossterm::event::KeyCode; 10 | use crossterm::event::KeyEvent; 11 | 12 | use crate::{sync_metronome_bars, App, AppTab}; 13 | 14 | pub fn handle_keys(key: KeyEvent, app: &mut App) { 15 | match key.code { 16 | KeyCode::Left => { 17 | app.selected_tab = AppTab::from_repr(app.prev_item:: (app.selected_tab)).unwrap() 18 | } 19 | KeyCode::Right => { 20 | app.selected_tab = AppTab::from_repr(app.next_item:: (app.selected_tab)).unwrap() 21 | } 22 | KeyCode::Esc => app.exit = true, 23 | KeyCode::Char('q') => app.exit = true, 24 | KeyCode::F(1) => { 25 | let has_changed = update_mode_from_state( 26 | &app.selected_mode, 27 | &mut app.practice_state, 28 | &app.config_state, 29 | ); 30 | 31 | sync_metronome_bars(app); 32 | if has_changed { 33 | let _ = app.tx_metronome.send(MetronomeCommand::Reset); 34 | app.current_bar = 0; 35 | app.current_tick = 0; 36 | let _ = app.tx_audio.send(AudioCommand::PlayChord(( 37 | app.practice_state.current_chord, 38 | calculate_duration_per_bar(app.bpm, app.ticks_per_bar).duration_per_bar, 39 | app.ticks_per_bar, 40 | ))); 41 | } 42 | } 43 | _ => {} 44 | } 45 | match app.selected_tab { 46 | AppTab::Playback => match key.code { 47 | KeyCode::Up => { 48 | app.bpm += 2; 49 | let _ = app.tx_metronome.send(MetronomeCommand::IncreaseBpm(2)); 50 | sync_metronome_bars(app); 51 | } 52 | KeyCode::Down => { 53 | app.bpm -= 2; 54 | let _ = app.tx_metronome.send(MetronomeCommand::DecreaseBpm(2)); 55 | sync_metronome_bars(app); 56 | } 57 | KeyCode::Char('r') => { 58 | app.practice_state.reset(); 59 | app.current_bar = 0; 60 | app.current_tick = 0; 61 | let _ = app.tx_metronome.send(MetronomeCommand::Reset); 62 | let _ = app.tx_audio.send(AudioCommand::PlayChord(( 63 | app.practice_state.current_chord, 64 | calculate_duration_per_bar(app.bpm, app.ticks_per_bar).duration_per_bar, 65 | app.ticks_per_bar, 66 | ))); 67 | } 68 | _ => {} 69 | }, 70 | 71 | AppTab::Mode => match key.code { 72 | KeyCode::Up => { 73 | app.selected_mode = 74 | ModeOption::from_repr(app.prev_item:: (app.selected_mode)).unwrap() 75 | } 76 | KeyCode::Down => { 77 | app.selected_mode = 78 | ModeOption::from_repr(app.next_item:: (app.selected_mode)).unwrap() 79 | } 80 | _ => {} 81 | }, 82 | AppTab::Config => match app.selected_mode { 83 | ModeOption::Fourths => match key.code { 84 | KeyCode::Up => { 85 | app.config_state.fourths_selected_quality = Quality::from_repr( 86 | app.prev_item:: (app.config_state.fourths_selected_quality), 87 | ) 88 | .unwrap(); 89 | } 90 | KeyCode::Down => { 91 | app.config_state.fourths_selected_quality = Quality::from_repr( 92 | app.next_item:: (app.config_state.fourths_selected_quality), 93 | ) 94 | .unwrap(); 95 | } 96 | _ => {} 97 | }, 98 | ModeOption::Random => match key.code { 99 | KeyCode::Up => { 100 | app.random_qualities_cursor = 101 | Quality::from_repr(app.prev_item:: (app.random_qualities_cursor)) 102 | .unwrap(); 103 | } 104 | KeyCode::Down => { 105 | app.random_qualities_cursor = 106 | Quality::from_repr(app.next_item:: (app.random_qualities_cursor)) 107 | .unwrap(); 108 | } 109 | KeyCode::Char(' ') => { 110 | if app 111 | .config_state 112 | .random_selected_qualities 113 | .contains(&app.random_qualities_cursor) 114 | { 115 | if app.config_state.random_selected_qualities.len() > 1 { 116 | app.config_state 117 | .random_selected_qualities 118 | .retain(|&x| x != app.random_qualities_cursor); 119 | } 120 | } else { 121 | app.config_state 122 | .random_selected_qualities 123 | .push(app.random_qualities_cursor); 124 | } 125 | } 126 | _ => {} 127 | }, 128 | ModeOption::Diatonic => match key.code { 129 | KeyCode::Down => { 130 | let all_roots = generate_all_roots(); 131 | let position = all_roots 132 | .iter() 133 | .position(|&x| x == app.config_state.diatonic_root); 134 | let next_position = (position.unwrap() + 1) % all_roots.len(); 135 | 136 | app.config_state.diatonic_root = all_roots[next_position]; 137 | } 138 | KeyCode::Up => { 139 | let all_roots = generate_all_roots(); 140 | let position = all_roots 141 | .iter() 142 | .position(|&x| x == app.config_state.diatonic_root); 143 | let next_position = if position.unwrap() == 0 { 144 | all_roots.len() - 1 145 | } else { 146 | position.unwrap() - 1 147 | }; 148 | app.config_state.diatonic_root = all_roots[next_position]; 149 | } 150 | KeyCode::Tab => { 151 | app.config_state.diatonic_option = DiatonicOption::from_repr( 152 | app.next_item:: (app.config_state.diatonic_option), 153 | ) 154 | .unwrap(); 155 | } 156 | _ => {} 157 | }, 158 | ModeOption::Custom => match key.code { 159 | KeyCode::Enter => { 160 | let progression = 161 | ProgressionChord::from_string(app.custom_input_buffer.clone()); 162 | app.config_state.progression = match progression { 163 | Ok(progression) => Some(Progression { 164 | chords: progression, 165 | }), 166 | _ => None, 167 | } 168 | } 169 | KeyCode::Backspace => { 170 | app.custom_input_buffer.pop(); 171 | } 172 | KeyCode::Char(c) => { 173 | app.custom_input_buffer.push(c); 174 | } 175 | _ => {} 176 | }, 177 | }, 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /chordflow_shared/src/practice_state.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Not; 2 | 3 | use chordflow_music_theory::{ 4 | chord::Chord, 5 | interval::Interval, 6 | note::{generate_all_roots, Note, NoteLetter}, 7 | quality::Quality, 8 | scale::Scale, 9 | util::random_chord, 10 | }; 11 | use rand::{rng, seq::IndexedRandom}; 12 | use strum::IntoEnumIterator; 13 | 14 | use crate::{mode::Mode, progression::Progression, DiatonicOption}; 15 | 16 | #[derive(Clone, PartialEq)] 17 | pub struct ConfigState { 18 | pub fourths_selected_quality: Quality, 19 | pub progression: Option , 20 | pub random_selected_qualities: Vec , 21 | pub diatonic_root: Note, 22 | pub diatonic_option: DiatonicOption, 23 | } 24 | impl Default for ConfigState { 25 | fn default() -> Self { 26 | ConfigState { 27 | fourths_selected_quality: Quality::default(), 28 | progression: Option::default(), 29 | random_selected_qualities: Quality::iter().collect(), 30 | diatonic_root: Note::default(), 31 | diatonic_option: DiatonicOption::default(), 32 | } 33 | } 34 | } 35 | 36 | #[derive(PartialEq, Clone)] 37 | pub struct PracticState { 38 | pub current_chord: Chord, 39 | pub next_chord: Chord, 40 | pub mode: Mode, 41 | pub next_scale_interval: Interval, 42 | pub current_progression_chord_idx: usize, 43 | pub next_progression_chord_idx: usize, 44 | } 45 | 46 | impl PracticState { 47 | pub fn set_mode(&mut self, mode: Mode) -> bool { 48 | if mode == self.mode { 49 | return false; 50 | } 51 | self.mode = mode; 52 | self.reset(); 53 | true 54 | } 55 | 56 | pub fn next_chord(&mut self) { 57 | self.current_chord = self.next_chord; 58 | if let Mode::Custom(Some(p)) = &self.mode { 59 | self.current_progression_chord_idx = 60 | (self.current_progression_chord_idx + 1) % p.chords.len(); 61 | } 62 | self.next_chord = self 63 | .generate_next_chord(self.current_chord, self.mode.clone()) 64 | .unwrap(); 65 | } 66 | 67 | pub fn generate_next_chord(&mut self, chord: Chord, mode: Mode) -> Option { 68 | match mode { 69 | Mode::Fourths(_) => { 70 | let mut next_note = chord.root.add_interval(Interval::PerfectFourth); 71 | if next_note == Note::new(NoteLetter::G, -1) { 72 | next_note = Note::new(NoteLetter::F, 1); 73 | } 74 | Some(Chord::new(next_note, chord.quality)) 75 | } 76 | Mode::Random(qualities) => { 77 | if qualities.is_empty() { 78 | Some(random_chord(None)) 79 | } else { 80 | Some(random_chord(Some(qualities))) 81 | } 82 | } 83 | Mode::Custom(Some(progression)) => { 84 | self.next_progression_chord_idx = 85 | (self.current_progression_chord_idx + 1) % progression.chords.len(); 86 | Some(progression.chords[self.next_progression_chord_idx].chord) 87 | } 88 | Mode::Custom(None) => None, 89 | Mode::Diatonic(scale, option) => { 90 | let interval = 91 | next_diatonic_scale_interval(&option, &scale, &self.next_scale_interval); 92 | let quality = calculate_chord_quality_in_scale(&scale, &interval); 93 | 94 | let next_note = scale.root.add_interval(interval); 95 | self.next_scale_interval = interval; 96 | Some(Chord::new(next_note, quality)) 97 | } 98 | } 99 | } 100 | 101 | pub fn reset(&mut self) { 102 | let mut rand = rng(); 103 | self.current_chord = match &self.mode { 104 | Mode::Fourths(q) => Chord::new(Note::new(NoteLetter::B, 0), *q), 105 | Mode::Random(qs) => { 106 | let note = *generate_all_roots().choose(&mut rand).unwrap(); 107 | Chord::new(note, *qs.choose(&mut rand).unwrap()) 108 | } 109 | Mode::Diatonic(scale, _) => { 110 | self.next_scale_interval = scale.intervals[0]; 111 | Chord::new(scale.root, Quality::Major) 112 | } 113 | Mode::Custom(p) => p.clone().expect("woops").chords[0].chord, 114 | }; 115 | self.next_chord = self 116 | .generate_next_chord(self.current_chord, self.mode.clone()) 117 | .unwrap(); 118 | } 119 | } 120 | 121 | fn next_diatonic_scale_interval( 122 | option: &DiatonicOption, 123 | scale: &Scale, 124 | current_scale_interval: &Interval, 125 | ) -> Interval { 126 | let mut rand = rng(); 127 | match option { 128 | DiatonicOption::Incemental => { 129 | let index = scale 130 | .intervals 131 | .iter() 132 | .position(|f| f == current_scale_interval) 133 | .unwrap(); 134 | let next_index = (index + 1) % scale.intervals.len(); 135 | scale.intervals[next_index] 136 | } 137 | DiatonicOption::Random => *scale.intervals.choose(&mut rand).unwrap(), 138 | } 139 | } 140 | 141 | fn calculate_chord_quality_in_scale(scale: &Scale, interval: &Interval) -> Quality { 142 | let new_scale_index = scale.intervals.iter().position(|f| f == interval).unwrap() as i32; 143 | let new_chord_indexes: Vec = 144 | vec![new_scale_index, new_scale_index + 2, new_scale_index + 4] 145 | .into_iter() 146 | .map(|i| normalize(i, 7)) 147 | .map(|i| scale.intervals[i as usize].to_semitones()) 148 | .collect(); 149 | let zero_based_chord_indexes = new_chord_indexes 150 | .iter() 151 | .map(|i| normalize(i - new_chord_indexes[0], 12)) 152 | .collect:: >(); 153 | Quality::from_intervals(zero_based_chord_indexes) 154 | } 155 | 156 | fn normalize(interval: i32, base: i32) -> i32 { 157 | (interval + base) % base 158 | } 159 | 160 | impl Default for PracticState { 161 | fn default() -> Self { 162 | let mode = Mode::Fourths(Quality::Major); 163 | let current_chord = Chord::new(Note::new(NoteLetter::B, 0), Quality::Major); 164 | let next_chord = Chord::new(Note::new(NoteLetter::E, 0), Quality::Major); 165 | PracticState { 166 | mode, 167 | current_chord, 168 | next_chord, 169 | next_scale_interval: Interval::Unison, 170 | next_progression_chord_idx: 0, 171 | current_progression_chord_idx: 0, 172 | } 173 | } 174 | } 175 | #[cfg(test)] 176 | mod tests { 177 | 178 | use chordflow_music_theory::scale::ScaleType; 179 | 180 | use super::*; 181 | 182 | #[test] 183 | fn test_calculate_chord_quality_in_scale() { 184 | assert_eq!( 185 | calculate_chord_quality_in_scale( 186 | &Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic), 187 | &Interval::MajorThird 188 | ), 189 | Quality::Minor 190 | ); 191 | assert_eq!( 192 | calculate_chord_quality_in_scale( 193 | &Scale::new(Note::new(NoteLetter::F, 1), ScaleType::Diatonic), 194 | &Interval::PerfectFourth 195 | ), 196 | Quality::Major 197 | ); 198 | 199 | let c_major = Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic); 200 | let real_qualities = vec![ 201 | Quality::Major, 202 | Quality::Minor, 203 | Quality::Minor, 204 | Quality::Major, 205 | Quality::Major, 206 | Quality::Minor, 207 | Quality::Diminished, 208 | ]; 209 | for (interval, quality) in c_major.intervals.iter().zip(real_qualities) { 210 | assert_eq!( 211 | calculate_chord_quality_in_scale(&c_major, interval), 212 | quality 213 | ); 214 | } 215 | } 216 | 217 | #[test] 218 | fn test_next_diatonic_chord() { 219 | let c_major = Scale::new(Note::new(NoteLetter::C, 0), ScaleType::Diatonic); 220 | 221 | let actual_intervals = vec![ 222 | Interval::MajorSecond, 223 | Interval::MajorThird, 224 | Interval::PerfectFourth, 225 | Interval::PerfectFifth, 226 | Interval::MajorSixth, 227 | Interval::MajorSeventh, 228 | Interval::Unison, 229 | ]; 230 | for (interval, real_interval) in c_major.intervals.iter().zip(actual_intervals) { 231 | assert_eq!( 232 | next_diatonic_scale_interval(&DiatonicOption::Incemental, &c_major, interval), 233 | real_interval 234 | ) 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /chordflow_tui/src/ui.rs: -------------------------------------------------------------------------------- 1 | use chordflow_music_theory::{note::generate_all_roots, quality::Quality}; 2 | use ratatui::{ 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::{Line, Span}, 6 | widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Tabs}, 7 | Frame, 8 | }; 9 | use strum::IntoEnumIterator; 10 | 11 | use crate::{App, AppTab, DiatonicOption, ModeOption}; 12 | 13 | pub fn render_ui(f: &mut Frame, app: &App) { 14 | let chunks = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints( 17 | [ 18 | Constraint::Length(3), // Tabs 19 | Constraint::Min(10), // Content Area 20 | Constraint::Length(10), // Content Area 21 | ] 22 | .as_ref(), 23 | ) 24 | .split(f.area()); 25 | 26 | let keymap_chunks = Layout::default() 27 | .direction(Direction::Horizontal) 28 | .constraints( 29 | [ 30 | Constraint::Percentage(50), // Tabs 31 | Constraint::Percentage(50), // Content Area 32 | ] 33 | .as_ref(), 34 | ) 35 | .split(chunks[2]); 36 | 37 | // Tabs 38 | let tab_titles = AppTab::iter(); 39 | let tabs = Tabs::new( 40 | tab_titles 41 | .map(|t| t.as_ref().to_string()) 42 | .map(Span::from) 43 | .collect:: >(), 44 | ) 45 | .block(Block::default().borders(Borders::ALL).title(" Tabs ")) 46 | .highlight_style( 47 | Style::default() 48 | .fg(Color::Yellow) 49 | .add_modifier(Modifier::BOLD), 50 | ) 51 | .select(AppTab::iter().position(|t| t == app.selected_tab).unwrap()); 52 | 53 | f.render_widget(tabs, chunks[0]); 54 | 55 | f.render_widget( 56 | render_local_keymap(vec![ 57 | "( q / Esc ) : Quit", 58 | "( ← / → ) : Navigate tabs", 59 | "( F1 ) : Apply configurations", 60 | ]), 61 | keymap_chunks[0], 62 | ); 63 | 64 | // Content Area 65 | match app.selected_tab { 66 | AppTab::Mode => { 67 | render_mode_tab(f, chunks[1], app); 68 | } 69 | AppTab::Config => render_config_tab(app, f, chunks[1], keymap_chunks[1]), 70 | AppTab::Playback => render_playback_tab(app, f, chunks[1], keymap_chunks[1]), 71 | } 72 | } 73 | 74 | fn render_mode_tab(f: &mut Frame, area: ratatui::layout::Rect, app: &App) { 75 | let items: Vec = ModeOption::iter() 76 | .map(|mode| { 77 | let style = if mode == app.selected_mode { 78 | Style::default() 79 | .fg(Color::Yellow) 80 | .add_modifier(Modifier::BOLD) 81 | } else { 82 | Style::default() 83 | }; 84 | ListItem::new(Span::styled(mode.as_ref().to_string(), style)) 85 | }) 86 | .collect(); 87 | 88 | let list = List::new(items) 89 | .block( 90 | Block::default() 91 | .borders(Borders::ALL) 92 | .title(" Select Mode "), 93 | ) 94 | .highlight_style( 95 | Style::default() 96 | .fg(Color::Yellow) 97 | .add_modifier(Modifier::BOLD), 98 | ); 99 | 100 | f.render_widget(list, area); 101 | } 102 | 103 | fn render_config_tab(app: &App, f: &mut Frame, area: ratatui::layout::Rect, keymap_chunks: Rect) { 104 | match app.selected_mode { 105 | ModeOption::Fourths => render_fourths_config(f, area, app, keymap_chunks), 106 | ModeOption::Random => render_random_config(f, area, app, keymap_chunks), 107 | ModeOption::Custom => render_custom_config(f, area, app, keymap_chunks), 108 | ModeOption::Diatonic => render_diatonic_config(f, area, app, keymap_chunks), 109 | } 110 | } 111 | 112 | fn render_diatonic_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 113 | let all_roots = generate_all_roots(); 114 | f.render_widget( 115 | render_local_keymap(vec![ 116 | "( ) : Select mode", 117 | "( ↑ / ↓ ) : Select root major", 118 | ]), 119 | keymap_chunks, 120 | ); 121 | let items: Vec = DiatonicOption::iter() 122 | .map(|mode| { 123 | let style = if mode == app.config_state.diatonic_option { 124 | Style::default() 125 | .fg(Color::Yellow) 126 | .add_modifier(Modifier::BOLD) 127 | } else { 128 | Style::default() 129 | }; 130 | ListItem::new(Span::styled(mode.as_ref().to_string(), style)) 131 | }) 132 | .collect(); 133 | 134 | let roots: Vec = all_roots 135 | .iter() 136 | .map(|note| { 137 | let style = if note == &app.config_state.diatonic_root { 138 | Style::default() 139 | .fg(Color::Yellow) 140 | .add_modifier(Modifier::BOLD) 141 | } else { 142 | Style::default() 143 | }; 144 | ListItem::new(Span::styled(format!("{}", note), style)) 145 | }) 146 | .collect(); 147 | 148 | let chunks = Layout::default() 149 | .direction(Direction::Vertical) 150 | .constraints([Constraint::Length(5), Constraint::Min(10)].as_ref()) 151 | .split(area); 152 | 153 | let list = List::new(items) 154 | .block( 155 | Block::default() 156 | .borders(Borders::ALL) 157 | .title(" Select Diatonic Progression "), 158 | ) 159 | .highlight_style( 160 | Style::default() 161 | .fg(Color::Yellow) 162 | .add_modifier(Modifier::BOLD), 163 | ); 164 | 165 | let root_list = List::new(roots) 166 | .block( 167 | Block::default() 168 | .borders(Borders::ALL) 169 | .title(" Select Root "), 170 | ) 171 | .highlight_style( 172 | Style::default() 173 | .fg(Color::Yellow) 174 | .add_modifier(Modifier::BOLD), 175 | ); 176 | 177 | f.render_widget(list, chunks[0]); 178 | f.render_widget(root_list, chunks[1]); 179 | } 180 | 181 | fn render_custom_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 182 | f.render_widget( 183 | render_local_keymap(vec!["( Enter ) : Parse input"]), 184 | keymap_chunks, 185 | ); 186 | let chunks = Layout::default() 187 | .direction(Direction::Vertical) 188 | .constraints([Constraint::Length(3), Constraint::Min(10)].as_ref()) 189 | .split(area); 190 | 191 | let input = Paragraph::new( 192 | Line::from(app.custom_input_buffer.clone()).style(Style::default().fg(Color::White)), 193 | ) 194 | .block( 195 | Block::default() 196 | .border_type(BorderType::Rounded) 197 | .borders(Borders::ALL) 198 | .title(" Input "), 199 | ); 200 | 201 | let progression = match app.config_state.progression.clone() { 202 | Some(progression) => progression 203 | .chords 204 | .iter() 205 | .flat_map(|c| { 206 | vec![ 207 | Span::from(c.bars.to_string()).style(Style::default().fg(Color::Yellow)), 208 | Span::from("x"), 209 | Span::from(c.chord.to_string().to_string()).style( 210 | Style::default() 211 | .fg(Color::Yellow) 212 | .add_modifier(Modifier::BOLD), 213 | ), 214 | Span::from(" "), 215 | ] 216 | }) 217 | .collect:: >(), 218 | None => { 219 | vec![Span::from("Invalid progression".to_string()) 220 | .style(Style::default().fg(Color::Red))] 221 | } 222 | }; 223 | 224 | let progression_block = Paragraph::new(Line::from(progression.clone())) 225 | .alignment(Alignment::Center) 226 | .block( 227 | Block::default() 228 | .borders(Borders::ALL) 229 | .title(" Parsed Progression "), 230 | ); 231 | 232 | f.render_widget(input, chunks[0]); 233 | f.render_widget(progression_block, chunks[1]); 234 | } 235 | 236 | fn render_random_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 237 | f.render_widget( 238 | render_local_keymap(vec![ 239 | "( ↑ / ↓ ) : Select quality", 240 | "( ) : Toggle selection", 241 | ]), 242 | keymap_chunks, 243 | ); 244 | let items: Vec = Quality::iter() 245 | .map(|quality| { 246 | let prefix = if app 247 | .config_state 248 | .random_selected_qualities 249 | .contains(&quality) 250 | { 251 | "[✔]" 252 | } else { 253 | "[ ]" 254 | }; 255 | let style = if quality == app.random_qualities_cursor { 256 | Style::default() 257 | .fg(Color::Yellow) 258 | .add_modifier(Modifier::BOLD) 259 | } else { 260 | Style::default() 261 | }; 262 | ListItem::new(Span::styled( 263 | format!("{} {}", prefix, quality.name()), 264 | style, 265 | )) 266 | }) 267 | .collect(); 268 | 269 | let list = List::new(items) 270 | .block( 271 | Block::default() 272 | .borders(Borders::ALL) 273 | .title(" Select Qualities "), 274 | ) 275 | .highlight_style( 276 | Style::default() 277 | .fg(Color::Yellow) 278 | .add_modifier(Modifier::BOLD), 279 | ); 280 | 281 | f.render_widget(list, area); 282 | } 283 | 284 | fn render_local_keymap(lst: Vec<&str>) -> List<'_> { 285 | let local_keys: Vec = lst.iter().map(|&key| ListItem::new(key)).collect(); 286 | let local_key_list = List::new(local_keys) 287 | .block(Block::default().borders(Borders::ALL).title(" KeyMap ")) 288 | .style(Style::default().fg(Color::Yellow)); 289 | local_key_list 290 | } 291 | 292 | fn render_fourths_config(f: &mut Frame, area: Rect, app: &App, keymap_chunks: Rect) { 293 | f.render_widget( 294 | render_local_keymap(vec!["( ↑ / ↓ ) : Select quality"]), 295 | keymap_chunks, 296 | ); 297 | 298 | let items: Vec = Quality::iter() 299 | .map(|selection| { 300 | let style = if selection == app.config_state.fourths_selected_quality { 301 | Style::default() 302 | .fg(Color::Yellow) 303 | .add_modifier(Modifier::BOLD) 304 | } else { 305 | Style::default() 306 | }; 307 | ListItem::new(Span::styled(selection.name(), style)) 308 | }) 309 | .collect(); 310 | 311 | let list = List::new(items) 312 | .block( 313 | Block::default() 314 | .borders(Borders::ALL) 315 | .title(" Select Quality "), 316 | ) 317 | .highlight_style( 318 | Style::default() 319 | .fg(Color::Yellow) 320 | .add_modifier(Modifier::BOLD), 321 | ); 322 | 323 | f.render_widget(list, area); 324 | } 325 | 326 | fn render_playback_tab(app: &App, f: &mut Frame, area: ratatui::layout::Rect, keymap_chunks: Rect) { 327 | f.render_widget( 328 | render_local_keymap(vec![ 329 | "( ↑ / ↓ ) : Increase / Decrease BPM with 2", 330 | ("( r ) : Restart"), 331 | ]), 332 | keymap_chunks, 333 | ); 334 | 335 | let chunks = Layout::default() 336 | .direction(Direction::Vertical) 337 | .constraints( 338 | [ 339 | Constraint::Length(6), 340 | Constraint::Length(6), 341 | Constraint::Min(10), 342 | ] 343 | .as_ref(), 344 | ) 345 | .split(area); 346 | 347 | let metronome_display = generate_metronome_display(app); 348 | 349 | let debug_text = format!("Bar: {} Beat: {}", app.current_bar, app.current_tick); 350 | 351 | let metronome_paragraph = Paragraph::new(vec![ 352 | Line::from(vec![ 353 | Span::styled("Speed: ".to_string(), Style::default()), 354 | Span::styled(format!("{} ", app.bpm), Style::default().fg(Color::Yellow)), 355 | Span::styled("BPM".to_string(), Style::default()), 356 | ]) 357 | .alignment(Alignment::Left), 358 | Line::from(""), 359 | Line::from(metronome_display).alignment(Alignment::Center), 360 | #[cfg(debug_assertions)] 361 | Line::from(debug_text), 362 | ]) 363 | .block( 364 | Block::default() 365 | .borders(Borders::ALL) 366 | .title(" Metronome ") 367 | .border_type(BorderType::Rounded) 368 | .title_alignment(Alignment::Center), 369 | ); 370 | 371 | f.render_widget(metronome_paragraph, chunks[0]); 372 | 373 | let current_chord = Line::from(Span::styled( 374 | format!("Current chord: {}", app.practice_state.current_chord), 375 | Style::default().fg(Color::Cyan), 376 | )); 377 | 378 | let next_chord = Line::from(Span::styled( 379 | format!("Next chord: {}", app.practice_state.next_chord), 380 | Style::default(), 381 | )); 382 | 383 | let chord_paragraph = Paragraph::new(vec![ 384 | Line::from(vec![ 385 | Span::styled("Mode: ".to_string(), Style::default()), 386 | Span::styled( 387 | format!("{}", app.practice_state.mode), 388 | Style::default().fg(Color::Yellow), 389 | ), 390 | ]), 391 | Line::from(""), 392 | current_chord, 393 | next_chord, 394 | ]) 395 | .block( 396 | Block::default() 397 | .borders(Borders::ALL) 398 | .title("🎵 Chords 🎵") 399 | .title_alignment(Alignment::Center), 400 | ) 401 | .alignment(Alignment::Left); 402 | 403 | f.render_widget(chord_paragraph, chunks[1]); 404 | } 405 | 406 | fn generate_metronome_display(app: &App) -> String { 407 | let mut metronome_display = String::new(); 408 | 409 | for bar in 0..app.bars_per_chord { 410 | if bar > 0 { 411 | metronome_display.push_str(" | "); // Separate bars 412 | } 413 | for tick in 0..app.ticks_per_bar { 414 | if bar < app.current_bar || (bar == app.current_bar && tick <= app.current_tick) { 415 | metronome_display.push('⬛'); 416 | } else { 417 | metronome_display.push('⬜'); 418 | } 419 | } 420 | } 421 | 422 | metronome_display 423 | } 424 | -------------------------------------------------------------------------------- /chordflow_desktop/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { 2 | --tw-border-spacing-x: 0; 3 | --tw-border-spacing-y: 0; 4 | --tw-translate-x: 0; 5 | --tw-translate-y: 0; 6 | --tw-rotate: 0; 7 | --tw-skew-x: 0; 8 | --tw-skew-y: 0; 9 | --tw-scale-x: 1; 10 | --tw-scale-y: 1; 11 | --tw-pan-x: ; 12 | --tw-pan-y: ; 13 | --tw-pinch-zoom: ; 14 | --tw-scroll-snap-strictness: proximity; 15 | --tw-gradient-from-position: ; 16 | --tw-gradient-via-position: ; 17 | --tw-gradient-to-position: ; 18 | --tw-ordinal: ; 19 | --tw-slashed-zero: ; 20 | --tw-numeric-figure: ; 21 | --tw-numeric-spacing: ; 22 | --tw-numeric-fraction: ; 23 | --tw-ring-inset: ; 24 | --tw-ring-offset-width: 0px; 25 | --tw-ring-offset-color: #fff; 26 | --tw-ring-color: rgb(59 130 246 / 0.5); 27 | --tw-ring-offset-shadow: 0 0 #0000; 28 | --tw-ring-shadow: 0 0 #0000; 29 | --tw-shadow: 0 0 #0000; 30 | --tw-shadow-colored: 0 0 #0000; 31 | --tw-blur: ; 32 | --tw-brightness: ; 33 | --tw-contrast: ; 34 | --tw-grayscale: ; 35 | --tw-hue-rotate: ; 36 | --tw-invert: ; 37 | --tw-saturate: ; 38 | --tw-sepia: ; 39 | --tw-drop-shadow: ; 40 | --tw-backdrop-blur: ; 41 | --tw-backdrop-brightness: ; 42 | --tw-backdrop-contrast: ; 43 | --tw-backdrop-grayscale: ; 44 | --tw-backdrop-hue-rotate: ; 45 | --tw-backdrop-invert: ; 46 | --tw-backdrop-opacity: ; 47 | --tw-backdrop-saturate: ; 48 | --tw-backdrop-sepia: ; 49 | --tw-contain-size: ; 50 | --tw-contain-layout: ; 51 | --tw-contain-paint: ; 52 | --tw-contain-style: ; 53 | } 54 | 55 | ::backdrop { 56 | --tw-border-spacing-x: 0; 57 | --tw-border-spacing-y: 0; 58 | --tw-translate-x: 0; 59 | --tw-translate-y: 0; 60 | --tw-rotate: 0; 61 | --tw-skew-x: 0; 62 | --tw-skew-y: 0; 63 | --tw-scale-x: 1; 64 | --tw-scale-y: 1; 65 | --tw-pan-x: ; 66 | --tw-pan-y: ; 67 | --tw-pinch-zoom: ; 68 | --tw-scroll-snap-strictness: proximity; 69 | --tw-gradient-from-position: ; 70 | --tw-gradient-via-position: ; 71 | --tw-gradient-to-position: ; 72 | --tw-ordinal: ; 73 | --tw-slashed-zero: ; 74 | --tw-numeric-figure: ; 75 | --tw-numeric-spacing: ; 76 | --tw-numeric-fraction: ; 77 | --tw-ring-inset: ; 78 | --tw-ring-offset-width: 0px; 79 | --tw-ring-offset-color: #fff; 80 | --tw-ring-color: rgb(59 130 246 / 0.5); 81 | --tw-ring-offset-shadow: 0 0 #0000; 82 | --tw-ring-shadow: 0 0 #0000; 83 | --tw-shadow: 0 0 #0000; 84 | --tw-shadow-colored: 0 0 #0000; 85 | --tw-blur: ; 86 | --tw-brightness: ; 87 | --tw-contrast: ; 88 | --tw-grayscale: ; 89 | --tw-hue-rotate: ; 90 | --tw-invert: ; 91 | --tw-saturate: ; 92 | --tw-sepia: ; 93 | --tw-drop-shadow: ; 94 | --tw-backdrop-blur: ; 95 | --tw-backdrop-brightness: ; 96 | --tw-backdrop-contrast: ; 97 | --tw-backdrop-grayscale: ; 98 | --tw-backdrop-hue-rotate: ; 99 | --tw-backdrop-invert: ; 100 | --tw-backdrop-opacity: ; 101 | --tw-backdrop-saturate: ; 102 | --tw-backdrop-sepia: ; 103 | --tw-contain-size: ; 104 | --tw-contain-layout: ; 105 | --tw-contain-paint: ; 106 | --tw-contain-style: ; 107 | } 108 | 109 | /* 110 | ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com 111 | */ 112 | 113 | /* 114 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 115 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 116 | */ 117 | 118 | *, 119 | ::before, 120 | ::after { 121 | box-sizing: border-box; 122 | /* 1 */ 123 | border-width: 0; 124 | /* 2 */ 125 | border-style: solid; 126 | /* 2 */ 127 | border-color: #e5e7eb; 128 | /* 2 */ 129 | } 130 | 131 | ::before, 132 | ::after { 133 | --tw-content: ''; 134 | } 135 | 136 | /* 137 | 1. Use a consistent sensible line-height in all browsers. 138 | 2. Prevent adjustments of font size after orientation changes in iOS. 139 | 3. Use a more readable tab size. 140 | 4. Use the user's configured `sans` font-family by default. 141 | 5. Use the user's configured `sans` font-feature-settings by default. 142 | 6. Use the user's configured `sans` font-variation-settings by default. 143 | 7. Disable tap highlights on iOS 144 | */ 145 | 146 | html, 147 | :host { 148 | line-height: 1.5; 149 | /* 1 */ 150 | -webkit-text-size-adjust: 100%; 151 | /* 2 */ 152 | -moz-tab-size: 4; 153 | /* 3 */ 154 | -o-tab-size: 4; 155 | tab-size: 4; 156 | /* 3 */ 157 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 158 | /* 4 */ 159 | font-feature-settings: normal; 160 | /* 5 */ 161 | font-variation-settings: normal; 162 | /* 6 */ 163 | -webkit-tap-highlight-color: transparent; 164 | /* 7 */ 165 | } 166 | 167 | /* 168 | 1. Remove the margin in all browsers. 169 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 170 | */ 171 | 172 | body { 173 | margin: 0; 174 | /* 1 */ 175 | line-height: inherit; 176 | /* 2 */ 177 | } 178 | 179 | /* 180 | 1. Add the correct height in Firefox. 181 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 182 | 3. Ensure horizontal rules are visible by default. 183 | */ 184 | 185 | hr { 186 | height: 0; 187 | /* 1 */ 188 | color: inherit; 189 | /* 2 */ 190 | border-top-width: 1px; 191 | /* 3 */ 192 | } 193 | 194 | /* 195 | Add the correct text decoration in Chrome, Edge, and Safari. 196 | */ 197 | 198 | abbr:where([title]) { 199 | -webkit-text-decoration: underline dotted; 200 | text-decoration: underline dotted; 201 | } 202 | 203 | /* 204 | Remove the default font size and weight for headings. 205 | */ 206 | 207 | h1, 208 | h2, 209 | h3, 210 | h4, 211 | h5, 212 | h6 { 213 | font-size: inherit; 214 | font-weight: inherit; 215 | } 216 | 217 | /* 218 | Reset links to optimize for opt-in styling instead of opt-out. 219 | */ 220 | 221 | a { 222 | color: inherit; 223 | text-decoration: inherit; 224 | } 225 | 226 | /* 227 | Add the correct font weight in Edge and Safari. 228 | */ 229 | 230 | b, 231 | strong { 232 | font-weight: bolder; 233 | } 234 | 235 | /* 236 | 1. Use the user's configured `mono` font-family by default. 237 | 2. Use the user's configured `mono` font-feature-settings by default. 238 | 3. Use the user's configured `mono` font-variation-settings by default. 239 | 4. Correct the odd `em` font sizing in all browsers. 240 | */ 241 | 242 | code, 243 | kbd, 244 | samp, 245 | pre { 246 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 247 | /* 1 */ 248 | font-feature-settings: normal; 249 | /* 2 */ 250 | font-variation-settings: normal; 251 | /* 3 */ 252 | font-size: 1em; 253 | /* 4 */ 254 | } 255 | 256 | /* 257 | Add the correct font size in all browsers. 258 | */ 259 | 260 | small { 261 | font-size: 80%; 262 | } 263 | 264 | /* 265 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 266 | */ 267 | 268 | sub, 269 | sup { 270 | font-size: 75%; 271 | line-height: 0; 272 | position: relative; 273 | vertical-align: baseline; 274 | } 275 | 276 | sub { 277 | bottom: -0.25em; 278 | } 279 | 280 | sup { 281 | top: -0.5em; 282 | } 283 | 284 | /* 285 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 286 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 287 | 3. Remove gaps between table borders by default. 288 | */ 289 | 290 | table { 291 | text-indent: 0; 292 | /* 1 */ 293 | border-color: inherit; 294 | /* 2 */ 295 | border-collapse: collapse; 296 | /* 3 */ 297 | } 298 | 299 | /* 300 | 1. Change the font styles in all browsers. 301 | 2. Remove the margin in Firefox and Safari. 302 | 3. Remove default padding in all browsers. 303 | */ 304 | 305 | button, 306 | input, 307 | optgroup, 308 | select, 309 | textarea { 310 | font-family: inherit; 311 | /* 1 */ 312 | font-feature-settings: inherit; 313 | /* 1 */ 314 | font-variation-settings: inherit; 315 | /* 1 */ 316 | font-size: 100%; 317 | /* 1 */ 318 | font-weight: inherit; 319 | /* 1 */ 320 | line-height: inherit; 321 | /* 1 */ 322 | letter-spacing: inherit; 323 | /* 1 */ 324 | color: inherit; 325 | /* 1 */ 326 | margin: 0; 327 | /* 2 */ 328 | padding: 0; 329 | /* 3 */ 330 | } 331 | 332 | /* 333 | Remove the inheritance of text transform in Edge and Firefox. 334 | */ 335 | 336 | button, 337 | select { 338 | text-transform: none; 339 | } 340 | 341 | /* 342 | 1. Correct the inability to style clickable types in iOS and Safari. 343 | 2. Remove default button styles. 344 | */ 345 | 346 | button, 347 | input:where([type='button']), 348 | input:where([type='reset']), 349 | input:where([type='submit']) { 350 | -webkit-appearance: button; 351 | /* 1 */ 352 | background-color: transparent; 353 | /* 2 */ 354 | background-image: none; 355 | /* 2 */ 356 | } 357 | 358 | /* 359 | Use the modern Firefox focus style for all focusable elements. 360 | */ 361 | 362 | :-moz-focusring { 363 | outline: auto; 364 | } 365 | 366 | /* 367 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 368 | */ 369 | 370 | :-moz-ui-invalid { 371 | box-shadow: none; 372 | } 373 | 374 | /* 375 | Add the correct vertical alignment in Chrome and Firefox. 376 | */ 377 | 378 | progress { 379 | vertical-align: baseline; 380 | } 381 | 382 | /* 383 | Correct the cursor style of increment and decrement buttons in Safari. 384 | */ 385 | 386 | ::-webkit-inner-spin-button, 387 | ::-webkit-outer-spin-button { 388 | height: auto; 389 | } 390 | 391 | /* 392 | 1. Correct the odd appearance in Chrome and Safari. 393 | 2. Correct the outline style in Safari. 394 | */ 395 | 396 | [type='search'] { 397 | -webkit-appearance: textfield; 398 | /* 1 */ 399 | outline-offset: -2px; 400 | /* 2 */ 401 | } 402 | 403 | /* 404 | Remove the inner padding in Chrome and Safari on macOS. 405 | */ 406 | 407 | ::-webkit-search-decoration { 408 | -webkit-appearance: none; 409 | } 410 | 411 | /* 412 | 1. Correct the inability to style clickable types in iOS and Safari. 413 | 2. Change font properties to `inherit` in Safari. 414 | */ 415 | 416 | ::-webkit-file-upload-button { 417 | -webkit-appearance: button; 418 | /* 1 */ 419 | font: inherit; 420 | /* 2 */ 421 | } 422 | 423 | /* 424 | Add the correct display in Chrome and Safari. 425 | */ 426 | 427 | summary { 428 | display: list-item; 429 | } 430 | 431 | /* 432 | Removes the default spacing and border for appropriate elements. 433 | */ 434 | 435 | blockquote, 436 | dl, 437 | dd, 438 | h1, 439 | h2, 440 | h3, 441 | h4, 442 | h5, 443 | h6, 444 | hr, 445 | figure, 446 | p, 447 | pre { 448 | margin: 0; 449 | } 450 | 451 | fieldset { 452 | margin: 0; 453 | padding: 0; 454 | } 455 | 456 | legend { 457 | padding: 0; 458 | } 459 | 460 | ol, 461 | ul, 462 | menu { 463 | list-style: none; 464 | margin: 0; 465 | padding: 0; 466 | } 467 | 468 | /* 469 | Reset default styling for dialogs. 470 | */ 471 | 472 | dialog { 473 | padding: 0; 474 | } 475 | 476 | /* 477 | Prevent resizing textareas horizontally by default. 478 | */ 479 | 480 | textarea { 481 | resize: vertical; 482 | } 483 | 484 | /* 485 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 486 | 2. Set the default placeholder color to the user's configured gray 400 color. 487 | */ 488 | 489 | input::-moz-placeholder, textarea::-moz-placeholder { 490 | opacity: 1; 491 | /* 1 */ 492 | color: #9ca3af; 493 | /* 2 */ 494 | } 495 | 496 | input::placeholder, 497 | textarea::placeholder { 498 | opacity: 1; 499 | /* 1 */ 500 | color: #9ca3af; 501 | /* 2 */ 502 | } 503 | 504 | /* 505 | Set the default cursor for buttons. 506 | */ 507 | 508 | button, 509 | [role="button"] { 510 | cursor: pointer; 511 | } 512 | 513 | /* 514 | Make sure disabled buttons don't get the pointer cursor. 515 | */ 516 | 517 | :disabled { 518 | cursor: default; 519 | } 520 | 521 | /* 522 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 523 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 524 | This can trigger a poorly considered lint error in some tools but is included by design. 525 | */ 526 | 527 | img, 528 | svg, 529 | video, 530 | canvas, 531 | audio, 532 | iframe, 533 | embed, 534 | object { 535 | display: block; 536 | /* 1 */ 537 | vertical-align: middle; 538 | /* 2 */ 539 | } 540 | 541 | /* 542 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 543 | */ 544 | 545 | img, 546 | video { 547 | max-width: 100%; 548 | height: auto; 549 | } 550 | 551 | /* Make elements with the HTML hidden attribute stay hidden by default */ 552 | 553 | [hidden]:where(:not([hidden="until-found"])) { 554 | display: none; 555 | } 556 | 557 | .m-2 { 558 | margin: 0.5rem; 559 | } 560 | 561 | .mt-8 { 562 | margin-top: 2rem; 563 | } 564 | 565 | .inline-block { 566 | display: inline-block; 567 | } 568 | 569 | .flex { 570 | display: flex; 571 | } 572 | 573 | .hidden { 574 | display: none; 575 | } 576 | 577 | .h-8 { 578 | height: 2rem; 579 | } 580 | 581 | .h-9 { 582 | height: 2.25rem; 583 | } 584 | 585 | .h-screen { 586 | height: 100vh; 587 | } 588 | 589 | .w-60 { 590 | width: 15rem; 591 | } 592 | 593 | .w-8 { 594 | width: 2rem; 595 | } 596 | 597 | .w-full { 598 | width: 100%; 599 | } 600 | 601 | .w-screen { 602 | width: 100vw; 603 | } 604 | 605 | .flex-1 { 606 | flex: 1 1 0%; 607 | } 608 | 609 | .cursor-not-allowed { 610 | cursor: not-allowed; 611 | } 612 | 613 | .flex-col { 614 | flex-direction: column; 615 | } 616 | 617 | .items-center { 618 | align-items: center; 619 | } 620 | 621 | .justify-center { 622 | justify-content: center; 623 | } 624 | 625 | .gap-2 { 626 | gap: 0.5rem; 627 | } 628 | 629 | .space-x-1 > :not([hidden]) ~ :not([hidden]) { 630 | --tw-space-x-reverse: 0; 631 | margin-right: calc(0.25rem * var(--tw-space-x-reverse)); 632 | margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); 633 | } 634 | 635 | .space-x-2 > :not([hidden]) ~ :not([hidden]) { 636 | --tw-space-x-reverse: 0; 637 | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); 638 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); 639 | } 640 | 641 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 642 | --tw-space-x-reverse: 0; 643 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 644 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 645 | } 646 | 647 | .space-y-1 > :not([hidden]) ~ :not([hidden]) { 648 | --tw-space-y-reverse: 0; 649 | margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); 650 | margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); 651 | } 652 | 653 | .space-y-2 > :not([hidden]) ~ :not([hidden]) { 654 | --tw-space-y-reverse: 0; 655 | margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); 656 | margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); 657 | } 658 | 659 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { 660 | --tw-space-y-reverse: 0; 661 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 662 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 663 | } 664 | 665 | .rounded-full { 666 | border-radius: 9999px; 667 | } 668 | 669 | .rounded-md { 670 | border-radius: 0.375rem; 671 | } 672 | 673 | .border-\[1px\] { 674 | border-width: 1px; 675 | } 676 | 677 | .border-tokyoNight-comment { 678 | --tw-border-opacity: 1; 679 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 680 | } 681 | 682 | .bg-tokyoNight-bg { 683 | --tw-bg-opacity: 1; 684 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 685 | } 686 | 687 | .bg-tokyoNight-bg_highlight\/70 { 688 | background-color: rgb(41 46 66 / 0.7); 689 | } 690 | 691 | .bg-tokyoNight-blue { 692 | --tw-bg-opacity: 1; 693 | background-color: rgb(122 162 247 / var(--tw-bg-opacity, 1)); 694 | } 695 | 696 | .bg-tokyoNight-fg_dark { 697 | --tw-bg-opacity: 1; 698 | background-color: rgb(169 177 214 / var(--tw-bg-opacity, 1)); 699 | } 700 | 701 | .bg-tokyoNight-orange { 702 | --tw-bg-opacity: 1; 703 | background-color: rgb(255 158 100 / var(--tw-bg-opacity, 1)); 704 | } 705 | 706 | .p-2 { 707 | padding: 0.5rem; 708 | } 709 | 710 | .p-3 { 711 | padding: 0.75rem; 712 | } 713 | 714 | .p-4 { 715 | padding: 1rem; 716 | } 717 | 718 | .align-middle { 719 | vertical-align: middle; 720 | } 721 | 722 | .font-mono { 723 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 724 | } 725 | 726 | .font-sans { 727 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 728 | } 729 | 730 | .text-2xl { 731 | font-size: 1.5rem; 732 | line-height: 2rem; 733 | } 734 | 735 | .text-3xl { 736 | font-size: 1.875rem; 737 | line-height: 2.25rem; 738 | } 739 | 740 | .text-4xl { 741 | font-size: 2.25rem; 742 | line-height: 2.5rem; 743 | } 744 | 745 | .text-5xl { 746 | font-size: 3rem; 747 | line-height: 1; 748 | } 749 | 750 | .text-8xl { 751 | font-size: 6rem; 752 | line-height: 1; 753 | } 754 | 755 | .text-lg { 756 | font-size: 1.125rem; 757 | line-height: 1.75rem; 758 | } 759 | 760 | .text-sm { 761 | font-size: 0.875rem; 762 | line-height: 1.25rem; 763 | } 764 | 765 | .text-xl { 766 | font-size: 1.25rem; 767 | line-height: 1.75rem; 768 | } 769 | 770 | .font-bold { 771 | font-weight: 700; 772 | } 773 | 774 | .font-semibold { 775 | font-weight: 600; 776 | } 777 | 778 | .tracking-wide { 779 | letter-spacing: 0.025em; 780 | } 781 | 782 | .text-tokyoNight-blue { 783 | --tw-text-opacity: 1; 784 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 785 | } 786 | 787 | .text-tokyoNight-fg { 788 | --tw-text-opacity: 1; 789 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 790 | } 791 | 792 | .text-tokyoNight-magenta { 793 | --tw-text-opacity: 1; 794 | color: rgb(187 154 247 / var(--tw-text-opacity, 1)); 795 | } 796 | 797 | .text-tokyoNight-magenta2 { 798 | --tw-text-opacity: 1; 799 | color: rgb(255 0 124 / var(--tw-text-opacity, 1)); 800 | } 801 | 802 | .text-tokyoNight-orange { 803 | --tw-text-opacity: 1; 804 | color: rgb(255 158 100 / var(--tw-text-opacity, 1)); 805 | } 806 | 807 | .text-tokyoNight-teal { 808 | --tw-text-opacity: 1; 809 | color: rgb(26 188 156 / var(--tw-text-opacity, 1)); 810 | } 811 | 812 | .text-tokyoNight-yellow { 813 | --tw-text-opacity: 1; 814 | color: rgb(224 175 104 / var(--tw-text-opacity, 1)); 815 | } 816 | 817 | .shadow-lg { 818 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 819 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 820 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 821 | } 822 | 823 | .transition-colors { 824 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 825 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 826 | transition-duration: 150ms; 827 | } 828 | 829 | .button { 830 | border-radius: 0.375rem; 831 | border-width: 1px; 832 | --tw-border-opacity: 1; 833 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 834 | padding: 0.5rem; 835 | --tw-text-opacity: 1; 836 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 837 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 838 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 839 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 840 | transition-property: all; 841 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 842 | transition-duration: 150ms; 843 | } 844 | 845 | .button:hover { 846 | --tw-border-opacity: 1; 847 | border-color: rgb(26 27 38 / var(--tw-border-opacity, 1)); 848 | --tw-bg-opacity: 1; 849 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 850 | --tw-text-opacity: 1; 851 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 852 | } 853 | 854 | .button:active { 855 | --tw-shadow: 0 0 #0000; 856 | --tw-shadow-colored: 0 0 #0000; 857 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 858 | } 859 | 860 | .selected-button { 861 | border-radius: 0.375rem; 862 | --tw-bg-opacity: 1; 863 | background-color: rgb(86 95 137 / var(--tw-bg-opacity, 1)); 864 | padding: 0.5rem; 865 | font-weight: 600; 866 | --tw-text-opacity: 1; 867 | color: rgb(22 22 30 / var(--tw-text-opacity, 1)); 868 | --tw-shadow: 0 0 #0000; 869 | --tw-shadow-colored: 0 0 #0000; 870 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 871 | transition-property: all; 872 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 873 | transition-duration: 150ms; 874 | } 875 | 876 | .select { 877 | border-radius: 0.375rem; 878 | border-width: 1px; 879 | --tw-border-opacity: 1; 880 | border-color: rgb(86 95 137 / var(--tw-border-opacity, 1)); 881 | --tw-bg-opacity: 1; 882 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 883 | padding: 0.5rem; 884 | --tw-text-opacity: 1; 885 | color: rgb(122 162 247 / var(--tw-text-opacity, 1)); 886 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 887 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 888 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 889 | transition-property: all; 890 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 891 | transition-duration: 150ms; 892 | } 893 | 894 | .select:hover { 895 | --tw-border-opacity: 1; 896 | border-color: rgb(26 27 38 / var(--tw-border-opacity, 1)); 897 | --tw-bg-opacity: 1; 898 | background-color: rgb(26 27 38 / var(--tw-bg-opacity, 1)); 899 | --tw-text-opacity: 1; 900 | color: rgb(192 202 245 / var(--tw-text-opacity, 1)); 901 | } 902 | 903 | .select:active { 904 | --tw-shadow: 0 0 #0000; 905 | --tw-shadow-colored: 0 0 #0000; 906 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 907 | } 908 | 909 | @media (min-width: 768px) { 910 | .md\:block { 911 | display: block; 912 | } 913 | 914 | .md\:w-60 { 915 | width: 15rem; 916 | } 917 | 918 | .md\:space-y-4 > :not([hidden]) ~ :not([hidden]) { 919 | --tw-space-y-reverse: 0; 920 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 921 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 922 | } 923 | } 924 | 925 | @media (min-width: 1024px) { 926 | .lg\:flex { 927 | display: flex; 928 | } 929 | } 930 | --------------------------------------------------------------------------------