Core additions since initial commit: - Platform-split MIDI (midi_linux.c / midi_windows.c) - Patch system: save/load/scan, subdirectory navigation, favourites - Config system: last MIDI device and CC mappings persisted to disk - Custom embedded pixel font and sprite sheet (no loose asset files at runtime) - Window icon embedded at runtime (SetWindowIcon) and in Windows .exe (windres) - chdirToExeDir() in platform.c so double-click launch finds patches/ correctly - About screen accessible from Master panel UI polish: - TITLE_BAR_H constant (20 px) — sprite buttons now fit inside title bars - All title bar text, button overlay text, and close/active labels vertically centred - Full-screen dim overlay computed from camera transform (no more partial coverage) - Patch browser: folder navigation, breadcrumb title, 256-slot limit lifted Build: - Makefile auto-discovers sources; embeds sprites/font/icon via xxd rules - Windows cross-compile: make PLATFORM=windows (mingw-w64 + windres) - windows.h isolated in platform.c to avoid Rectangle/CloseWindow conflicts with raylib.h - WIN_RES uses lazy = assignment so OBJ_DIR expands correctly for windres output path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1720 lines
70 KiB
C
1720 lines
70 KiB
C
#include "ui.h"
|
|
#include "synth.h"
|
|
#include "sprites_data.h"
|
|
#include "font_data.h"
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <stdatomic.h>
|
|
|
|
#ifndef M_PI
|
|
#define M_PI 3.14159265358979323846
|
|
#endif
|
|
|
|
#define SPRITE_SCALE 2
|
|
#define TILE_SIZE 8
|
|
#define KNOB_SIZE 16
|
|
#define TITLE_BAR_H (TILE_SIZE * SPRITE_SCALE + 4) /* fits 16-px sprite buttons */
|
|
|
|
// File-local text helpers — ui must be in scope at every call site
|
|
#define DRAW_TEXT(txt, x, y, sz, col) \
|
|
DrawTextEx(ui->font, (txt), (Vector2){(float)(x),(float)(y)}, (float)(sz), 0, (col))
|
|
#define MEASURE_TEXT(txt, sz) \
|
|
((int)MeasureTextEx(ui->font, (txt), (float)(sz), 0).x)
|
|
|
|
// ------------------------------------------------------------------ CONTROL REGISTRY
|
|
|
|
static void registerControl(UIState *ui, int id, float *ptr, float min, float max)
|
|
{
|
|
for (int i = 0; i < ui->controlRegistryCount; i++) {
|
|
if (ui->controlRegistry[i].id == id) return; // already registered
|
|
}
|
|
if (ui->controlRegistryCount >= 128) return;
|
|
ui->controlRegistry[ui->controlRegistryCount++] = (UIControlEntry){id, ptr, min, max};
|
|
|
|
// Resolve any pending CC mappings that were waiting for this control
|
|
for (int i = 0; i < ui->pendingCount; i++) {
|
|
if (ui->pendingMappings[i].controlId == id) {
|
|
int cc = ui->pendingMappings[i].cc;
|
|
if (cc >= 0 && cc < 128) {
|
|
ui->midi->ccMappings[cc].active = 1;
|
|
ui->midi->ccMappings[cc].valuePtr = ptr;
|
|
ui->midi->ccMappings[cc].min = ui->pendingMappings[i].min;
|
|
ui->midi->ccMappings[cc].max = ui->pendingMappings[i].max;
|
|
ui->midi->ccMappings[cc].controlId = id;
|
|
}
|
|
// Shift-compact the pending list
|
|
for (int j = i; j < ui->pendingCount - 1; j++)
|
|
ui->pendingMappings[j] = ui->pendingMappings[j + 1];
|
|
ui->pendingCount--;
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ INTERNAL
|
|
|
|
static Rectangle getTile(SpriteIndex index)
|
|
{
|
|
if (index == SPRITE_KNOB)
|
|
return (Rectangle){ 72, 0, KNOB_SIZE, KNOB_SIZE };
|
|
return (Rectangle){ index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE };
|
|
}
|
|
|
|
static void drawTile( UIState *ui, SpriteIndex index, float x, float y, Color tint)
|
|
{
|
|
Rectangle src = getTile(index);
|
|
Rectangle dest = {
|
|
x, y,
|
|
src.width * SPRITE_SCALE,
|
|
src.height * SPRITE_SCALE
|
|
};
|
|
DrawTexturePro(ui->sprites, src, dest, (Vector2){0, 0}, 0.0f, tint);
|
|
}
|
|
|
|
static void drawTileRotated( UIState *ui, SpriteIndex index, float x, float y, float rotation, Color tint)
|
|
{
|
|
Rectangle src = getTile(index);
|
|
float w = src.width * SPRITE_SCALE;
|
|
float h = src.height * SPRITE_SCALE;
|
|
Rectangle dest = { x + w * 0.5f, y + h * 0.5f, w, h };
|
|
DrawTexturePro(ui->sprites, src, dest, (Vector2){ w * 0.5f, h * 0.5f }, rotation, tint);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ LIFECYCLE
|
|
|
|
static Font loadEmbeddedFont(void)
|
|
{
|
|
const int glyphCount = 224, glyphW = 8, glyphH = 8, cols = 16;
|
|
Image img = LoadImageFromMemory(".png", font_png, (int)font_png_len);
|
|
Font f = { 0 };
|
|
f.baseSize = glyphH;
|
|
f.glyphCount = glyphCount;
|
|
f.glyphs = (GlyphInfo *)MemAlloc(glyphCount * sizeof(GlyphInfo));
|
|
f.recs = (Rectangle *)MemAlloc(glyphCount * sizeof(Rectangle));
|
|
for (int i = 0; i < glyphCount; i++) {
|
|
f.recs[i] = (Rectangle){ (float)((i % cols) * glyphW), (float)((i / cols) * glyphH), glyphW, glyphH };
|
|
f.glyphs[i].value = 32 + i;
|
|
f.glyphs[i].offsetX = 0;
|
|
f.glyphs[i].offsetY = 0;
|
|
f.glyphs[i].advanceX = glyphW;
|
|
}
|
|
f.texture = LoadTextureFromImage(img);
|
|
SetTextureFilter(f.texture, TEXTURE_FILTER_POINT);
|
|
UnloadImage(img);
|
|
return f;
|
|
}
|
|
|
|
void uiInit(UIState *ui, MidiState *midi, Camera2D *camera)
|
|
{
|
|
Image spriteImg = LoadImageFromMemory(".png", sprites_png, (int)sprites_png_len);
|
|
ui->sprites = LoadTextureFromImage(spriteImg);
|
|
UnloadImage(spriteImg);
|
|
SetTextureFilter(ui->sprites, TEXTURE_FILTER_POINT);
|
|
ui->font = loadEmbeddedFont();
|
|
ui->midi = midi;
|
|
ui->aboutMenuOpen = 0;
|
|
ui->midiMenuOpen = 0;
|
|
ui->midiMenuSelected = -1;
|
|
ui->draggingKnob = -1;
|
|
ui->draggingSlider = -1;
|
|
ui->dragStartValue = 0.0f;
|
|
ui->camera = camera;
|
|
ui->midiLearnActive = 0;
|
|
ui->midiLearnTargetId = -1;
|
|
ui->midiLearnValuePtr = NULL;
|
|
ui->midiLearnMin = 0.0f;
|
|
ui->midiLearnMax = 1.0f;
|
|
ui->patchMenuOpen = 0;
|
|
ui->focusedTextInput = -1;
|
|
ui->patchConfirmOverwrite = 0;
|
|
ui->patchConfirmClear = 0;
|
|
ui->patchSaveName[0] = '\0';
|
|
ui->patchCurrentName[0] = '\0';
|
|
ui->patchFileCount = 0;
|
|
ui->patchScrollOffset = 0;
|
|
ui->patchShowFavsOnly = 0;
|
|
ui->patchFavCount = patchLoadFavs(ui->patchFavs, PATCH_MAX_FILES);
|
|
ui->patchCurrentDir[0] = '\0';
|
|
ui->patchSubDirCount = 0;
|
|
ui->controlRegistryCount = 0;
|
|
ui->pendingCount = 0;
|
|
}
|
|
|
|
void uiClose( UIState *ui)
|
|
{
|
|
UnloadTexture(ui->sprites);
|
|
UnloadFont(ui->font);
|
|
}
|
|
|
|
// Window helpers:
|
|
|
|
void recomputeViewPort(Camera2D* camera, int x_logical_resolution, int y_logical_resolution, int border_width){
|
|
float x_scale = (GetScreenWidth() / (float)(x_logical_resolution + 2*border_width));
|
|
float y_scale = (GetScreenHeight() / (float)(y_logical_resolution + 2*border_width));
|
|
|
|
|
|
// Set the scale.
|
|
if (x_scale > y_scale) {
|
|
camera->zoom = y_scale;
|
|
} else {
|
|
camera->zoom= x_scale;
|
|
}
|
|
|
|
// Center the active drawing space in the window.
|
|
camera->target = (Vector2){ ((x_logical_resolution * camera->zoom - GetScreenWidth()) / (2.0f * camera->zoom)), ((y_logical_resolution * camera->zoom - GetScreenHeight()) / (2.0f * camera->zoom)) };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ PRIMITIVES
|
|
|
|
float uiKnob(UIState *ui, int id, float x, float y, float *ptr, float min, float max, const char *label)
|
|
{
|
|
registerControl(ui, id, ptr, min, max);
|
|
float value = *ptr;
|
|
float scaledSize = KNOB_SIZE * SPRITE_SCALE;
|
|
int fontSize = 8;
|
|
|
|
// Highlight ring when this knob is the selected MIDI learn target
|
|
if (ui->midiLearnActive == 2 && id == ui->midiLearnTargetId) {
|
|
int pulse = (int)(GetTime() * 4.0) % 2;
|
|
Color ring = pulse ? ORANGE : (Color){180, 90, 0, 255};
|
|
DrawRectangleLines((int)(x - 3), (int)(y - 3),
|
|
(int)(scaledSize + 6), (int)(scaledSize + 6), ring);
|
|
}
|
|
|
|
// Map value to rotation angle
|
|
float t = (value - min) / (max - min);
|
|
float rotation = -135.0f + t * 270.0f - 225.0f;
|
|
|
|
drawTileRotated(ui, SPRITE_KNOB, x, y, rotation, WHITE);
|
|
|
|
int textWidth = MEASURE_TEXT(label, fontSize);
|
|
DRAW_TEXT(label, (int)(x + scaledSize * 0.5f - textWidth * 0.5f),
|
|
(int)(y + scaledSize + 2), fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
float cx = x + scaledSize * 0.5f;
|
|
float cy = y + scaledSize * 0.5f;
|
|
float dist = sqrtf((mouse.x - cx) * (mouse.x - cx) +
|
|
(mouse.y - cy) * (mouse.y - cy));
|
|
|
|
// MIDI learn: intercept click in select mode
|
|
if (ui->midiLearnActive == 1 &&
|
|
IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && dist < scaledSize * 0.5f) {
|
|
ui->midiLearnValuePtr = ptr;
|
|
ui->midiLearnMin = min;
|
|
ui->midiLearnMax = max;
|
|
ui->midiLearnTargetId = id;
|
|
ui->midiLearnActive = 2;
|
|
atomic_store(&ui->midi->midiLearnMode, 1);
|
|
atomic_store(&ui->midi->midiLearnCC, -1);
|
|
return value;
|
|
}
|
|
|
|
if (ui->midiLearnActive == 0) {
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && dist < scaledSize * 0.5f) {
|
|
ui->draggingKnob = id;
|
|
ui->dragStart = mouse;
|
|
ui->dragStartValue = value;
|
|
}
|
|
if (ui->draggingKnob == id) {
|
|
if (IsKeyDown(KEY_LEFT_CONTROL)) {
|
|
value = min + (max - min) * 0.5f;
|
|
ui->dragStartValue = value;
|
|
ui->dragStart = mouse;
|
|
} else if (IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
|
|
float delta = (ui->dragStart.y - mouse.y) * ((max - min) / 200.0f);
|
|
value = ui->dragStartValue + delta;
|
|
if (value < min) value = min;
|
|
if (value > max) value = max;
|
|
} else {
|
|
ui->draggingKnob = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
*ptr = value;
|
|
return value;
|
|
}
|
|
|
|
float uiSlider(UIState *ui, int id, float x, float y, float width, float *ptr, float min, float max, const char *label)
|
|
{
|
|
registerControl(ui, id, ptr, min, max);
|
|
float value = *ptr;
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
int fontSize = 8;
|
|
float trackHeight = scaledTile * 0.25f;
|
|
float trackY = y + scaledTile * 0.5f - trackHeight * 0.5f;
|
|
|
|
// Highlight when selected MIDI learn target
|
|
if (ui->midiLearnActive == 2 && id == ui->midiLearnTargetId) {
|
|
int pulse = (int)(GetTime() * 4.0) % 2;
|
|
Color ring = pulse ? ORANGE : (Color){180, 90, 0, 255};
|
|
DrawRectangleLines((int)(x - 2), (int)(y - 2),
|
|
(int)(width + 4), (int)(scaledTile + 4), ring);
|
|
}
|
|
|
|
DrawRectangle((int)x, (int)trackY, (int)width, (int)trackHeight, DARKGRAY);
|
|
|
|
float t = (value - min) / (max - min);
|
|
float handleX = x + t * (width - scaledTile);
|
|
drawTile(ui, SPRITE_SLIDER, handleX, y, WHITE);
|
|
|
|
int textWidth = MEASURE_TEXT(label, fontSize);
|
|
DRAW_TEXT(label, (int)(x + width * 0.5f - textWidth * 0.5f),
|
|
(int)(y + scaledTile + 2), fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
int onTrack = (mouse.x >= x && mouse.x <= x + width &&
|
|
mouse.y >= y && mouse.y <= y + scaledTile);
|
|
|
|
// MIDI learn: intercept click in select mode
|
|
if (ui->midiLearnActive == 1 && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && onTrack) {
|
|
ui->midiLearnValuePtr = ptr;
|
|
ui->midiLearnMin = min;
|
|
ui->midiLearnMax = max;
|
|
ui->midiLearnTargetId = id;
|
|
ui->midiLearnActive = 2;
|
|
atomic_store(&ui->midi->midiLearnMode, 1);
|
|
atomic_store(&ui->midi->midiLearnCC, -1);
|
|
return value;
|
|
}
|
|
|
|
if (ui->midiLearnActive == 0) {
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && onTrack) {
|
|
ui->draggingSlider = id;
|
|
ui->dragStart = mouse;
|
|
ui->dragStartValue = value;
|
|
}
|
|
if (ui->draggingSlider == id) {
|
|
if (IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
|
|
float delta = (mouse.x - ui->dragStart.x) * ((max - min) / width);
|
|
value = ui->dragStartValue + delta;
|
|
if (value < min) value = min;
|
|
if (value > max) value = max;
|
|
} else {
|
|
ui->draggingSlider = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
*ptr = value;
|
|
return value;
|
|
}
|
|
|
|
int uiWaveformSelector( UIState *ui, float x, float y, int currentWaveform)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
int result = currentWaveform;
|
|
|
|
for (int i = 0; i < 6; i++) {
|
|
float tx = x + i * (scaledTile + 2);
|
|
bool active = (i == currentWaveform);
|
|
Color tint = active ? GREEN : GRAY;
|
|
Rectangle dest = { tx, y, scaledTile, scaledTile };
|
|
Rectangle src = getTile((SpriteIndex)i);
|
|
|
|
DrawTexturePro(ui->sprites, src, dest, (Vector2){0, 0}, 0.0f, tint);
|
|
|
|
// Click to select
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= tx && mouse.x <= tx + scaledTile &&
|
|
mouse.y >= y && mouse.y <= y + scaledTile) {
|
|
result = i;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
void uiADSRShape(float x, float y, float width, float height,
|
|
float attack, float decay, float sustain, float release,
|
|
Color color)
|
|
{
|
|
// Normalize times to fractions of the display width
|
|
float total = attack + decay + release + 0.001f;
|
|
float aFrac = attack / total;
|
|
float dFrac = decay / total;
|
|
float rFrac = release / total;
|
|
float sFrac = 1.0f - aFrac - dFrac - rFrac;
|
|
|
|
// Key points
|
|
Vector2 p0 = { x, y + height };
|
|
Vector2 p1 = { x + aFrac * width, y };
|
|
Vector2 p2 = { x + (aFrac + dFrac) * width, y + (1.0f - sustain) * height };
|
|
Vector2 p3 = { x + (aFrac + dFrac + sFrac) * width, y + (1.0f - sustain) * height };
|
|
Vector2 p4 = { x + width, y + height };
|
|
|
|
DrawLineV(p0, p1, color);
|
|
DrawLineV(p1, p2, color);
|
|
DrawLineV(p2, p3, color);
|
|
DrawLineV(p3, p4, color);
|
|
}
|
|
|
|
void uiVoiceMeter( UIState *ui, float x, float y, Voice *voices, int voiceCount)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
for (int i = 0; i < voiceCount; i++) {
|
|
float tx = x + i * (scaledTile + 2);
|
|
Color tint = voices[i].active ? GREEN : GRAY;
|
|
drawTile(ui, SPRITE_BTN_GREEN, tx, y, tint);
|
|
}
|
|
}
|
|
|
|
void uiOscillatorPanel( UIState *ui, int baseId, float x, float y, float width, float height,
|
|
Oscillator *osc, const char *title)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
|
|
// Panel background and border
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, DARKGRAY);
|
|
|
|
// Title bar
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT(title, (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
// Active button
|
|
float activeBtnX = x + width - scaledTile - padding;
|
|
float activeBtnY = y + 2;
|
|
drawTile(ui, osc->active ? SPRITE_BTN_GREEN : SPRITE_BTN_RED,
|
|
activeBtnX, activeBtnY, WHITE);
|
|
DRAW_TEXT("Active", (int)(activeBtnX - MEASURE_TEXT("Active", fontSize) - 4),
|
|
(int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= activeBtnX && mouse.x <= activeBtnX + scaledTile &&
|
|
mouse.y >= activeBtnY && mouse.y <= activeBtnY + scaledTile) {
|
|
osc->active = !osc->active;
|
|
}
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
// Waveform selector
|
|
DRAW_TEXT("Wave", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
contentY += fontSize + 4;
|
|
osc->waveform = uiWaveformSelector(ui, x + padding, contentY, osc->waveform);
|
|
contentY += scaledTile + padding * 2;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// Knobs
|
|
float knobSize = KNOB_SIZE * SPRITE_SCALE;
|
|
float knobSpacing = (width - padding * 2) / 3.0f;
|
|
float knobY = contentY;
|
|
|
|
float knobOffset = knobSpacing * 0.5f - knobSize * 0.5f;
|
|
|
|
uiKnob(ui, baseId + 0,
|
|
x + padding + knobSpacing * 0.0f + knobOffset, knobY,
|
|
&osc->dutyCycle, 0.05f, 0.95f, "PWidth");
|
|
|
|
uiKnob(ui, baseId + 1,
|
|
x + padding + knobSpacing * 1.0f + knobOffset, knobY,
|
|
&osc->detune, -100.0f, 100.0f, "Tune");
|
|
|
|
uiKnob(ui, baseId + 2,
|
|
x + padding + knobSpacing * 2.0f + knobOffset, knobY,
|
|
&osc->gain, 0.0f, OSC_MAX_GAIN, "Gain");
|
|
|
|
contentY += knobSize + fontSize + padding * 2;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// Modulation section — octave buttons share this header row
|
|
DRAW_TEXT("Modulation", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
{
|
|
const char *octLabels[] = { "-2", "-1", "0", "+1", "+2" };
|
|
float octStep = scaledTile + 1;
|
|
float octX = x + width - padding - 5.0f * octStep + 1;
|
|
int lblFont = 8;
|
|
for (int o = 0; o < 5; o++) {
|
|
float bx = octX + o * octStep;
|
|
int oval = o - 2;
|
|
int sel = (osc->octave == oval);
|
|
drawTile(ui, sel ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, bx, contentY,
|
|
sel ? GREEN : GRAY);
|
|
int lw = MEASURE_TEXT(octLabels[o], lblFont);
|
|
DRAW_TEXT(octLabels[o],
|
|
(int)(bx + (scaledTile - lw) * 0.5f),
|
|
(int)(contentY + (scaledTile - lblFont) * 0.5f),
|
|
lblFont, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= bx && mouse.x <= bx + scaledTile &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
osc->octave = oval;
|
|
}
|
|
}
|
|
}
|
|
contentY += fontSize + 24;
|
|
|
|
const char *modParams[] = { "PWidth", "Tune", "Gain" };
|
|
for (int i = 0; i < 3; i++) {
|
|
DRAW_TEXT(modParams[i], (int)(x + padding * 2), (int)contentY, fontSize, RAYWHITE);
|
|
|
|
// Depth knob — sits to the right of the label, left of the buttons
|
|
float knobSize = KNOB_SIZE * SPRITE_SCALE;
|
|
float knobX = x + padding * 2 + MEASURE_TEXT("PWidth", fontSize) + 8;
|
|
float knobY = contentY - knobSize * 0.25f;
|
|
|
|
// osc->modDepth[i] = uiKnob(ui, baseId + 10 + i,
|
|
// knobX, knobY,
|
|
// osc->modDepth[i], -1.0f, 1.0f, "");
|
|
|
|
float depthMin, depthMax;
|
|
switch (i) {
|
|
case 1: // Detune, the only weird case.
|
|
depthMin = -1200.0f;
|
|
depthMax = 1200.0f;
|
|
break;
|
|
case 2:
|
|
depthMin = -OSC_MAX_GAIN;
|
|
depthMax = OSC_MAX_GAIN;
|
|
break;
|
|
default:
|
|
depthMin = -1.0f;
|
|
depthMax = 1.0f;
|
|
}
|
|
uiKnob(ui, baseId + 10 + i,
|
|
knobX, knobY,
|
|
&osc->modDepth[i], depthMin, depthMax, "");
|
|
|
|
// Routing buttons — E0, E1, L0, L1
|
|
float btnX = x + width - (scaledTile + 2) * 4 - padding;
|
|
const char *modLabels[] = { "E0", "E1", "L0", "L1" };
|
|
ModSource sources[] = {
|
|
MOD_SOURCE_AMP_ENV,
|
|
MOD_SOURCE_MOD_ENV,
|
|
MOD_SOURCE_LFO,
|
|
MOD_SOURCE_LFO2
|
|
};
|
|
|
|
for (int b = 0; b < 4; b++) {
|
|
float bx = btnX + b * (scaledTile + 2);
|
|
bool enabled = (osc->modRouting[i] == (int)sources[b]);
|
|
Color tint = enabled ? GREEN : GRAY;
|
|
|
|
drawTile(ui, enabled ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, bx, contentY, tint);
|
|
DRAW_TEXT(modLabels[b], (int)(bx + 1), (int)(contentY + (scaledTile - 8) * 0.5f), 8, RAYWHITE);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= bx && mouse.x <= bx + scaledTile &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
osc->modRouting[i] = enabled ? MOD_SOURCE_NONE : sources[b];
|
|
}
|
|
}
|
|
contentY += scaledTile + 24;
|
|
}
|
|
}
|
|
|
|
void uiEnvelopePanel( UIState *ui, int baseId, float x, float y, float width, float height,
|
|
Envelope *env, const char *title)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
|
|
// Panel background and border
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, DARKGRAY);
|
|
|
|
// Title bar
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT(title, (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
// ADSR shape visualizer
|
|
float visWidth = width - padding * 2;
|
|
float visHeight = 40.0f;
|
|
DrawRectangle((int)(x + padding), (int)contentY,
|
|
(int)visWidth, (int)visHeight, (Color){20, 20, 20, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY,
|
|
(int)visWidth, (int)visHeight, DARKGRAY);
|
|
|
|
uiADSRShape(x + padding, contentY, visWidth, visHeight,
|
|
env->attackSec, env->decaySec, env->sustainLevel, env->releaseSec,
|
|
GREEN);
|
|
|
|
contentY += visHeight + padding;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// ADSR sliders
|
|
float sliderWidth = width - padding * 2;
|
|
|
|
// Attack
|
|
DRAW_TEXT("A", (int)(x + padding), (int)contentY, fontSize, RAYWHITE);
|
|
uiSlider(ui, baseId + 0,
|
|
x + padding + fontSize + 4, contentY,
|
|
sliderWidth - fontSize - 4,
|
|
&env->attackSec, 0.001f, 2.0f, "");
|
|
contentY += scaledTile + fontSize + padding;
|
|
|
|
// Decay
|
|
DRAW_TEXT("D", (int)(x + padding), (int)contentY, fontSize, RAYWHITE);
|
|
uiSlider(ui, baseId + 1,
|
|
x + padding + fontSize + 4, contentY,
|
|
sliderWidth - fontSize - 4,
|
|
&env->decaySec, 0.001f, 2.0f, "");
|
|
contentY += scaledTile + fontSize + padding;
|
|
|
|
// Sustain
|
|
DRAW_TEXT("S", (int)(x + padding), (int)contentY, fontSize, RAYWHITE);
|
|
uiSlider(ui, baseId + 2,
|
|
x + padding + fontSize + 4, contentY,
|
|
sliderWidth - fontSize - 4,
|
|
&env->sustainLevel, 0.0f, 1.0f, "");
|
|
contentY += scaledTile + fontSize + padding;
|
|
|
|
// Release
|
|
DRAW_TEXT("R", (int)(x + padding), (int)contentY, fontSize, RAYWHITE);
|
|
uiSlider(ui, baseId + 3,
|
|
x + padding + fontSize + 4, contentY,
|
|
sliderWidth - fontSize - 4,
|
|
&env->releaseSec, 0.001f, 2.0f, "");
|
|
contentY += scaledTile + fontSize + padding;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// Current stage indicator
|
|
const char *stageNames[] = { "IDLE", "ATTACK", "DECAY", "SUSTAIN", "RELEASE" };
|
|
Color stageColors[] = {
|
|
GRAY, GREEN, YELLOW, SKYBLUE, RED
|
|
};
|
|
DRAW_TEXT("Stage:", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
DRAW_TEXT(stageNames[env->stage],
|
|
(int)(x + padding + MEASURE_TEXT("Stage: ", fontSize)),
|
|
(int)contentY, fontSize, stageColors[env->stage]);
|
|
}
|
|
|
|
static int uiTextInput(UIState *ui, int id, float x, float y, float width,
|
|
char *buf, int bufSize)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
int fontSize = 8;
|
|
int focused = (ui->focusedTextInput == id);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
|
if (mouse.x >= x && mouse.x <= x + width &&
|
|
mouse.y >= y && mouse.y <= y + scaledTile) {
|
|
ui->focusedTextInput = id;
|
|
focused = 1;
|
|
} else if (focused) {
|
|
ui->focusedTextInput = -1;
|
|
focused = 0;
|
|
}
|
|
}
|
|
|
|
if (focused) {
|
|
int len = (int)strlen(buf);
|
|
int ch;
|
|
while ((ch = GetCharPressed()) > 0) {
|
|
if (ch >= 32 && ch < 127 && len + 1 < bufSize) {
|
|
buf[len++] = (char)ch;
|
|
buf[len] = '\0';
|
|
}
|
|
}
|
|
if (IsKeyPressed(KEY_BACKSPACE) && len > 0)
|
|
buf[--len] = '\0';
|
|
}
|
|
|
|
Color borderCol = focused ? BLUE : DARKGRAY;
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)scaledTile, (Color){20, 20, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)scaledTile, borderCol);
|
|
DRAW_TEXT(buf, (int)(x + 4), (int)(y + 2), fontSize, RAYWHITE);
|
|
if (focused && (int)(GetTime() * 2.0) % 2 == 0) {
|
|
int tw = MEASURE_TEXT(buf, fontSize);
|
|
DRAW_TEXT("|", (int)(x + 4 + tw), (int)(y + 2), fontSize, RAYWHITE);
|
|
}
|
|
|
|
return focused && IsKeyPressed(KEY_ENTER);
|
|
}
|
|
|
|
static void uiSaveCcMappings(UIState *ui)
|
|
{
|
|
const char *devName = NULL;
|
|
for (int i = 0; i < ui->midi->inputCount; i++) {
|
|
if (ui->midi->inputs[i].client == ui->midi->connectedClient &&
|
|
ui->midi->inputs[i].port == ui->midi->connectedPort) {
|
|
devName = ui->midi->inputs[i].clientName;
|
|
break;
|
|
}
|
|
}
|
|
if (!devName) return;
|
|
|
|
ConfigCcEntry entries[128];
|
|
int count = 0;
|
|
for (int cc = 0; cc < 128 && count < 128; cc++) {
|
|
if (ui->midi->ccMappings[cc].active) {
|
|
entries[count].cc = cc;
|
|
entries[count].controlId = ui->midi->ccMappings[cc].controlId;
|
|
entries[count].min = ui->midi->ccMappings[cc].min;
|
|
entries[count].max = ui->midi->ccMappings[cc].max;
|
|
count++;
|
|
}
|
|
}
|
|
configSaveCcMappings(devName, entries, count);
|
|
}
|
|
|
|
void uiLoadCcMappingsForDevice(UIState *ui, const char *deviceName)
|
|
{
|
|
for (int i = 0; i < 128; i++) ui->midi->ccMappings[i].active = 0;
|
|
ui->pendingCount = 0;
|
|
|
|
ConfigCcEntry entries[128];
|
|
int count = configLoadCcMappings(deviceName, entries, 128);
|
|
for (int i = 0; i < count; i++) {
|
|
int resolved = 0;
|
|
for (int j = 0; j < ui->controlRegistryCount; j++) {
|
|
if (ui->controlRegistry[j].id == entries[i].controlId) {
|
|
int cc = entries[i].cc;
|
|
ui->midi->ccMappings[cc].active = 1;
|
|
ui->midi->ccMappings[cc].valuePtr = ui->controlRegistry[j].valuePtr;
|
|
ui->midi->ccMappings[cc].min = entries[i].min;
|
|
ui->midi->ccMappings[cc].max = entries[i].max;
|
|
ui->midi->ccMappings[cc].controlId = entries[i].controlId;
|
|
resolved = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (!resolved && ui->pendingCount < 128)
|
|
ui->pendingMappings[ui->pendingCount++] = entries[i];
|
|
}
|
|
}
|
|
|
|
void uiMidiMenu(UIState *ui, float x, float y, float width, MidiState *midi)
|
|
{
|
|
if (!ui->midiMenuOpen) return;
|
|
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float rowH = (float)(fontSize + 8);
|
|
float btnW = width - padding * 2;
|
|
|
|
int visRows = midi->inputCount > 0 ? midi->inputCount : 1;
|
|
if (visRows > 10) visRows = 10;
|
|
float height = (float)TITLE_BAR_H + padding // title bar
|
|
+ scaledTile + padding // rescan button
|
|
+ visRows * rowH + padding // device rows
|
|
+ scaledTile + padding; // close button
|
|
|
|
// Dim the rest of the UI
|
|
{
|
|
float invZ = 1.0f / ui->camera->zoom;
|
|
Vector2 org = GetScreenToWorld2D((Vector2){0.0f, 0.0f}, *ui->camera);
|
|
DrawRectangle((int)org.x, (int)org.y,
|
|
(int)(GetScreenWidth() * invZ),
|
|
(int)(GetScreenHeight() * invZ),
|
|
(Color){0, 0, 0, 140});
|
|
}
|
|
|
|
// Panel background
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, LIGHTGRAY);
|
|
|
|
// Title bar
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT("MIDI Devices", (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
// Close button in title bar
|
|
float closeBtnX = x + width - scaledTile - padding;
|
|
float closeBtnY = y + 2;
|
|
drawTile(ui, SPRITE_BTN_RED, closeBtnX, closeBtnY, WHITE);
|
|
DRAW_TEXT("X", (int)(closeBtnX + (scaledTile - MEASURE_TEXT("X", fontSize)) * 0.5f),
|
|
(int)(closeBtnY + (scaledTile - fontSize) * 0.5f), fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= closeBtnX && mouse.x <= closeBtnX + scaledTile &&
|
|
mouse.y >= closeBtnY && mouse.y <= closeBtnY + scaledTile) {
|
|
ui->midiMenuOpen = 0;
|
|
}
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
// Rescan button
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile,
|
|
(Color){55, 55, 75, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile, GRAY);
|
|
int rescanTextW = MEASURE_TEXT("Rescan", fontSize);
|
|
DRAW_TEXT("Rescan", (int)(x + padding + btnW * 0.5f - rescanTextW * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= x + padding && mouse.x <= x + padding + btnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
midiScanInputs(midi);
|
|
}
|
|
contentY += scaledTile + padding;
|
|
|
|
// Device rows
|
|
if (midi->inputCount == 0) {
|
|
DRAW_TEXT("No MIDI inputs found.", (int)(x + padding), (int)(contentY + 2), fontSize, GRAY);
|
|
contentY += rowH;
|
|
}
|
|
for (int i = 0; i < midi->inputCount && i < 10; i++) {
|
|
MidiInputInfo *info = &midi->inputs[i];
|
|
bool active = (midi->connectedClient == info->client &&
|
|
midi->connectedPort == info->port);
|
|
Color rowBg = active ? (Color){20, 60, 20, 255} : (Color){45, 45, 45, 255};
|
|
Color rowEdge= active ? GREEN : DARKGRAY;
|
|
Color textCol= active ? GREEN : RAYWHITE;
|
|
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), rowBg);
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), rowEdge);
|
|
DRAW_TEXT(TextFormat("[%d:%d] %s", info->client, info->port, info->clientName),
|
|
(int)(x + padding + 4), (int)(contentY + 2), fontSize, textCol);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= x + padding && mouse.x <= x + padding + btnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + rowH - 2) {
|
|
if (active) {
|
|
midiDevDisconnect(midi);
|
|
for (int j = 0; j < 128; j++) ui->midi->ccMappings[j].active = 0;
|
|
ui->pendingCount = 0;
|
|
} else {
|
|
midiDevConnect(midi, info->client, info->port);
|
|
configSaveLastDevice(info->clientName);
|
|
uiLoadCcMappingsForDevice(ui, info->clientName);
|
|
}
|
|
}
|
|
contentY += rowH;
|
|
}
|
|
contentY += padding;
|
|
|
|
// Close button at bottom
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile,
|
|
(Color){70, 40, 40, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile, DARKGRAY);
|
|
int closeTextW = MEASURE_TEXT("Close", fontSize);
|
|
DRAW_TEXT("Close", (int)(x + padding + btnW * 0.5f - closeTextW * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= x + padding && mouse.x <= x + padding + btnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->midiMenuOpen = 0;
|
|
}
|
|
}
|
|
|
|
static void buildPatchPath(char *out, size_t sz, const char *dir, const char *name)
|
|
{
|
|
if (dir[0]) snprintf(out, sz, "patches/%s/%s.json", dir, name);
|
|
else snprintf(out, sz, "patches/%s.json", name);
|
|
}
|
|
|
|
static void patchRescan(UIState *ui)
|
|
{
|
|
ui->patchFileCount = patchScanDir(
|
|
ui->patchCurrentDir,
|
|
ui->patchFiles, PATCH_MAX_FILES,
|
|
ui->patchSubDirs, PATCH_MAX_DIRS, &ui->patchSubDirCount);
|
|
ui->patchScrollOffset = 0;
|
|
}
|
|
|
|
void uiPatchMenu(UIState *ui, float x, float y, float width, Synth *s)
|
|
{
|
|
if (!ui->patchMenuOpen) return;
|
|
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float rowH = (float)(fontSize + 8);
|
|
float saveW = 60.0f;
|
|
float btnW = width - padding * 2;
|
|
float inputW = btnW - 4 - saveW;
|
|
|
|
const int MAX_VIS = 8;
|
|
|
|
float height = (float)TITLE_BAR_H + padding
|
|
+ scaledTile + padding
|
|
+ 1 + padding
|
|
+ scaledTile + padding // filter toggle row
|
|
+ MAX_VIS * rowH + padding
|
|
+ 1 + padding
|
|
+ scaledTile + padding;
|
|
|
|
{
|
|
float invZ = 1.0f / ui->camera->zoom;
|
|
Vector2 org = GetScreenToWorld2D((Vector2){0.0f, 0.0f}, *ui->camera);
|
|
DrawRectangle((int)org.x, (int)org.y,
|
|
(int)(GetScreenWidth() * invZ),
|
|
(int)(GetScreenHeight() * invZ),
|
|
(Color){0, 0, 0, 140});
|
|
}
|
|
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, LIGHTGRAY);
|
|
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
{
|
|
char titleBuf[280];
|
|
if (ui->patchCurrentDir[0])
|
|
snprintf(titleBuf, sizeof(titleBuf), "Patches / %s", ui->patchCurrentDir);
|
|
else
|
|
snprintf(titleBuf, sizeof(titleBuf), "Patches");
|
|
DRAW_TEXT(titleBuf, (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
}
|
|
|
|
float closeBtnX = x + width - scaledTile - padding;
|
|
float closeBtnY = y + 2;
|
|
drawTile(ui, SPRITE_BTN_RED, closeBtnX, closeBtnY, WHITE);
|
|
DRAW_TEXT("X", (int)(closeBtnX + (scaledTile - MEASURE_TEXT("X", fontSize)) * 0.5f),
|
|
(int)(closeBtnY + (scaledTile - fontSize) * 0.5f), fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= closeBtnX && mouse.x <= closeBtnX + scaledTile &&
|
|
mouse.y >= closeBtnY && mouse.y <= closeBtnY + scaledTile) {
|
|
ui->patchMenuOpen = 0;
|
|
ui->focusedTextInput = -1;
|
|
ui->patchConfirmOverwrite = 0;
|
|
ui->patchConfirmClear = 0;
|
|
return;
|
|
}
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
if (!ui->patchConfirmOverwrite) {
|
|
// Normal: text input + Save button
|
|
int enterPressed = uiTextInput(ui, 900, x + padding, contentY, inputW,
|
|
ui->patchSaveName, PATCH_NAME_LEN);
|
|
|
|
float saveX = x + padding + inputW + 4;
|
|
int canSave = (ui->patchSaveName[0] != '\0');
|
|
Color saveBg = canSave ? (Color){20, 60, 20, 255} : (Color){45, 45, 45, 255};
|
|
DrawRectangle((int)saveX, (int)contentY, (int)saveW, (int)scaledTile, saveBg);
|
|
DrawRectangleLines((int)saveX, (int)contentY, (int)saveW, (int)scaledTile,
|
|
canSave ? GREEN : DARKGRAY);
|
|
int saveTW = MEASURE_TEXT("Save", fontSize);
|
|
DRAW_TEXT("Save", (int)(saveX + saveW * 0.5f - saveTW * 0.5f),
|
|
(int)(contentY + 2), fontSize, canSave ? RAYWHITE : GRAY);
|
|
|
|
int clickSave = IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= saveX && mouse.x <= saveX + saveW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile;
|
|
if (canSave && (enterPressed || clickSave)) {
|
|
int exists = 0;
|
|
for (int i = 0; i < ui->patchFileCount; i++) {
|
|
if (strcmp(ui->patchFiles[i], ui->patchSaveName) == 0) {
|
|
exists = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (exists) {
|
|
ui->patchConfirmOverwrite = 1;
|
|
ui->focusedTextInput = -1;
|
|
} else {
|
|
char path[512];
|
|
buildPatchPath(path, sizeof(path), ui->patchCurrentDir, ui->patchSaveName);
|
|
if (patchSave(s, path)) {
|
|
snprintf(ui->patchCurrentName, PATCH_NAME_LEN, "%s", ui->patchSaveName);
|
|
patchRescan(ui);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Confirm overwrite
|
|
float yesW = 44.0f, noW = 44.0f;
|
|
char prompt[PATCH_NAME_LEN + 16];
|
|
snprintf(prompt, sizeof(prompt), "Overwrite '%s'?", ui->patchSaveName);
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile,
|
|
(Color){50, 35, 15, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)scaledTile, ORANGE);
|
|
DRAW_TEXT(prompt, (int)(x + padding + 4), (int)(contentY + 2), fontSize, ORANGE);
|
|
|
|
float yesX = x + width - padding - noW - 4 - yesW;
|
|
float noX = x + width - padding - noW;
|
|
|
|
DrawRectangle((int)yesX, (int)contentY, (int)yesW, (int)scaledTile,
|
|
(Color){20, 60, 20, 255});
|
|
DrawRectangleLines((int)yesX, (int)contentY, (int)yesW, (int)scaledTile, GREEN);
|
|
int yesTW = MEASURE_TEXT("Yes", fontSize);
|
|
DRAW_TEXT("Yes", (int)(yesX + yesW * 0.5f - yesTW * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
|
|
DrawRectangle((int)noX, (int)contentY, (int)noW, (int)scaledTile,
|
|
(Color){70, 40, 40, 255});
|
|
DrawRectangleLines((int)noX, (int)contentY, (int)noW, (int)scaledTile, DARKGRAY);
|
|
int noTW = MEASURE_TEXT("No", fontSize);
|
|
DRAW_TEXT("No", (int)(noX + noW * 0.5f - noTW * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
|
if (mouse.x >= yesX && mouse.x <= yesX + yesW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
char path[512];
|
|
buildPatchPath(path, sizeof(path), ui->patchCurrentDir, ui->patchSaveName);
|
|
if (patchSave(s, path)) {
|
|
snprintf(ui->patchCurrentName, PATCH_NAME_LEN, "%s", ui->patchSaveName);
|
|
patchRescan(ui);
|
|
}
|
|
ui->patchConfirmOverwrite = 0;
|
|
} else if (mouse.x >= noX && mouse.x <= noX + noW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->patchConfirmOverwrite = 0;
|
|
}
|
|
}
|
|
}
|
|
contentY += scaledTile + padding;
|
|
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += 1 + padding;
|
|
|
|
// Filter toggle: [All] [* Favs]
|
|
float filterBtnW = (btnW - 4) * 0.5f;
|
|
float allBtnX = x + padding;
|
|
float favBtnX = allBtnX + filterBtnW + 4;
|
|
|
|
Color allBg = !ui->patchShowFavsOnly ? (Color){40,40,60,255} : (Color){25,25,35,255};
|
|
Color favBg = ui->patchShowFavsOnly ? (Color){60,55,15,255} : (Color){25,25,35,255};
|
|
Color allEdge = !ui->patchShowFavsOnly ? LIGHTGRAY : DARKGRAY;
|
|
Color favEdge = ui->patchShowFavsOnly ? YELLOW : DARKGRAY;
|
|
Color allText = !ui->patchShowFavsOnly ? RAYWHITE : GRAY;
|
|
Color favText = ui->patchShowFavsOnly ? YELLOW : GRAY;
|
|
|
|
DrawRectangle((int)allBtnX, (int)contentY, (int)filterBtnW, (int)scaledTile, allBg);
|
|
DrawRectangleLines((int)allBtnX, (int)contentY, (int)filterBtnW, (int)scaledTile, allEdge);
|
|
{ int tw = MEASURE_TEXT("All", fontSize);
|
|
DRAW_TEXT("All", (int)(allBtnX + filterBtnW*0.5f - tw*0.5f), (int)(contentY+2), fontSize, allText); }
|
|
|
|
DrawRectangle((int)favBtnX, (int)contentY, (int)filterBtnW, (int)scaledTile, favBg);
|
|
DrawRectangleLines((int)favBtnX, (int)contentY, (int)filterBtnW, (int)scaledTile, favEdge);
|
|
{ int tw = MEASURE_TEXT("* Favs", fontSize);
|
|
DRAW_TEXT("* Favs", (int)(favBtnX + filterBtnW*0.5f - tw*0.5f), (int)(contentY+2), fontSize, favText); }
|
|
|
|
if (!ui->patchConfirmClear && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
|
if (mouse.x >= allBtnX && mouse.x <= allBtnX + filterBtnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->patchShowFavsOnly = 0;
|
|
ui->patchScrollOffset = 0;
|
|
}
|
|
if (mouse.x >= favBtnX && mouse.x <= favBtnX + filterBtnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->patchShowFavsOnly = 1;
|
|
ui->patchScrollOffset = 0;
|
|
}
|
|
}
|
|
contentY += scaledTile + padding;
|
|
|
|
// Build filtered patch index list
|
|
int filteredIdx[PATCH_MAX_FILES];
|
|
int filteredCount = 0;
|
|
for (int i = 0; i < ui->patchFileCount; i++) {
|
|
if (!ui->patchShowFavsOnly) {
|
|
filteredIdx[filteredCount++] = i;
|
|
} else {
|
|
for (int j = 0; j < ui->patchFavCount; j++) {
|
|
if (strcmp(ui->patchFavs[j], ui->patchFiles[i]) == 0) {
|
|
filteredIdx[filteredCount++] = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Virtual row layout: [..] + subdirs + patches
|
|
int isRoot = (ui->patchCurrentDir[0] == '\0');
|
|
int backRows = isRoot ? 0 : 1;
|
|
int totalVirtual = backRows + ui->patchSubDirCount + filteredCount;
|
|
|
|
// Mouse-wheel scroll when hovering over list
|
|
float listY0 = contentY;
|
|
if (mouse.y >= listY0 && mouse.y <= listY0 + MAX_VIS * rowH) {
|
|
ui->patchScrollOffset -= (int)GetMouseWheelMove();
|
|
}
|
|
|
|
// Arrow key navigation: move selection through patches only
|
|
if (ui->focusedTextInput == -1 && !ui->patchConfirmOverwrite && !ui->patchConfirmClear
|
|
&& filteredCount > 0) {
|
|
int curFi = -1;
|
|
for (int fi = 0; fi < filteredCount; fi++) {
|
|
if (strcmp(ui->patchFiles[filteredIdx[fi]], ui->patchCurrentName) == 0) {
|
|
curFi = fi;
|
|
break;
|
|
}
|
|
}
|
|
int nextFi = curFi;
|
|
if (IsKeyPressed(KEY_DOWN) && curFi < filteredCount - 1) nextFi = curFi + 1;
|
|
if (IsKeyPressed(KEY_UP) && curFi > 0) nextFi = curFi - 1;
|
|
if (nextFi != curFi) {
|
|
int i = filteredIdx[nextFi];
|
|
char path[512];
|
|
buildPatchPath(path, sizeof(path), ui->patchCurrentDir, ui->patchFiles[i]);
|
|
if (patchLoad(s, path)) {
|
|
snprintf(ui->patchCurrentName, PATCH_NAME_LEN, "%s", ui->patchFiles[i]);
|
|
snprintf(ui->patchSaveName, PATCH_NAME_LEN, "%s", ui->patchFiles[i]);
|
|
}
|
|
int vRow = nextFi + backRows + ui->patchSubDirCount;
|
|
if (vRow < ui->patchScrollOffset)
|
|
ui->patchScrollOffset = vRow;
|
|
if (vRow >= ui->patchScrollOffset + MAX_VIS)
|
|
ui->patchScrollOffset = vRow - MAX_VIS + 1;
|
|
}
|
|
}
|
|
|
|
int maxScroll = totalVirtual - MAX_VIS;
|
|
if (maxScroll < 0) maxScroll = 0;
|
|
if (ui->patchScrollOffset < 0) ui->patchScrollOffset = 0;
|
|
if (ui->patchScrollOffset > maxScroll) ui->patchScrollOffset = maxScroll;
|
|
|
|
// Scrollbar (only when list overflows)
|
|
if (totalVirtual > MAX_VIS) {
|
|
float trackH = MAX_VIS * rowH;
|
|
float trackX = x + width - 4;
|
|
float thumbH = trackH * MAX_VIS / (float)totalVirtual;
|
|
float thumbY = listY0 + (ui->patchScrollOffset / (float)totalVirtual) * trackH;
|
|
DrawRectangle((int)trackX, (int)listY0, 3, (int)trackH, (Color){40,40,40,255});
|
|
DrawRectangle((int)trackX, (int)thumbY, 3, (int)thumbH, LIGHTGRAY);
|
|
}
|
|
|
|
// Combined row list: [..], subdirs, patches
|
|
float starW = scaledTile;
|
|
float nameBtnW = btnW - starW - 2;
|
|
|
|
if (totalVirtual == 0) {
|
|
DRAW_TEXT("(No patches found.)", (int)(x + padding), (int)(contentY + 2), fontSize, GRAY);
|
|
}
|
|
for (int v = 0; v < MAX_VIS; v++) {
|
|
int r = v + ui->patchScrollOffset;
|
|
if (r >= totalVirtual) break;
|
|
|
|
if (!isRoot && r == 0) {
|
|
// ".." — navigate to parent
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2),
|
|
(Color){20, 30, 50, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2),
|
|
(Color){80, 120, 180, 255});
|
|
DRAW_TEXT("..", (int)(x + padding + 4), (int)(contentY + 2), fontSize, SKYBLUE);
|
|
if (!ui->patchConfirmClear && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= x + padding && mouse.x <= x + padding + btnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + rowH - 2) {
|
|
char *lastSlash = strrchr(ui->patchCurrentDir, '/');
|
|
if (lastSlash) *lastSlash = '\0';
|
|
else ui->patchCurrentDir[0] = '\0';
|
|
ui->patchCurrentName[0] = '\0';
|
|
patchRescan(ui);
|
|
return;
|
|
}
|
|
} else if (r - backRows < ui->patchSubDirCount) {
|
|
// Subdirectory row
|
|
int d = r - backRows;
|
|
char label[PATCH_NAME_LEN + 4];
|
|
snprintf(label, sizeof(label), "> %s", ui->patchSubDirs[d]);
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2),
|
|
(Color){20, 30, 50, 255});
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2),
|
|
(Color){80, 120, 180, 255});
|
|
DRAW_TEXT(label, (int)(x + padding + 4), (int)(contentY + 2), fontSize, SKYBLUE);
|
|
if (!ui->patchConfirmClear && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= x + padding && mouse.x <= x + padding + btnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + rowH - 2) {
|
|
if (ui->patchCurrentDir[0]) {
|
|
size_t curLen = strlen(ui->patchCurrentDir);
|
|
snprintf(ui->patchCurrentDir + curLen,
|
|
sizeof(ui->patchCurrentDir) - curLen,
|
|
"/%s", ui->patchSubDirs[d]);
|
|
} else {
|
|
snprintf(ui->patchCurrentDir, sizeof(ui->patchCurrentDir),
|
|
"%s", ui->patchSubDirs[d]);
|
|
}
|
|
ui->patchCurrentName[0] = '\0';
|
|
patchRescan(ui);
|
|
return;
|
|
}
|
|
} else {
|
|
// Patch row
|
|
int fi = r - backRows - ui->patchSubDirCount;
|
|
int i = filteredIdx[fi];
|
|
|
|
int current = (strcmp(ui->patchFiles[i], ui->patchCurrentName) == 0);
|
|
int fav = 0;
|
|
for (int j = 0; j < ui->patchFavCount; j++)
|
|
if (strcmp(ui->patchFavs[j], ui->patchFiles[i]) == 0) { fav = 1; break; }
|
|
|
|
Color rowBg = current ? (Color){20, 60, 20, 255} : (Color){45, 45, 45, 255};
|
|
Color rowEdge = current ? GREEN : DARKGRAY;
|
|
Color textCol = current ? GREEN : RAYWHITE;
|
|
|
|
DrawRectangle((int)(x + padding), (int)contentY, (int)nameBtnW, (int)(rowH - 2), rowBg);
|
|
DrawRectangleLines((int)(x + padding), (int)contentY, (int)nameBtnW, (int)(rowH - 2), rowEdge);
|
|
DRAW_TEXT(ui->patchFiles[i], (int)(x + padding + 4), (int)(contentY + 2), fontSize, textCol);
|
|
|
|
float starX = x + padding + nameBtnW + 2;
|
|
Color starBg = fav ? (Color){60,55,10,255} : (Color){35,35,35,255};
|
|
Color starEdge = fav ? YELLOW : DARKGRAY;
|
|
DrawRectangle((int)starX, (int)contentY, (int)starW, (int)(rowH - 2), starBg);
|
|
DrawRectangleLines((int)starX, (int)contentY, (int)starW, (int)(rowH - 2), starEdge);
|
|
{ int tw = MEASURE_TEXT("*", fontSize);
|
|
DRAW_TEXT("*", (int)(starX + starW*0.5f - tw*0.5f), (int)(contentY + 2),
|
|
fontSize, fav ? YELLOW : GRAY); }
|
|
|
|
if (!ui->patchConfirmClear && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
|
if (mouse.x >= starX && mouse.x <= starX + starW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + rowH - 2) {
|
|
int found = -1;
|
|
for (int j = 0; j < ui->patchFavCount; j++) {
|
|
if (strcmp(ui->patchFavs[j], ui->patchFiles[i]) == 0) { found = j; break; }
|
|
}
|
|
if (found >= 0) {
|
|
for (int j = found; j < ui->patchFavCount - 1; j++)
|
|
memcpy(ui->patchFavs[j], ui->patchFavs[j+1], PATCH_NAME_LEN);
|
|
ui->patchFavCount--;
|
|
} else if (ui->patchFavCount < PATCH_MAX_FILES) {
|
|
memcpy(ui->patchFavs[ui->patchFavCount], ui->patchFiles[i], PATCH_NAME_LEN);
|
|
ui->patchFavCount++;
|
|
}
|
|
patchSaveFavs(ui->patchFavs, ui->patchFavCount);
|
|
} else if (mouse.x >= x + padding && mouse.x <= x + padding + nameBtnW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + rowH - 2) {
|
|
char path[512];
|
|
buildPatchPath(path, sizeof(path), ui->patchCurrentDir, ui->patchFiles[i]);
|
|
if (patchLoad(s, path)) {
|
|
snprintf(ui->patchCurrentName, PATCH_NAME_LEN, "%s", ui->patchFiles[i]);
|
|
snprintf(ui->patchSaveName, PATCH_NAME_LEN, "%s", ui->patchFiles[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
contentY += rowH;
|
|
}
|
|
contentY += padding;
|
|
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += 1 + padding;
|
|
|
|
// Bottom row: Rescan | Init | Close
|
|
float thirdW = (btnW - 8) * (1.0f / 3.0f);
|
|
float rescanX = x + padding;
|
|
float initX = rescanX + thirdW + 4;
|
|
float closeX = initX + thirdW + 4;
|
|
|
|
DrawRectangle((int)rescanX, (int)contentY, (int)thirdW, (int)scaledTile,
|
|
(Color){55, 55, 75, 255});
|
|
DrawRectangleLines((int)rescanX, (int)contentY, (int)thirdW, (int)scaledTile, GRAY);
|
|
int rescanTW = MEASURE_TEXT("Rescan", fontSize);
|
|
DRAW_TEXT("Rescan", (int)(rescanX + thirdW * 0.5f - rescanTW * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
if (!ui->patchConfirmClear &&
|
|
IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= rescanX && mouse.x <= rescanX + thirdW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
patchRescan(ui);
|
|
}
|
|
|
|
DrawRectangle((int)initX, (int)contentY, (int)thirdW, (int)scaledTile,
|
|
(Color){60, 45, 10, 255});
|
|
DrawRectangleLines((int)initX, (int)contentY, (int)thirdW, (int)scaledTile, ORANGE);
|
|
int initTW = MEASURE_TEXT("Init", fontSize);
|
|
DRAW_TEXT("Init", (int)(initX + thirdW * 0.5f - initTW * 0.5f),
|
|
(int)(contentY + 2), fontSize, ORANGE);
|
|
if (!ui->patchConfirmClear &&
|
|
IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= initX && mouse.x <= initX + thirdW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->patchConfirmClear = 1;
|
|
ui->focusedTextInput = -1;
|
|
ui->patchConfirmOverwrite = 0;
|
|
}
|
|
|
|
DrawRectangle((int)closeX, (int)contentY, (int)thirdW, (int)scaledTile,
|
|
(Color){70, 40, 40, 255});
|
|
DrawRectangleLines((int)closeX, (int)contentY, (int)thirdW, (int)scaledTile, DARKGRAY);
|
|
int closeTW2 = MEASURE_TEXT("Close", fontSize);
|
|
DRAW_TEXT("Close", (int)(closeX + thirdW * 0.5f - closeTW2 * 0.5f),
|
|
(int)(contentY + 2), fontSize, RAYWHITE);
|
|
if (!ui->patchConfirmClear &&
|
|
IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= closeX && mouse.x <= closeX + thirdW &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
ui->patchMenuOpen = 0;
|
|
ui->focusedTextInput = -1;
|
|
ui->patchConfirmOverwrite = 0;
|
|
ui->patchConfirmClear = 0;
|
|
}
|
|
|
|
// Clear-to-init confirmation overlay — drawn last, on top of panel content
|
|
if (ui->patchConfirmClear) {
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){0, 0, 0, 160});
|
|
|
|
float dlgW = width - padding * 4;
|
|
float dlgH = padding + (fontSize + 4) * 2 + padding + scaledTile + padding;
|
|
float dlgX = x + padding * 2;
|
|
float dlgY = y + (height - dlgH) * 0.5f;
|
|
|
|
DrawRectangle((int)dlgX, (int)dlgY, (int)dlgW, (int)dlgH, (Color){20, 15, 10, 255});
|
|
DrawRectangleLines((int)dlgX, (int)dlgY, (int)dlgW, (int)dlgH, ORANGE);
|
|
|
|
DRAW_TEXT("Clear to init?",
|
|
(int)(dlgX + padding), (int)(dlgY + padding), fontSize, ORANGE);
|
|
DRAW_TEXT("Unsaved changes will be lost.",
|
|
(int)(dlgX + padding), (int)(dlgY + padding + fontSize + 4), fontSize, GRAY);
|
|
|
|
float dlgBtnY = dlgY + padding + (fontSize + 4) * 2 + padding;
|
|
float dlgBtnW = (dlgW - padding * 3) * 0.5f;
|
|
float dlgYesX = dlgX + padding;
|
|
float dlgNoX = dlgX + padding * 2 + dlgBtnW;
|
|
|
|
DrawRectangle((int)dlgYesX, (int)dlgBtnY, (int)dlgBtnW, (int)scaledTile,
|
|
(Color){20, 60, 20, 255});
|
|
DrawRectangleLines((int)dlgYesX, (int)dlgBtnY, (int)dlgBtnW, (int)scaledTile, GREEN);
|
|
int dlgYesTW = MEASURE_TEXT("Yes", fontSize);
|
|
DRAW_TEXT("Yes", (int)(dlgYesX + dlgBtnW * 0.5f - dlgYesTW * 0.5f),
|
|
(int)(dlgBtnY + 2), fontSize, RAYWHITE);
|
|
|
|
DrawRectangle((int)dlgNoX, (int)dlgBtnY, (int)dlgBtnW, (int)scaledTile,
|
|
(Color){70, 40, 40, 255});
|
|
DrawRectangleLines((int)dlgNoX, (int)dlgBtnY, (int)dlgBtnW, (int)scaledTile, DARKGRAY);
|
|
int dlgNoTW = MEASURE_TEXT("No", fontSize);
|
|
DRAW_TEXT("No", (int)(dlgNoX + dlgBtnW * 0.5f - dlgNoTW * 0.5f),
|
|
(int)(dlgBtnY + 2), fontSize, RAYWHITE);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
|
if (mouse.x >= dlgYesX && mouse.x <= dlgYesX + dlgBtnW &&
|
|
mouse.y >= dlgBtnY && mouse.y <= dlgBtnY + scaledTile) {
|
|
synthResetPatch(s);
|
|
ui->patchCurrentName[0] = '\0';
|
|
ui->patchSaveName[0] = '\0';
|
|
ui->patchConfirmClear = 0;
|
|
} else if (mouse.x >= dlgNoX && mouse.x <= dlgNoX + dlgBtnW &&
|
|
mouse.y >= dlgBtnY && mouse.y <= dlgBtnY + scaledTile) {
|
|
ui->patchConfirmClear = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void uiLfoPanel(UIState *ui, int baseId, float x, float y, float width, float height,
|
|
LFO *lfo, const char *title)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, DARKGRAY);
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT(title, (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
float activeBtnX = x + width - scaledTile - padding;
|
|
float activeBtnY = y + 2;
|
|
drawTile(ui, lfo->active ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, activeBtnX, activeBtnY, WHITE);
|
|
DRAW_TEXT("Active", (int)(activeBtnX - MEASURE_TEXT("Active", fontSize) - 4),
|
|
(int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= activeBtnX && mouse.x <= activeBtnX + scaledTile &&
|
|
mouse.y >= activeBtnY && mouse.y <= activeBtnY + scaledTile) {
|
|
lfo->active = !lfo->active;
|
|
}
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
DRAW_TEXT("Wave", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
contentY += fontSize + 4;
|
|
lfo->waveform = (Waveform)uiWaveformSelector(ui, x + padding, contentY, lfo->waveform);
|
|
contentY += scaledTile + padding * 2;
|
|
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
float knobSize = KNOB_SIZE * SPRITE_SCALE;
|
|
float centerX = x + width * 0.5f;
|
|
uiKnob(ui, baseId,
|
|
centerX - knobSize * 0.5f, contentY,
|
|
&lfo->rate, 0.01f, 20.0f, "Rate Hz");
|
|
}
|
|
|
|
void uiFilterPanel(UIState *ui, float x, float y, float width, float height,
|
|
Filter *filter, const char *title)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, DARKGRAY);
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT(title, (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
float activeBtnX = x + width - scaledTile - padding;
|
|
float activeBtnY = y + 2;
|
|
drawTile(ui, filter->active ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, activeBtnX, activeBtnY, WHITE);
|
|
DRAW_TEXT("Active", (int)(activeBtnX - MEASURE_TEXT("Active", fontSize) - 4),
|
|
(int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= activeBtnX && mouse.x <= activeBtnX + scaledTile &&
|
|
mouse.y >= activeBtnY && mouse.y <= activeBtnY + scaledTile) {
|
|
filter->active = !filter->active;
|
|
}
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
|
|
// Filter type selector
|
|
DRAW_TEXT("Type", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
contentY += fontSize + 4;
|
|
const char *typeLabels[] = { "LP", "HP", "BP" };
|
|
for (int t = 0; t < FILTER_COUNT; t++) {
|
|
float bx = x + padding + t * (scaledTile + 2);
|
|
bool active = (filter->type == (FilterType)t);
|
|
drawTile(ui, active ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, bx, contentY,
|
|
active ? GREEN : GRAY);
|
|
DRAW_TEXT(typeLabels[t], (int)(bx + 1), (int)(contentY + (scaledTile - 8) * 0.5f), 8, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= bx && mouse.x <= bx + scaledTile &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
filter->type = (FilterType)t;
|
|
}
|
|
}
|
|
contentY += scaledTile + padding;
|
|
|
|
// Cutoff and resonance knobs
|
|
float knobSize = KNOB_SIZE * SPRITE_SCALE;
|
|
float knobSpacing = (width - padding * 2) / 2.0f;
|
|
float knobOffset = knobSpacing * 0.5f - knobSize * 0.5f;
|
|
uiKnob(ui, 400,
|
|
x + padding + knobSpacing * 0.0f + knobOffset, contentY,
|
|
&filter->cutoff, 20.0f, 20000.0f, "Cutoff");
|
|
uiKnob(ui, 401,
|
|
x + padding + knobSpacing * 1.0f + knobOffset, contentY,
|
|
&filter->resonance, 0.0f, 0.99f, "Res");
|
|
contentY += knobSize + fontSize + padding * 2;
|
|
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// Cutoff mod routing
|
|
DRAW_TEXT("Cutoff Mod", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
contentY += fontSize + 24;
|
|
|
|
float knobX = x + padding + MEASURE_TEXT("PWidth", fontSize) + 8;
|
|
float knobYm = contentY - knobSize * 0.25f;
|
|
uiKnob(ui, 402, knobX, knobYm,
|
|
&filter->modDepth, -20000.0f, 20000.0f, "");
|
|
|
|
float btnX = x + width - (scaledTile + 2) * 4 - padding;
|
|
const char *modLabels[] = { "E0", "E1", "L0", "L1" };
|
|
ModSource modSources[] = {
|
|
MOD_SOURCE_AMP_ENV,
|
|
MOD_SOURCE_MOD_ENV,
|
|
MOD_SOURCE_LFO,
|
|
MOD_SOURCE_LFO2
|
|
};
|
|
for (int b = 0; b < 4; b++) {
|
|
float bx = btnX + b * (scaledTile + 2);
|
|
bool enabled = (filter->modRouting == (int)modSources[b]);
|
|
drawTile(ui, enabled ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, bx, contentY,
|
|
enabled ? GREEN : GRAY);
|
|
DRAW_TEXT(modLabels[b], (int)(bx + 1), (int)(contentY + (scaledTile - 8) * 0.5f), 8, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= bx && mouse.x <= bx + scaledTile &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
filter->modRouting = enabled ? MOD_SOURCE_NONE : (int)modSources[b];
|
|
}
|
|
}
|
|
contentY += scaledTile + 8;
|
|
|
|
// Resonance mod routing
|
|
DRAW_TEXT("Res Mod", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
contentY += fontSize + 24;
|
|
|
|
float resKnobY = contentY - knobSize * 0.25f;
|
|
uiKnob(ui, 403, knobX, resKnobY,
|
|
&filter->resModDepth, -0.99f, 0.99f, "");
|
|
|
|
for (int b = 0; b < 4; b++) {
|
|
float bx = btnX + b * (scaledTile + 2);
|
|
bool enabled = (filter->resModRouting == (int)modSources[b]);
|
|
drawTile(ui, enabled ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, bx, contentY,
|
|
enabled ? GREEN : GRAY);
|
|
DRAW_TEXT(modLabels[b], (int)(bx + 1), (int)(contentY + (scaledTile - 8) * 0.5f), 8, RAYWHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= bx && mouse.x <= bx + scaledTile &&
|
|
mouse.y >= contentY && mouse.y <= contentY + scaledTile) {
|
|
filter->resModRouting = enabled ? MOD_SOURCE_NONE : (int)modSources[b];
|
|
}
|
|
}
|
|
}
|
|
|
|
void uiMasterPanel(UIState *ui, float x, float y, float width, float height, Synth *s)
|
|
{
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float padding = 6.0f;
|
|
int fontSize = 8;
|
|
|
|
// Check every frame: has MIDI thread reported a CC for learn?
|
|
if (ui->midiLearnActive == 2) {
|
|
int cc = atomic_load(&ui->midi->midiLearnCC);
|
|
if (cc >= 0) {
|
|
ui->midi->ccMappings[cc].active = 1;
|
|
ui->midi->ccMappings[cc].valuePtr = ui->midiLearnValuePtr;
|
|
ui->midi->ccMappings[cc].min = ui->midiLearnMin;
|
|
ui->midi->ccMappings[cc].max = ui->midiLearnMax;
|
|
ui->midi->ccMappings[cc].controlId = ui->midiLearnTargetId;
|
|
atomic_store(&ui->midi->midiLearnMode, 0);
|
|
atomic_store(&ui->midi->midiLearnCC, -1);
|
|
ui->midiLearnActive = 0;
|
|
ui->midiLearnTargetId = -1;
|
|
ui->midiLearnValuePtr = NULL;
|
|
uiSaveCcMappings(ui);
|
|
}
|
|
}
|
|
|
|
// Panel background and border
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, DARKGRAY);
|
|
|
|
// Title bar
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT("Master", (int)(x + padding), (int)(y + (TITLE_BAR_H - fontSize) / 2), fontSize, RAYWHITE);
|
|
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
float centerX = x + width * 0.5f;
|
|
float knobSize = KNOB_SIZE * SPRITE_SCALE;
|
|
|
|
// Volume knob centered in panel
|
|
float knobX = centerX - knobSize * 0.5f;
|
|
uiKnob(ui, 300, knobX, contentY, &s->volume, 0.0f, 1.0f, "Volume");
|
|
contentY += knobSize + fontSize + padding * 2;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// Pitch bend range knob — centered in left half of the row
|
|
float knobSpacing = (width - padding * 2) / 2.0f;
|
|
uiKnob(ui, 301,
|
|
x + padding + knobSpacing * 0.5f - knobSize * 0.5f, contentY,
|
|
&s->pitchBendRange, 1.0f, 24.0f, "PB Range");
|
|
|
|
// Voice meter
|
|
float meterX = x + padding + knobSpacing;
|
|
float meterY = contentY;
|
|
DRAW_TEXT("Voices", (int)meterX, (int)meterY, fontSize, GRAY);
|
|
meterY += fontSize + 4;
|
|
|
|
// Two rows of 4 voices each
|
|
for (int i = 0; i < VOICE_COUNT; i++) {
|
|
float bx = meterX + (i % 4) * (scaledTile + 2);
|
|
float by = meterY + (i / 4) * (scaledTile + 2);
|
|
Color tint = s->voices[i].active ? GREEN : GRAY;
|
|
drawTile(ui, s->voices[i].active ? SPRITE_BTN_GREEN : SPRITE_BTN_RED,
|
|
bx, by, tint);
|
|
}
|
|
|
|
contentY += knobSize + fontSize + padding * 2;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += padding;
|
|
|
|
// MIDI section — button opens device picker
|
|
DRAW_TEXT("MIDI", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
|
|
bool connected = (ui->midi->connectedClient >= 0);
|
|
float midiBtnX = x + width - scaledTile - padding;
|
|
drawTile(ui, connected ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, midiBtnX, contentY, WHITE);
|
|
|
|
Vector2 midiMouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
midiMouse.x >= midiBtnX && midiMouse.x <= midiBtnX + scaledTile &&
|
|
midiMouse.y >= contentY && midiMouse.y <= contentY + scaledTile) {
|
|
midiScanInputs(ui->midi);
|
|
ui->midiMenuOpen = 1;
|
|
}
|
|
contentY += fontSize + 4;
|
|
|
|
if (connected) {
|
|
const char *devName = "...";
|
|
for (int i = 0; i < ui->midi->inputCount; i++) {
|
|
if (ui->midi->inputs[i].client == ui->midi->connectedClient &&
|
|
ui->midi->inputs[i].port == ui->midi->connectedPort) {
|
|
devName = ui->midi->inputs[i].clientName;
|
|
break;
|
|
}
|
|
}
|
|
DRAW_TEXT(devName, (int)(x + padding), (int)contentY, fontSize, GREEN);
|
|
} else {
|
|
DRAW_TEXT("Not connected", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
}
|
|
contentY += fontSize + 4;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += 1 + padding;
|
|
|
|
// Patches section
|
|
DRAW_TEXT("Patches", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
|
|
float patchBtnX = x + width - scaledTile - padding;
|
|
drawTile(ui, SPRITE_BTN_GREEN, patchBtnX, contentY, WHITE);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
midiMouse.x >= patchBtnX && midiMouse.x <= patchBtnX + scaledTile &&
|
|
midiMouse.y >= contentY && midiMouse.y <= contentY + scaledTile) {
|
|
patchRescan(ui);
|
|
ui->patchMenuOpen = 1;
|
|
}
|
|
contentY += fontSize + 4;
|
|
|
|
if (ui->patchCurrentName[0]) {
|
|
DRAW_TEXT(ui->patchCurrentName, (int)(x + padding), (int)contentY, fontSize, GREEN);
|
|
} else {
|
|
DRAW_TEXT("No patch", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
}
|
|
contentY += fontSize + 4;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += 1 + padding;
|
|
|
|
// MIDI Learn section
|
|
DRAW_TEXT("MIDI Lrn", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
|
|
int learnOn = (ui->midiLearnActive > 0);
|
|
int pulse = (int)(GetTime() * 4.0) % 2;
|
|
Color learnColor = learnOn ? (pulse ? YELLOW : (Color){180, 180, 0, 255}) : GRAY;
|
|
float learnBtnX = x + width - scaledTile - padding;
|
|
drawTile(ui, learnOn ? SPRITE_BTN_GREEN : SPRITE_BTN_RED, learnBtnX, contentY,
|
|
learnColor);
|
|
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
midiMouse.x >= learnBtnX && midiMouse.x <= learnBtnX + scaledTile &&
|
|
midiMouse.y >= contentY && midiMouse.y <= contentY + scaledTile) {
|
|
if (ui->midiLearnActive > 0) {
|
|
// Cancel learn
|
|
atomic_store(&ui->midi->midiLearnMode, 0);
|
|
atomic_store(&ui->midi->midiLearnCC, -1);
|
|
ui->midiLearnActive = 0;
|
|
ui->midiLearnTargetId = -1;
|
|
ui->midiLearnValuePtr = NULL;
|
|
} else {
|
|
ui->midiLearnActive = 1;
|
|
ui->midiLearnTargetId = -1;
|
|
}
|
|
}
|
|
contentY += fontSize + 4;
|
|
|
|
const char *learnStatus = "";
|
|
if (ui->midiLearnActive == 1) learnStatus = "Click a ctrl";
|
|
else if (ui->midiLearnActive == 2) learnStatus = "Wiggle CC...";
|
|
DRAW_TEXT(learnStatus, (int)(x + padding), (int)contentY, fontSize, YELLOW);
|
|
contentY += fontSize + 4;
|
|
|
|
// Divider
|
|
DrawLine((int)(x + padding), (int)contentY,
|
|
(int)(x + width - padding), (int)contentY, DARKGRAY);
|
|
contentY += 1 + padding;
|
|
|
|
// About button
|
|
DRAW_TEXT("About", (int)(x + padding), (int)contentY, fontSize, GRAY);
|
|
float aboutBtnX = x + width - scaledTile - padding;
|
|
drawTile(ui, SPRITE_BTN_GREEN, aboutBtnX, contentY, WHITE);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
midiMouse.x >= aboutBtnX && midiMouse.x <= aboutBtnX + scaledTile &&
|
|
midiMouse.y >= contentY && midiMouse.y <= contentY + scaledTile) {
|
|
ui->aboutMenuOpen = 1;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ ABOUT MENU
|
|
|
|
void uiAboutMenu(UIState *ui, float x, float y, float width)
|
|
{
|
|
if (!ui->aboutMenuOpen) return;
|
|
|
|
/* Edit these strings to update the about screen. */
|
|
static const char *lines[] = {
|
|
"SoundThing v0.9.5",
|
|
"",
|
|
"Polyphonic subtractive synthesizer",
|
|
"Built with Raylib",
|
|
"",
|
|
"by Jake the Anachronaut",
|
|
"",
|
|
"",
|
|
/* Add a project URL here: */
|
|
"github.com/RealBusinessAccount",
|
|
};
|
|
const int lineCount = (int)(sizeof(lines) / sizeof(lines[0]));
|
|
|
|
int fontSize = 8;
|
|
float padding = 8.0f;
|
|
float scaledTile = TILE_SIZE * SPRITE_SCALE;
|
|
float lineH = (float)(fontSize + 6);
|
|
float height = (float)TITLE_BAR_H + padding
|
|
+ lineCount * lineH + padding;
|
|
|
|
/* Full-screen dim */
|
|
{
|
|
float invZ = 1.0f / ui->camera->zoom;
|
|
Vector2 org = GetScreenToWorld2D((Vector2){0.0f, 0.0f}, *ui->camera);
|
|
DrawRectangle((int)org.x, (int)org.y,
|
|
(int)(GetScreenWidth() * invZ),
|
|
(int)(GetScreenHeight() * invZ),
|
|
(Color){0, 0, 0, 140});
|
|
}
|
|
|
|
/* Panel background */
|
|
DrawRectangle((int)x, (int)y, (int)width, (int)height, (Color){30, 30, 30, 255});
|
|
DrawRectangleLines((int)x, (int)y, (int)width, (int)height, LIGHTGRAY);
|
|
|
|
/* Title bar */
|
|
DrawRectangle((int)x, (int)y, (int)width, TITLE_BAR_H, (Color){50, 50, 50, 255});
|
|
DRAW_TEXT("About SoundThing",
|
|
(int)(x + padding),
|
|
(int)(y + (TITLE_BAR_H - fontSize) / 2),
|
|
fontSize, RAYWHITE);
|
|
|
|
/* Close button */
|
|
float closeBtnX = x + width - scaledTile - padding;
|
|
float closeBtnY = y + (TITLE_BAR_H - scaledTile) / 2.0f;
|
|
drawTile(ui, SPRITE_BTN_RED, closeBtnX, closeBtnY, WHITE);
|
|
DRAW_TEXT("X",
|
|
(int)(closeBtnX + (scaledTile - MEASURE_TEXT("X", fontSize)) * 0.5f),
|
|
(int)(closeBtnY + (scaledTile - fontSize) * 0.5f),
|
|
fontSize, RAYWHITE);
|
|
|
|
Vector2 mouse = GetScreenToWorld2D(GetMousePosition(), *ui->camera);
|
|
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) &&
|
|
mouse.x >= closeBtnX && mouse.x <= closeBtnX + scaledTile &&
|
|
mouse.y >= closeBtnY && mouse.y <= closeBtnY + scaledTile) {
|
|
ui->aboutMenuOpen = 0;
|
|
}
|
|
|
|
/* Content lines */
|
|
float contentY = y + TITLE_BAR_H + padding;
|
|
for (int i = 0; i < lineCount; i++) {
|
|
if (lines[i][0] != '\0')
|
|
DRAW_TEXT(lines[i], (int)(x + padding), (int)contentY, fontSize, RAYWHITE);
|
|
contentY += lineH;
|
|
}
|
|
}
|
|
|