some sort of ui
This commit is contained in:
136
crates/ui/src/app.rs
Normal file
136
crates/ui/src/app.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use params::{ParamId, ParamStore};
|
||||
use vizia::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Data)]
|
||||
pub enum Panel {
|
||||
Osc,
|
||||
Env,
|
||||
Lfo,
|
||||
Filter,
|
||||
Fx,
|
||||
ModMatrix,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Osc,
|
||||
Self::Env,
|
||||
Self::Lfo,
|
||||
Self::Filter,
|
||||
Self::Fx,
|
||||
Self::ModMatrix,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Osc => "OSC",
|
||||
Self::Env => "ENV",
|
||||
Self::Lfo => "LFO",
|
||||
Self::Filter => "FLT",
|
||||
Self::Fx => "FX",
|
||||
Self::ModMatrix => "MOD",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Lens, Clone)]
|
||||
pub struct AppData {
|
||||
pub params: Vec<f32>,
|
||||
pub active_panel: Panel,
|
||||
pub preset_name: String,
|
||||
pub voice_count: u8,
|
||||
pub cpu_load: f32,
|
||||
pub host_bpm: f32,
|
||||
#[lens(ignore)]
|
||||
pub store: ParamStore,
|
||||
}
|
||||
|
||||
impl AppData {
|
||||
pub fn new(store: ParamStore) -> Self {
|
||||
let params = (0..ParamId::COUNT)
|
||||
.map(|i| store.get(unsafe { std::mem::transmute::<usize, ParamId>(i) }))
|
||||
.collect();
|
||||
Self {
|
||||
params,
|
||||
store,
|
||||
active_panel: Panel::Osc,
|
||||
preset_name: "Init".into(),
|
||||
voice_count: 0,
|
||||
cpu_load: 0.0,
|
||||
host_bpm: 120.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
SetParam(ParamId, f32),
|
||||
SetPanel(Panel),
|
||||
UpdateMetrics { voices: u8, cpu: f32 },
|
||||
}
|
||||
|
||||
impl Model for AppData {
|
||||
fn event(&mut self, _cx: &mut EventContext, event: &mut Event) {
|
||||
event.map(|e: &AppEvent, _| match e {
|
||||
AppEvent::SetParam(id, val) => {
|
||||
let v = val.clamp(0.0, 1.0);
|
||||
self.params[*id as usize] = v;
|
||||
self.store.set(*id, v);
|
||||
}
|
||||
AppEvent::SetPanel(p) => self.active_panel = *p,
|
||||
AppEvent::UpdateMetrics { voices, cpu } => {
|
||||
self.voice_count = *voices;
|
||||
self.cpu_load = *cpu;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_root(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
crate::panels::header::build(cx);
|
||||
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
for &p in Panel::ALL {
|
||||
tab_button(cx, p);
|
||||
}
|
||||
})
|
||||
.width(Pixels(56.0))
|
||||
.height(Stretch(1.0))
|
||||
.background_color(Color::rgb(14, 14, 26));
|
||||
|
||||
Binding::new(cx, AppData::active_panel, |cx, panel_lens| {
|
||||
VStack::new(cx, |cx| match panel_lens.get(cx) {
|
||||
Panel::Osc => crate::panels::osc::build(cx),
|
||||
Panel::Env => crate::panels::env_panel::build(cx),
|
||||
Panel::Lfo => crate::panels::lfo::build(cx),
|
||||
Panel::Filter => crate::panels::filter::build(cx),
|
||||
Panel::Fx => crate::panels::fx::build(cx),
|
||||
Panel::ModMatrix => crate::panels::mod_matrix::build(cx),
|
||||
})
|
||||
.class("panel")
|
||||
.width(Stretch(1.0))
|
||||
.height(Stretch(1.0));
|
||||
});
|
||||
|
||||
crate::panels::macro_bar::build(cx);
|
||||
})
|
||||
.height(Stretch(1.0));
|
||||
})
|
||||
.background_color(Color::rgb(18, 18, 28))
|
||||
.width(Stretch(1.0))
|
||||
.height(Stretch(1.0));
|
||||
}
|
||||
|
||||
fn tab_button(cx: &mut Context, p: Panel) {
|
||||
Binding::new(cx, AppData::active_panel, move |cx, active_lens| {
|
||||
let al = active_lens.get(cx) == p;
|
||||
Label::new(cx, p.label())
|
||||
.class("tab")
|
||||
.checked(al)
|
||||
.on_press(move |cx| cx.emit(AppEvent::SetPanel(p)))
|
||||
.width(Stretch(1.0))
|
||||
.height(Pixels(44.0))
|
||||
.text_align(TextAlign::Center);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
pub mod knob {}
|
||||
pub mod env_disp {}
|
||||
pub mod wave_disp {}
|
||||
pub mod mod_matrix {}
|
||||
pub mod spectrum {}
|
||||
|
||||
pub fn run_ui(_params: params::ParamStore) {
|
||||
unimplemented!("stub")
|
||||
}
|
||||
pub mod app;
|
||||
pub mod panels;
|
||||
pub mod widgets;
|
||||
|
||||
20
crates/ui/src/main.rs
Normal file
20
crates/ui/src/main.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
mod app;
|
||||
mod panels;
|
||||
mod widgets;
|
||||
|
||||
fn main() {
|
||||
use app::AppData;
|
||||
use params::ParamStore;
|
||||
use vizia::prelude::*;
|
||||
|
||||
let _ = Application::new(|cx| {
|
||||
cx.add_stylesheet(include_str!("theme.css"))
|
||||
.expect("theme.css");
|
||||
AppData::new(ParamStore::new()).build(cx);
|
||||
app::build_root(cx);
|
||||
})
|
||||
.title("Tenko")
|
||||
.inner_size((1280, 760))
|
||||
.resizable(false)
|
||||
.run();
|
||||
}
|
||||
69
crates/ui/src/panels/env_panel.rs
Normal file
69
crates/ui/src/panels/env_panel.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::widgets::{env_display::EnvDisplay, knob::labeled_knob};
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
const ENV_KNOBS: [[ParamId; 9]; 3] = [
|
||||
[
|
||||
ParamId::Env1Delay,
|
||||
ParamId::Env1Attack,
|
||||
ParamId::Env1Hold,
|
||||
ParamId::Env1Decay,
|
||||
ParamId::Env1Sustain,
|
||||
ParamId::Env1Release,
|
||||
ParamId::Env1AttackCurve,
|
||||
ParamId::Env1DecayCurve,
|
||||
ParamId::Env1ReleaseCurve,
|
||||
],
|
||||
[
|
||||
ParamId::Env2Delay,
|
||||
ParamId::Env2Attack,
|
||||
ParamId::Env2Hold,
|
||||
ParamId::Env2Decay,
|
||||
ParamId::Env2Sustain,
|
||||
ParamId::Env2Release,
|
||||
ParamId::Env2AttackCurve,
|
||||
ParamId::Env2DecayCurve,
|
||||
ParamId::Env2ReleaseCurve,
|
||||
],
|
||||
[
|
||||
ParamId::Env3Delay,
|
||||
ParamId::Env3Attack,
|
||||
ParamId::Env3Hold,
|
||||
ParamId::Env3Decay,
|
||||
ParamId::Env3Sustain,
|
||||
ParamId::Env3Release,
|
||||
ParamId::Env3AttackCurve,
|
||||
ParamId::Env3DecayCurve,
|
||||
ParamId::Env3ReleaseCurve,
|
||||
],
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "ENVELOPES")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for (i, knobs) in ENV_KNOBS.iter().enumerate() {
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, format!("ENV {}", i + 1)).class("knob-label");
|
||||
EnvDisplay::new(cx, i);
|
||||
})
|
||||
.width(Pixels(218.0));
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in knobs.iter() {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(108.0));
|
||||
}
|
||||
})
|
||||
.vertical_gap(Pixels(8.0))
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
71
crates/ui/src/panels/filter.rs
Normal file
71
crates/ui/src/panels/filter.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::widgets::knob::labeled_knob;
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
const FILTER_KNOBS: [[ParamId; 4]; 2] = [
|
||||
[
|
||||
ParamId::Filter1Cutoff,
|
||||
ParamId::Filter1Resonance,
|
||||
ParamId::Filter1Drive,
|
||||
ParamId::Filter1Keytrack,
|
||||
],
|
||||
[
|
||||
ParamId::Filter2Cutoff,
|
||||
ParamId::Filter2Resonance,
|
||||
ParamId::Filter2Drive,
|
||||
ParamId::Filter2Keytrack,
|
||||
],
|
||||
];
|
||||
const TYPES: [&str; 4] = ["LADDER", "SVF", "COMB", "FORMANT"];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "FILTERS")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
|
||||
HStack::new(cx, |cx| {
|
||||
for m in ["SERIAL", "PARALLEL"] {
|
||||
Label::new(cx, m).class("tab").padding(Pixels(5.0));
|
||||
}
|
||||
})
|
||||
.bottom(Pixels(8.0));
|
||||
|
||||
for (i, knobs) in FILTER_KNOBS.iter().enumerate() {
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, format!("FILTER {}", i + 1)).class("knob-label");
|
||||
HStack::new(cx, |cx| {
|
||||
for t in TYPES {
|
||||
Label::new(cx, t)
|
||||
.class("tab")
|
||||
.padding(Pixels(3.0))
|
||||
.height(Pixels(20.0));
|
||||
}
|
||||
})
|
||||
.top(Pixels(4.0));
|
||||
Element::new(cx)
|
||||
.width(Pixels(170.0))
|
||||
.height(Pixels(52.0))
|
||||
.background_color(Color::from("#12121c"))
|
||||
.corner_radius(Pixels(3.0))
|
||||
.top(Pixels(4.0));
|
||||
})
|
||||
.width(Pixels(178.0));
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in knobs.iter() {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(120.0));
|
||||
}
|
||||
})
|
||||
.vertical_gap(Pixels(8.0))
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
95
crates/ui/src/panels/fx.rs
Normal file
95
crates/ui/src/panels/fx.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crate::widgets::knob::labeled_knob;
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
struct Slot {
|
||||
name: &'static str,
|
||||
params: &'static [ParamId],
|
||||
}
|
||||
|
||||
const CHAIN: &[Slot] = &[
|
||||
Slot {
|
||||
name: "DIST",
|
||||
params: &[ParamId::DistDrive],
|
||||
},
|
||||
Slot {
|
||||
name: "CHORUS",
|
||||
params: &[
|
||||
ParamId::ChorusRate,
|
||||
ParamId::ChorusDepth,
|
||||
ParamId::ChorusMix,
|
||||
],
|
||||
},
|
||||
Slot {
|
||||
name: "PHASER",
|
||||
params: &[
|
||||
ParamId::PhaserRate,
|
||||
ParamId::PhaserDepth,
|
||||
ParamId::PhaserMix,
|
||||
],
|
||||
},
|
||||
Slot {
|
||||
name: "REVERB",
|
||||
params: &[
|
||||
ParamId::ReverbSize,
|
||||
ParamId::ReverbDamping,
|
||||
ParamId::ReverbMix,
|
||||
],
|
||||
},
|
||||
Slot {
|
||||
name: "DELAY",
|
||||
params: &[
|
||||
ParamId::DelayTime,
|
||||
ParamId::DelayFeedback,
|
||||
ParamId::DelayMix,
|
||||
],
|
||||
},
|
||||
Slot {
|
||||
name: "EQ",
|
||||
params: &[
|
||||
ParamId::EqLowGain,
|
||||
ParamId::EqMidFreq,
|
||||
ParamId::EqMidGain,
|
||||
ParamId::EqHighGain,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "FX CHAIN")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for slot in CHAIN {
|
||||
fx_row(cx, slot);
|
||||
}
|
||||
})
|
||||
.vertical_gap(Pixels(5.0))
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
|
||||
fn fx_row(cx: &mut Context, slot: &'static Slot) {
|
||||
HStack::new(cx, |cx| {
|
||||
Element::new(cx)
|
||||
.width(Pixels(10.0))
|
||||
.height(Pixels(10.0))
|
||||
.background_color(Color::from("#ff7830"))
|
||||
.corner_radius(Pixels(5.0));
|
||||
Label::new(cx, slot.name)
|
||||
.color(Color::from("#d8d8e8"))
|
||||
.width(Pixels(55.0))
|
||||
.left(Pixels(8.0));
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in slot.params {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(68.0))
|
||||
.padding_top(Stretch(1.0))
|
||||
.padding_bottom(Stretch(1.0));
|
||||
}
|
||||
38
crates/ui/src/panels/header.rs
Normal file
38
crates/ui/src/panels/header.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::app::AppData;
|
||||
use vizia::prelude::*;
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
HStack::new(cx, |cx| {
|
||||
Label::new(cx, AppData::preset_name)
|
||||
.font_size(13.0)
|
||||
.color(Color::from("#d8d8e8"))
|
||||
.width(Pixels(180.0));
|
||||
|
||||
Element::new(cx).width(Stretch(1.0)).height(Stretch(1.0));
|
||||
|
||||
stat_pair(cx, "BPM", AppData::host_bpm.map(|b| format!("{b:.0}")));
|
||||
stat_pair(cx, "VOICES", AppData::voice_count.map(|v| format!("{v}")));
|
||||
stat_pair(
|
||||
cx,
|
||||
"CPU",
|
||||
AppData::cpu_load.map(|c| format!("{:.0}%", c * 100.0)),
|
||||
);
|
||||
})
|
||||
.height(Pixels(34.0))
|
||||
.background_color(Color::from("#0e0e1a"))
|
||||
.padding(Pixels(8.0))
|
||||
.padding_top(Stretch(1.0))
|
||||
.padding_bottom(Stretch(1.0))
|
||||
.horizontal_gap(Pixels(16.0));
|
||||
}
|
||||
|
||||
fn stat_pair(cx: &mut Context, key: &'static str, val_lens: impl Lens<Target = String>) {
|
||||
HStack::new(cx, |cx| {
|
||||
Label::new(cx, key)
|
||||
.class("knob-label")
|
||||
.color(Color::from("#888899"));
|
||||
Label::new(cx, val_lens)
|
||||
.color(Color::from("#ff7830"))
|
||||
.left(Pixels(4.0));
|
||||
});
|
||||
}
|
||||
69
crates/ui/src/panels/lfo.rs
Normal file
69
crates/ui/src/panels/lfo.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::widgets::{knob::labeled_knob, wavetable_display::WavetableDisplay};
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
const LFO_KNOBS: [[ParamId; 4]; 4] = [
|
||||
[
|
||||
ParamId::Lfo1Rate,
|
||||
ParamId::Lfo1Phase,
|
||||
ParamId::Lfo1Depth,
|
||||
ParamId::Lfo1WavePos,
|
||||
],
|
||||
[
|
||||
ParamId::Lfo2Rate,
|
||||
ParamId::Lfo2Phase,
|
||||
ParamId::Lfo2Depth,
|
||||
ParamId::Lfo2WavePos,
|
||||
],
|
||||
[
|
||||
ParamId::Lfo3Rate,
|
||||
ParamId::Lfo3Phase,
|
||||
ParamId::Lfo3Depth,
|
||||
ParamId::Lfo3WavePos,
|
||||
],
|
||||
[
|
||||
ParamId::Lfo4Rate,
|
||||
ParamId::Lfo4Phase,
|
||||
ParamId::Lfo4Depth,
|
||||
ParamId::Lfo4WavePos,
|
||||
],
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "LFOs")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for (i, knobs) in LFO_KNOBS.iter().enumerate() {
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, format!("LFO {}", i + 1)).class("knob-label");
|
||||
let sine: Vec<f32> = (0..96)
|
||||
.map(|j| (j as f32 / 96.0 * std::f32::consts::TAU).sin())
|
||||
.collect();
|
||||
WavetableDisplay::new(cx, sine)
|
||||
.width(Pixels(110.0))
|
||||
.height(Pixels(52.0));
|
||||
})
|
||||
.width(Pixels(118.0));
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in knobs.iter() {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
Label::new(cx, "SYNC").class("knob-label").left(Pixels(8.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(84.0));
|
||||
}
|
||||
Button::new(cx, |cx| Label::new(cx, "+ LFO"))
|
||||
.class("add-btn")
|
||||
.top(Pixels(6.0));
|
||||
})
|
||||
.vertical_gap(Pixels(8.0))
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
33
crates/ui/src/panels/macro_bar.rs
Normal file
33
crates/ui/src/panels/macro_bar.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::widgets::knob::labeled_knob;
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
const MACROS: [ParamId; 8] = [
|
||||
ParamId::Macro1,
|
||||
ParamId::Macro2,
|
||||
ParamId::Macro3,
|
||||
ParamId::Macro4,
|
||||
ParamId::Macro5,
|
||||
ParamId::Macro6,
|
||||
ParamId::Macro7,
|
||||
ParamId::Macro8,
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "MACRO")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for &m in &MACROS {
|
||||
VStack::new(cx, |cx| {
|
||||
labeled_knob(cx, m);
|
||||
})
|
||||
.class("macro-knob")
|
||||
.bottom(Pixels(3.0));
|
||||
}
|
||||
})
|
||||
.width(Pixels(68.0))
|
||||
.background_color(Color::from("#16162a"))
|
||||
.padding(Pixels(8.0))
|
||||
.vertical_gap(Pixels(2.0));
|
||||
}
|
||||
8
crates/ui/src/panels/mod.rs
Normal file
8
crates/ui/src/panels/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod env_panel;
|
||||
pub mod filter;
|
||||
pub mod fx;
|
||||
pub mod header;
|
||||
pub mod lfo;
|
||||
pub mod macro_bar;
|
||||
pub mod mod_matrix;
|
||||
pub mod osc;
|
||||
61
crates/ui/src/panels/mod_matrix.rs
Normal file
61
crates/ui/src/panels/mod_matrix.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use vizia::prelude::*;
|
||||
|
||||
const SOURCES: &[&str] = &[
|
||||
"ENV1", "ENV2", "ENV3", "LFO1", "LFO2", "LFO3", "LFO4", "VEL", "NOTE", "MOD", "AFT", "M1",
|
||||
"M2", "M3", "M4", "M5", "M6", "M7", "M8",
|
||||
];
|
||||
const DESTS: &[&str] = &[
|
||||
"CUT1", "RES1", "CUT2", "RES2", "P1", "P2", "P3", "AMP", "PAN", "LR1", "LR2",
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "MOD MATRIX — 64 slots")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
Label::new(cx, "Click cell → depth · Ctrl+click → clear")
|
||||
.class("knob-label")
|
||||
.bottom(Pixels(8.0));
|
||||
|
||||
ScrollView::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
// column header
|
||||
HStack::new(cx, |cx| {
|
||||
Element::new(cx).width(Pixels(48.0));
|
||||
for &d in DESTS {
|
||||
Label::new(cx, d)
|
||||
.class("knob-label")
|
||||
.width(Pixels(38.0))
|
||||
.text_align(TextAlign::Center);
|
||||
}
|
||||
})
|
||||
.height(Pixels(20.0));
|
||||
|
||||
for &src in SOURCES {
|
||||
HStack::new(cx, |cx| {
|
||||
Label::new(cx, src).class("knob-label").width(Pixels(48.0));
|
||||
for _ in DESTS {
|
||||
mod_cell(cx);
|
||||
}
|
||||
})
|
||||
.height(Pixels(34.0))
|
||||
.padding_top(Stretch(1.0))
|
||||
.padding_bottom(Stretch(1.0));
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
|
||||
fn mod_cell(cx: &mut Context) {
|
||||
Element::new(cx)
|
||||
.width(Pixels(34.0))
|
||||
.height(Pixels(26.0))
|
||||
.background_color(Color::rgb(30, 30, 48))
|
||||
.border_color(Color::rgb(42, 42, 68))
|
||||
.border_width(Pixels(1.0))
|
||||
.corner_radius(Pixels(3.0))
|
||||
.left(Pixels(2.0))
|
||||
.cursor(CursorIcon::Hand);
|
||||
}
|
||||
76
crates/ui/src/panels/osc.rs
Normal file
76
crates/ui/src/panels/osc.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::widgets::{knob::labeled_knob, wavetable_display::WavetableDisplay};
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
|
||||
const OSC_KNOBS: [[ParamId; 8]; 3] = [
|
||||
[
|
||||
ParamId::Osc1Gain,
|
||||
ParamId::Osc1Pan,
|
||||
ParamId::Osc1Semitone,
|
||||
ParamId::Osc1Fine,
|
||||
ParamId::Osc1UnisonCount,
|
||||
ParamId::Osc1UnisonDetune,
|
||||
ParamId::Osc1UnisonSpread,
|
||||
ParamId::Osc1WavePos,
|
||||
],
|
||||
[
|
||||
ParamId::Osc2Gain,
|
||||
ParamId::Osc2Pan,
|
||||
ParamId::Osc2Semitone,
|
||||
ParamId::Osc2Fine,
|
||||
ParamId::Osc2UnisonCount,
|
||||
ParamId::Osc2UnisonDetune,
|
||||
ParamId::Osc2UnisonSpread,
|
||||
ParamId::Osc2WavePos,
|
||||
],
|
||||
[
|
||||
ParamId::Osc3Gain,
|
||||
ParamId::Osc3Pan,
|
||||
ParamId::Osc3Semitone,
|
||||
ParamId::Osc3Fine,
|
||||
ParamId::Osc3UnisonCount,
|
||||
ParamId::Osc3UnisonDetune,
|
||||
ParamId::Osc3UnisonSpread,
|
||||
ParamId::Osc3WavePos,
|
||||
],
|
||||
];
|
||||
|
||||
pub fn build(cx: &mut Context) {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "OSCILLATORS")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for (i, knobs) in OSC_KNOBS.iter().enumerate() {
|
||||
osc_row(cx, i + 1, knobs);
|
||||
}
|
||||
Button::new(cx, |cx| Label::new(cx, "+ OSC"))
|
||||
.class("add-btn")
|
||||
.top(Pixels(6.0));
|
||||
})
|
||||
.vertical_gap(Pixels(8.0))
|
||||
.padding(Pixels(12.0));
|
||||
}
|
||||
|
||||
fn osc_row(cx: &mut Context, idx: usize, knobs: &'static [ParamId; 8]) {
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, format!("OSC {idx}")).class("knob-label");
|
||||
let sine: Vec<f32> = (0..128)
|
||||
.map(|i| (i as f32 / 128.0 * std::f32::consts::TAU).sin())
|
||||
.collect();
|
||||
WavetableDisplay::new(cx, sine);
|
||||
})
|
||||
.width(Pixels(158.0));
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in knobs {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(98.0));
|
||||
}
|
||||
49
crates/ui/src/theme.css
Normal file
49
crates/ui/src/theme.css
Normal file
@@ -0,0 +1,49 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Inter", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #1a1a28;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background-color: #22223a;
|
||||
color: #888899;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: #1a1a28;
|
||||
color: #ff7830;
|
||||
}
|
||||
|
||||
.knob-label {
|
||||
color: #888899;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #ff7830;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.macro-knob {
|
||||
background-color: #22223a;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background-color: #22223a;
|
||||
color: #888899;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
111
crates/ui/src/widgets/env_display.rs
Normal file
111
crates/ui/src/widgets/env_display.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use params::ParamId;
|
||||
use vizia::prelude::*;
|
||||
use vizia::vg;
|
||||
|
||||
pub struct EnvDisplay {
|
||||
pub delay: f32,
|
||||
pub attack: f32,
|
||||
pub hold: f32,
|
||||
pub decay: f32,
|
||||
pub sustain: f32,
|
||||
pub release: f32,
|
||||
}
|
||||
|
||||
impl EnvDisplay {
|
||||
pub fn new(cx: &mut Context, env_idx: usize) -> Handle<Self> {
|
||||
let ids = env_param_ids(env_idx);
|
||||
let g = |id: ParamId| {
|
||||
cx.data::<crate::app::AppData>()
|
||||
.map(|d| d.params[id as usize])
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
Self {
|
||||
delay: g(ids[0]),
|
||||
attack: g(ids[1]),
|
||||
hold: g(ids[2]),
|
||||
decay: g(ids[3]),
|
||||
sustain: g(ids[4]),
|
||||
release: g(ids[5]),
|
||||
}
|
||||
.build(cx, |_| {})
|
||||
.width(Pixels(210.0))
|
||||
.height(Pixels(72.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn env_param_ids(idx: usize) -> [ParamId; 6] {
|
||||
match idx {
|
||||
0 => [
|
||||
ParamId::Env1Delay,
|
||||
ParamId::Env1Attack,
|
||||
ParamId::Env1Hold,
|
||||
ParamId::Env1Decay,
|
||||
ParamId::Env1Sustain,
|
||||
ParamId::Env1Release,
|
||||
],
|
||||
1 => [
|
||||
ParamId::Env2Delay,
|
||||
ParamId::Env2Attack,
|
||||
ParamId::Env2Hold,
|
||||
ParamId::Env2Decay,
|
||||
ParamId::Env2Sustain,
|
||||
ParamId::Env2Release,
|
||||
],
|
||||
_ => [
|
||||
ParamId::Env3Delay,
|
||||
ParamId::Env3Attack,
|
||||
ParamId::Env3Hold,
|
||||
ParamId::Env3Decay,
|
||||
ParamId::Env3Sustain,
|
||||
ParamId::Env3Release,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
impl View for EnvDisplay {
|
||||
fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) {
|
||||
let b = cx.bounds();
|
||||
let mut p = vg::Paint::default();
|
||||
p.set_color(vg::Color::from_argb(255, 18, 18, 30));
|
||||
canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p);
|
||||
|
||||
let total =
|
||||
(self.delay + self.attack + self.hold + self.decay + 0.25 + self.release).max(0.001);
|
||||
let sx = |t: f32| b.x + (t / total) * b.w;
|
||||
let top = b.y + 6.0;
|
||||
let bot = b.y + b.h - 6.0;
|
||||
let sy = top + (1.0 - self.sustain) * (bot - top);
|
||||
|
||||
let mut t = 0.0f32;
|
||||
let x0 = sx(t);
|
||||
t += self.delay;
|
||||
let x1 = sx(t);
|
||||
t += self.attack;
|
||||
let x2 = sx(t);
|
||||
t += self.hold;
|
||||
let x3 = sx(t);
|
||||
t += self.decay;
|
||||
let x4 = sx(t);
|
||||
t += 0.25;
|
||||
let x5 = sx(t);
|
||||
t += self.release;
|
||||
let x6 = sx(t);
|
||||
|
||||
let mut path = vg::Path::new();
|
||||
path.move_to((x0, bot));
|
||||
path.line_to((x1, bot));
|
||||
path.line_to((x2, top));
|
||||
path.line_to((x3, top));
|
||||
path.line_to((x4, sy));
|
||||
path.line_to((x5, sy));
|
||||
path.line_to((x6, bot));
|
||||
|
||||
p.set_style(vg::PaintStyle::Stroke);
|
||||
p.set_color(vg::Color::from_argb(255, 255, 120, 48));
|
||||
p.set_stroke_width(2.0);
|
||||
p.set_stroke_cap(vg::paint::Cap::Round);
|
||||
p.set_stroke_join(vg::paint::Join::Round);
|
||||
p.set_anti_alias(true);
|
||||
canvas.draw_path(&path, &p);
|
||||
}
|
||||
}
|
||||
135
crates/ui/src/widgets/knob.rs
Normal file
135
crates/ui/src/widgets/knob.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use crate::app::AppEvent;
|
||||
use params::ParamId;
|
||||
use std::f32::consts::PI;
|
||||
use vizia::prelude::*;
|
||||
use vizia::vg;
|
||||
|
||||
const ARC_START: f32 = PI * 0.75;
|
||||
const ARC_RANGE: f32 = PI * 1.5;
|
||||
|
||||
pub struct TenkoKnob {
|
||||
pub param: ParamId,
|
||||
value: f32,
|
||||
drag_origin_y: Option<f32>,
|
||||
drag_origin_v: f32,
|
||||
}
|
||||
|
||||
impl TenkoKnob {
|
||||
pub fn new(cx: &mut Context, param: ParamId) -> Handle<Self> {
|
||||
let init = cx
|
||||
.data::<crate::app::AppData>()
|
||||
.map(|d| d.params[param as usize])
|
||||
.unwrap_or(param.default_value());
|
||||
|
||||
Self {
|
||||
param,
|
||||
value: init,
|
||||
drag_origin_y: None,
|
||||
drag_origin_v: 0.0,
|
||||
}
|
||||
.build(cx, |_| {})
|
||||
.bind(
|
||||
crate::app::AppData::params.idx(param as usize),
|
||||
|h, lens| {
|
||||
let v = lens.get(&h);
|
||||
h.modify(|k: &mut TenkoKnob| k.value = v);
|
||||
},
|
||||
)
|
||||
.width(Pixels(46.0))
|
||||
.height(Pixels(46.0))
|
||||
.cursor(CursorIcon::NsResize)
|
||||
}
|
||||
}
|
||||
|
||||
impl View for TenkoKnob {
|
||||
fn element(&self) -> Option<&'static str> {
|
||||
Some("tenko-knob")
|
||||
}
|
||||
|
||||
fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
|
||||
event.map(|we: &WindowEvent, _| match we {
|
||||
WindowEvent::MouseDown(MouseButton::Left) => {
|
||||
cx.capture();
|
||||
self.drag_origin_y = Some(cx.mouse().cursor_y);
|
||||
self.drag_origin_v = self.value;
|
||||
}
|
||||
WindowEvent::MouseUp(MouseButton::Left) => {
|
||||
cx.release();
|
||||
self.drag_origin_y = None;
|
||||
}
|
||||
WindowEvent::MouseMove(_, y) => {
|
||||
if let Some(oy) = self.drag_origin_y {
|
||||
let sens = if cx.modifiers().shift() {
|
||||
0.0015
|
||||
} else {
|
||||
0.004
|
||||
};
|
||||
let new_v = (self.drag_origin_v + (oy - y) * sens).clamp(0.0, 1.0);
|
||||
cx.emit(AppEvent::SetParam(self.param, new_v));
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseDoubleClick(MouseButton::Left) => {
|
||||
cx.emit(AppEvent::SetParam(self.param, self.param.default_value()));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) {
|
||||
let b = cx.bounds();
|
||||
let kx = b.x + b.w * 0.5;
|
||||
let ky = b.y + b.h * 0.5;
|
||||
let r = b.w.min(b.h) * 0.5 - 5.0;
|
||||
let val_end = ARC_START + self.value * ARC_RANGE;
|
||||
|
||||
let mut paint = vg::Paint::default();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_style(vg::PaintStyle::Stroke);
|
||||
paint.set_stroke_width(3.5);
|
||||
paint.set_stroke_cap(vg::paint::Cap::Round);
|
||||
paint.set_color(vg::Color::from_argb(255, 45, 45, 65));
|
||||
|
||||
let track_rect = vg::Rect::from_xywh(kx - r, ky - r, r * 2.0, r * 2.0);
|
||||
let mut path = vg::Path::new();
|
||||
path.add_arc(track_rect, ARC_START.to_degrees(), ARC_RANGE.to_degrees());
|
||||
canvas.draw_path(&path, &paint);
|
||||
|
||||
if self.value > 0.001 {
|
||||
paint.set_color(vg::Color::from_argb(255, 255, 120, 48));
|
||||
let mut vpath = vg::Path::new();
|
||||
vpath.add_arc(
|
||||
track_rect,
|
||||
ARC_START.to_degrees(),
|
||||
(self.value * ARC_RANGE).to_degrees(),
|
||||
);
|
||||
canvas.draw_path(&vpath, &paint);
|
||||
}
|
||||
|
||||
paint.set_style(vg::PaintStyle::Fill);
|
||||
paint.set_color(vg::Color::from_argb(255, 30, 30, 48));
|
||||
canvas.draw_circle((kx, ky), r - 6.0, &paint);
|
||||
|
||||
let px = kx + val_end.cos() * (r - 9.0);
|
||||
let py = ky + val_end.sin() * (r - 9.0);
|
||||
paint.set_style(vg::PaintStyle::Stroke);
|
||||
paint.set_stroke_width(2.0);
|
||||
paint.set_color(vg::Color::from_argb(255, 220, 220, 235));
|
||||
let mut ptr = vg::Path::new();
|
||||
ptr.move_to((kx, ky));
|
||||
ptr.line_to((px, py));
|
||||
canvas.draw_path(&ptr, &paint);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn labeled_knob(cx: &mut Context, param: ParamId) {
|
||||
VStack::new(cx, |cx| {
|
||||
TenkoKnob::new(cx, param);
|
||||
Label::new(cx, param.label())
|
||||
.class("knob-label")
|
||||
.text_align(TextAlign::Center)
|
||||
.width(Stretch(1.0));
|
||||
})
|
||||
.width(Pixels(50.0))
|
||||
.height(Pixels(62.0))
|
||||
.padding(Pixels(2.0));
|
||||
}
|
||||
3
crates/ui/src/widgets/mod.rs
Normal file
3
crates/ui/src/widgets/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod env_display;
|
||||
pub mod knob;
|
||||
pub mod wavetable_display;
|
||||
54
crates/ui/src/widgets/wavetable_display.rs
Normal file
54
crates/ui/src/widgets/wavetable_display.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use vizia::prelude::*;
|
||||
use vizia::vg;
|
||||
|
||||
pub struct WavetableDisplay {
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
impl WavetableDisplay {
|
||||
pub fn new(cx: &mut Context, samples: Vec<f32>) -> Handle<Self> {
|
||||
Self { samples }
|
||||
.build(cx, |_| {})
|
||||
.width(Pixels(150.0))
|
||||
.height(Pixels(68.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl View for WavetableDisplay {
|
||||
fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) {
|
||||
let b = cx.bounds();
|
||||
|
||||
let mut p = vg::Paint::default();
|
||||
p.set_color(vg::Color::from_argb(255, 18, 18, 30));
|
||||
canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p);
|
||||
|
||||
p.set_color(vg::Color::from_argb(40, 255, 255, 255));
|
||||
p.set_style(vg::PaintStyle::Stroke);
|
||||
p.set_stroke_width(1.0);
|
||||
let mid_y = b.y + b.h * 0.5;
|
||||
let mut zl = vg::Path::new();
|
||||
zl.move_to((b.x, mid_y));
|
||||
zl.line_to((b.x + b.w, mid_y));
|
||||
canvas.draw_path(&zl, &p);
|
||||
|
||||
if self.samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = self.samples.len() as f32;
|
||||
|
||||
let mut wave = vg::Path::new();
|
||||
for (i, &s) in self.samples.iter().enumerate() {
|
||||
let x = b.x + (i as f32 / n) * b.w;
|
||||
let y = mid_y - s * b.h * 0.44;
|
||||
if i == 0 {
|
||||
wave.move_to((x, y));
|
||||
} else {
|
||||
wave.line_to((x, y));
|
||||
}
|
||||
}
|
||||
p.set_color(vg::Color::from_argb(255, 255, 120, 48));
|
||||
p.set_stroke_width(1.5);
|
||||
p.set_anti_alias(true);
|
||||
canvas.draw_path(&wave, &p);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user