Files
soundThing/source/ui.c
Anachronaut e40645f280 Fix patch browser keyboard navigation to cover directory rows
Arrow keys now navigate the full virtual row list (../, subdirs, patches)
rather than only patch rows. Landing on a patch auto-loads it as before;
landing on .. or a subdir highlights it and Enter confirms navigation.
Added patchVCursor to UIState to track keyboard focus independently of
the loaded patch. Mouse-click on a patch now also syncs the cursor so
subsequent arrow-key movement starts from the right position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:32:46 -04:00

1760 lines
72 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->patchVCursor = -1;
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;
ui->patchVCursor = -1;
}
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 over all virtual rows: [..], subdirs, patches
if (ui->focusedTextInput == -1 && !ui->patchConfirmOverwrite && !ui->patchConfirmClear
&& totalVirtual > 0) {
// Sync cursor from currently loaded patch if unset
if (ui->patchVCursor < 0 && ui->patchCurrentName[0]) {
for (int fi = 0; fi < filteredCount; fi++) {
if (strcmp(ui->patchFiles[filteredIdx[fi]], ui->patchCurrentName) == 0) {
ui->patchVCursor = fi + backRows + ui->patchSubDirCount;
break;
}
}
}
int next = ui->patchVCursor;
if (IsKeyPressed(KEY_DOWN))
next = (next < 0) ? 0 : (next < totalVirtual - 1 ? next + 1 : next);
if (IsKeyPressed(KEY_UP))
next = (next < 0) ? totalVirtual - 1 : (next > 0 ? next - 1 : next);
if (next != ui->patchVCursor) {
ui->patchVCursor = next;
// Auto-load when landing on a patch row
if (next >= backRows + ui->patchSubDirCount && filteredCount > 0) {
int fi = next - backRows - ui->patchSubDirCount;
int i = filteredIdx[fi];
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]);
}
}
}
// Enter activates navigation rows (.. and subdirs)
if (IsKeyPressed(KEY_ENTER) && ui->patchVCursor >= 0) {
int vc = ui->patchVCursor;
if (!isRoot && vc == 0) {
char *lastSlash = strrchr(ui->patchCurrentDir, '/');
if (lastSlash) *lastSlash = '\0';
else ui->patchCurrentDir[0] = '\0';
ui->patchCurrentName[0] = '\0';
patchRescan(ui);
return;
} else if (vc >= backRows && vc < backRows + ui->patchSubDirCount) {
int d = vc - backRows;
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;
}
}
// Scroll to keep cursor visible
if (ui->patchVCursor >= 0) {
if (ui->patchVCursor < ui->patchScrollOffset)
ui->patchScrollOffset = ui->patchVCursor;
if (ui->patchVCursor >= ui->patchScrollOffset + MAX_VIS)
ui->patchScrollOffset = ui->patchVCursor - 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
Color dotBg = (ui->patchVCursor == r) ? (Color){40, 60, 100, 255} : (Color){20, 30, 50, 255};
Color dotEdge = (ui->patchVCursor == r) ? SKYBLUE : (Color){80, 120, 180, 255};
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), dotBg);
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), dotEdge);
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]);
Color dirBg = (ui->patchVCursor == r) ? (Color){40, 60, 100, 255} : (Color){20, 30, 50, 255};
Color dirEdge = (ui->patchVCursor == r) ? SKYBLUE : (Color){80, 120, 180, 255};
DrawRectangle((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), dirBg);
DrawRectangleLines((int)(x + padding), (int)contentY, (int)btnW, (int)(rowH - 2), dirEdge);
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]);
ui->patchVCursor = r;
}
}
}
}
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;
}
}