some sort of ui

This commit is contained in:
2026-04-19 16:20:33 +03:00
parent 4f14980610
commit cdff703f7e
30 changed files with 1504 additions and 184 deletions

136
crates/ui/src/app.rs Normal file
View 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);
});
}

View File

@@ -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
View 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();
}

View 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));
}

View 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));
}

View 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));
}

View 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));
});
}

View 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));
}

View 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));
}

View 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;

View 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);
}

View 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
View 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;
}

View 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);
}
}

View 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));
}

View File

@@ -0,0 +1,3 @@
pub mod env_display;
pub mod knob;
pub mod wavetable_display;

View 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);
}
}