begin macos port

This commit is contained in:
2026-03-03 10:00:38 -05:00
parent 7e298faadd
commit ad30ca8cb7
13 changed files with 2205 additions and 124 deletions

View File

@@ -0,0 +1,343 @@
#include "audio/audio.h"
#include <AudioToolbox/AudioToolbox.h>
#include <CoreAudio/CoreAudio.h>
#include <string.h>
#include <math.h>
#include <stdatomic.h>
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
#define AUDIO_MAX_DEVICES 32
#define AUDIO_MAX_CHANNELS 32
#define AUDIO_TEST_TONE_HZ 440.0
#define AUDIO_TEST_TONE_SEC 2.0
#define AUDIO_PI 3.14159265358979323846
struct CoreAudioDeviceInfo {
AudioDeviceID device_id;
};
struct AudioEngine {
AudioDeviceInfo devices[AUDIO_MAX_DEVICES];
CoreAudioDeviceInfo ca_devices[AUDIO_MAX_DEVICES];
int32_t device_count;
AUGraph graph;
AudioUnit output_unit;
int32_t active_device_index;
double sample_rate;
int32_t num_channels;
// Test tone state (accessed from audio render thread)
_Atomic int32_t test_tone_active;
_Atomic int32_t test_tone_samples_remaining;
double test_tone_phase;
};
////////////////////////////////
// Audio render callback
static AudioEngine *g_audio_engine = nullptr;
static OSStatus audio_render_callback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
{
(void)inRefCon; (void)ioActionFlags; (void)inTimeStamp; (void)inBusNumber;
AudioEngine *engine = g_audio_engine;
if (!engine) {
for (UInt32 buf = 0; buf < ioData->mNumberBuffers; buf++)
memset(ioData->mBuffers[buf].mData, 0, ioData->mBuffers[buf].mDataByteSize);
return noErr;
}
int32_t tone_active = atomic_load(&engine->test_tone_active);
if (!tone_active) {
for (UInt32 buf = 0; buf < ioData->mNumberBuffers; buf++)
memset(ioData->mBuffers[buf].mData, 0, ioData->mBuffers[buf].mDataByteSize);
return noErr;
}
double phase = engine->test_tone_phase;
double phase_inc = 2.0 * AUDIO_PI * AUDIO_TEST_TONE_HZ / engine->sample_rate;
int32_t remaining = atomic_load(&engine->test_tone_samples_remaining);
int32_t samples_to_gen = (remaining < (int32_t)inNumberFrames) ? remaining : (int32_t)inNumberFrames;
// CoreAudio with kAudioFormatFlagIsNonInterleaved: each buffer = one channel
for (UInt32 buf = 0; buf < ioData->mNumberBuffers; buf++) {
float *out = (float *)ioData->mBuffers[buf].mData;
double p = phase;
for (UInt32 s = 0; s < inNumberFrames; s++) {
if ((int32_t)s < samples_to_gen) {
out[s] = (float)(sin(p) * 0.5); // -6dB
p += phase_inc;
if (p >= 2.0 * AUDIO_PI) p -= 2.0 * AUDIO_PI;
} else {
out[s] = 0.0f;
}
}
}
// Advance phase using first channel's traversal
phase += phase_inc * samples_to_gen;
while (phase >= 2.0 * AUDIO_PI) phase -= 2.0 * AUDIO_PI;
engine->test_tone_phase = phase;
int32_t new_remaining = atomic_fetch_sub(&engine->test_tone_samples_remaining, samples_to_gen);
if (new_remaining <= samples_to_gen) {
atomic_store(&engine->test_tone_active, 0);
atomic_store(&engine->test_tone_samples_remaining, 0);
}
return noErr;
}
////////////////////////////////
// Device enumeration
static void enumerate_output_devices(AudioEngine *engine) {
engine->device_count = 0;
AudioObjectPropertyAddress prop = {
kAudioHardwarePropertyDevices,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
UInt32 data_size = 0;
if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &prop, 0, nullptr, &data_size) != noErr)
return;
int device_count = (int)(data_size / sizeof(AudioDeviceID));
if (device_count <= 0) return;
AudioDeviceID *device_ids = (AudioDeviceID *)malloc(data_size);
if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &prop, 0, nullptr, &data_size, device_ids) != noErr) {
free(device_ids);
return;
}
for (int i = 0; i < device_count && engine->device_count < AUDIO_MAX_DEVICES; i++) {
// Check if device has output channels
AudioObjectPropertyAddress stream_prop = {
kAudioDevicePropertyStreamConfiguration,
kAudioDevicePropertyScopeOutput,
kAudioObjectPropertyElementMain
};
UInt32 stream_size = 0;
if (AudioObjectGetPropertyDataSize(device_ids[i], &stream_prop, 0, nullptr, &stream_size) != noErr)
continue;
AudioBufferList *buf_list = (AudioBufferList *)malloc(stream_size);
if (AudioObjectGetPropertyData(device_ids[i], &stream_prop, 0, nullptr, &stream_size, buf_list) != noErr) {
free(buf_list);
continue;
}
int output_channels = 0;
for (UInt32 b = 0; b < buf_list->mNumberBuffers; b++)
output_channels += (int)buf_list->mBuffers[b].mNumberChannels;
free(buf_list);
if (output_channels == 0) continue;
// Get device name
AudioObjectPropertyAddress name_prop = {
kAudioObjectPropertyName,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
CFStringRef name_ref = nullptr;
UInt32 name_size = sizeof(name_ref);
if (AudioObjectGetPropertyData(device_ids[i], &name_prop, 0, nullptr, &name_size, &name_ref) != noErr)
continue;
int32_t idx = engine->device_count++;
CFStringGetCString(name_ref, engine->devices[idx].name, sizeof(engine->devices[idx].name),
kCFStringEncodingUTF8);
CFRelease(name_ref);
engine->devices[idx].id = idx;
engine->ca_devices[idx].device_id = device_ids[i];
}
free(device_ids);
}
////////////////////////////////
// Public API
AudioEngine *audio_create(void *hwnd) {
(void)hwnd;
AudioEngine *engine = new AudioEngine();
memset(engine, 0, sizeof(*engine));
engine->active_device_index = -1;
atomic_store(&engine->test_tone_active, 0);
atomic_store(&engine->test_tone_samples_remaining, 0);
g_audio_engine = engine;
enumerate_output_devices(engine);
return engine;
}
void audio_destroy(AudioEngine *engine) {
audio_close_device(engine);
if (g_audio_engine == engine) g_audio_engine = nullptr;
delete engine;
}
void audio_refresh_devices(AudioEngine *engine) {
audio_close_device(engine);
enumerate_output_devices(engine);
}
int32_t audio_get_device_count(AudioEngine *engine) {
return engine->device_count;
}
AudioDeviceInfo *audio_get_device(AudioEngine *engine, int32_t index) {
if (index < 0 || index >= engine->device_count) return nullptr;
return &engine->devices[index];
}
bool audio_open_device(AudioEngine *engine, int32_t index) {
audio_close_device(engine);
if (index < 0 || index >= engine->device_count) return false;
AudioDeviceID device_id = engine->ca_devices[index].device_id;
// Create AUGraph
if (NewAUGraph(&engine->graph) != noErr) return false;
// Add HAL output node
AudioComponentDescription output_desc = {};
output_desc.componentType = kAudioUnitType_Output;
output_desc.componentSubType = kAudioUnitSubType_HALOutput;
output_desc.componentManufacturer = kAudioUnitManufacturer_Apple;
AUNode output_node;
if (AUGraphAddNode(engine->graph, &output_desc, &output_node) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
if (AUGraphOpen(engine->graph) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
if (AUGraphNodeInfo(engine->graph, output_node, nullptr, &engine->output_unit) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
// Set device
if (AudioUnitSetProperty(engine->output_unit, kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0, &device_id, sizeof(device_id)) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
// Get device sample rate
AudioObjectPropertyAddress rate_prop = {
kAudioDevicePropertyNominalSampleRate,
kAudioDevicePropertyScopeOutput,
kAudioObjectPropertyElementMain
};
Float64 sample_rate = 44100.0;
UInt32 rate_size = sizeof(sample_rate);
AudioObjectGetPropertyData(device_id, &rate_prop, 0, nullptr, &rate_size, &sample_rate);
engine->sample_rate = sample_rate;
// Set stream format: Float32, non-interleaved
AudioStreamBasicDescription fmt = {};
fmt.mSampleRate = sample_rate;
fmt.mFormatID = kAudioFormatLinearPCM;
fmt.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved | kAudioFormatFlagIsPacked;
fmt.mBitsPerChannel = 32;
fmt.mChannelsPerFrame = 2;
fmt.mFramesPerPacket = 1;
fmt.mBytesPerFrame = 4;
fmt.mBytesPerPacket = 4;
engine->num_channels = 2;
AudioUnitSetProperty(engine->output_unit, kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Input, 0, &fmt, sizeof(fmt));
// Set render callback
AURenderCallbackStruct cb = {};
cb.inputProc = audio_render_callback;
cb.inputProcRefCon = engine;
if (AudioUnitSetProperty(engine->output_unit, kAudioUnitProperty_SetRenderCallback,
kAudioUnitScope_Input, 0, &cb, sizeof(cb)) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
// Initialize and start
if (AUGraphInitialize(engine->graph) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
if (AUGraphStart(engine->graph) != noErr) {
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
return false;
}
engine->active_device_index = index;
engine->test_tone_phase = 0.0;
atomic_store(&engine->test_tone_active, 0);
atomic_store(&engine->test_tone_samples_remaining, 0);
return true;
}
void audio_close_device(AudioEngine *engine) {
if (!engine->graph) return;
atomic_store(&engine->test_tone_active, 0);
atomic_store(&engine->test_tone_samples_remaining, 0);
AUGraphStop(engine->graph);
AUGraphUninitialize(engine->graph);
DisposeAUGraph(engine->graph);
engine->graph = nullptr;
engine->output_unit = nullptr;
engine->active_device_index = -1;
engine->test_tone_phase = 0.0;
}
void audio_play_test_tone(AudioEngine *engine) {
if (!engine->graph) return;
engine->test_tone_phase = 0.0;
int32_t total_samples = (int32_t)(engine->sample_rate * AUDIO_TEST_TONE_SEC);
atomic_store(&engine->test_tone_samples_remaining, total_samples);
atomic_store(&engine->test_tone_active, 1);
}
bool audio_is_test_tone_playing(AudioEngine *engine) {
return atomic_load(&engine->test_tone_active) != 0;
}
void audio_update(AudioEngine *engine, float dt) {
(void)engine;
(void)dt;
}

View File

@@ -11,8 +11,10 @@
////////////////////////////////
// Codebase keywords
#ifndef __APPLE__
#define internal static
#define global static
#endif
#define local_persist static
////////////////////////////////

View File

@@ -1,5 +1,8 @@
// Unity build - include all src files here
// -mta
#ifdef __APPLE__
#include <mach/mach_time.h>
#endif
// [h]
#include "base/base_inc.h"
#include "platform/platform.h"
@@ -14,10 +17,17 @@
#include "base/base_inc.cpp"
#include "ui/ui_core.cpp"
#include "ui/ui_widgets.cpp"
#ifdef __APPLE__
#include "platform/platform_macos.mm"
#include "renderer/renderer_metal.mm"
#include "midi/midi_coremidi.cpp"
#include "audio/audio_coreaudio.cpp"
#else
#include "platform/platform_win32.cpp"
#include "renderer/renderer_dx12.cpp"
#include "midi/midi_win32.cpp"
#include "audio/audio_asio.cpp"
#endif
#include "menus.cpp"
////////////////////////////////
@@ -58,8 +68,14 @@ struct AppState {
B32 show_props;
B32 show_log;
B32 show_midi_devices;
#ifdef __APPLE__
uint64_t freq_numer;
uint64_t freq_denom;
uint64_t last_time;
#else
LARGE_INTEGER freq;
LARGE_INTEGER last_time;
#endif
// Tab state
S32 right_panel_tab; // 0 = Properties, 1 = MIDI Devices
@@ -643,10 +659,16 @@ static void build_ui(AppState *app) {
static void do_frame(AppState *app) {
// Timing
#ifdef __APPLE__
uint64_t now = mach_absolute_time();
F32 dt = (F32)(now - app->last_time) * (F32)app->freq_numer / ((F32)app->freq_denom * 1e9f);
app->last_time = now;
#else
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
F32 dt = (F32)(now.QuadPart - app->last_time.QuadPart) / (F32)app->freq.QuadPart;
app->last_time = now;
#endif
if (dt > 0.1f) dt = 0.1f;
// Resize
@@ -737,9 +759,15 @@ int main(int argc, char **argv) {
app.show_midi_devices = 1;
app.demo_dropdown_sel = 1; // default to 48000 Hz
snprintf(app.demo_text_a, sizeof(app.demo_text_a), "My Instrument");
#ifdef __APPLE__
snprintf(app.demo_text_b, sizeof(app.demo_text_b), "~/Samples/output");
{ mach_timebase_info_data_t tbi; mach_timebase_info(&tbi); app.freq_numer = tbi.numer; app.freq_denom = tbi.denom; }
app.last_time = mach_absolute_time();
#else
snprintf(app.demo_text_b, sizeof(app.demo_text_b), "C:\\Samples\\output");
QueryPerformanceFrequency(&app.freq);
QueryPerformanceCounter(&app.last_time);
#endif
platform_set_frame_callback(window, frame_callback, &app);

249
src/midi/midi_coremidi.cpp Normal file
View File

@@ -0,0 +1,249 @@
#include "midi/midi.h"
#include <CoreMIDI/CoreMIDI.h>
#include <CoreFoundation/CoreFoundation.h>
#include <string.h>
#include <stdatomic.h>
#define MIDI_MAX_DEVICES 64
#define MIDI_RELEASE_FLASH_DURATION 0.15f
struct MidiEngine {
MidiDeviceInfo devices[MIDI_MAX_DEVICES];
int32_t device_count;
MIDIClientRef client;
MIDIPortRef input_port;
// Map: source endpoint index -> our device array index
int32_t source_to_device[MIDI_MAX_DEVICES];
// Set atomically from callback thread
_Atomic int32_t pending_note_on_vel[MIDI_MAX_DEVICES];
_Atomic int32_t pending_note_num[MIDI_MAX_DEVICES];
_Atomic int32_t pending_note_off[MIDI_MAX_DEVICES];
_Atomic int32_t held_note_count[MIDI_MAX_DEVICES];
// Main thread only
int32_t display_velocity[MIDI_MAX_DEVICES];
int32_t display_note[MIDI_MAX_DEVICES];
float release_timers[MIDI_MAX_DEVICES];
};
////////////////////////////////
// MIDI read callback
static void midi_read_callback(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon) {
(void)readProcRefCon;
MidiEngine *engine = (MidiEngine *)readProcRefCon;
int32_t device_idx = (int32_t)(intptr_t)srcConnRefCon;
if (!engine || device_idx < 0 || device_idx >= MIDI_MAX_DEVICES) return;
const MIDIPacket *packet = &pktlist->packet[0];
for (UInt32 i = 0; i < pktlist->numPackets; i++) {
// Parse MIDI bytes
for (UInt16 j = 0; j < packet->length; ) {
uint8_t status = packet->data[j];
// Skip non-status bytes (running status not handled for simplicity)
if (status < 0x80) { j++; continue; }
uint8_t kind = status & 0xF0;
if (kind == 0x90 && j + 2 < packet->length) {
uint8_t note = packet->data[j + 1];
uint8_t velocity = packet->data[j + 2];
j += 3;
if (velocity > 0) {
atomic_store(&engine->pending_note_on_vel[device_idx], (int32_t)velocity);
atomic_store(&engine->pending_note_num[device_idx], (int32_t)(note + 1));
atomic_fetch_add(&engine->held_note_count[device_idx], 1);
} else {
// Note-on with velocity 0 = note-off
atomic_store(&engine->pending_note_off[device_idx], 1);
int32_t count = atomic_fetch_sub(&engine->held_note_count[device_idx], 1);
if (count <= 1) atomic_store(&engine->held_note_count[device_idx], 0);
}
} else if (kind == 0x80 && j + 2 < packet->length) {
j += 3;
atomic_store(&engine->pending_note_off[device_idx], 1);
int32_t count = atomic_fetch_sub(&engine->held_note_count[device_idx], 1);
if (count <= 1) atomic_store(&engine->held_note_count[device_idx], 0);
} else if (kind == 0xC0 || kind == 0xD0) {
j += 2; // Program Change, Channel Pressure (2 bytes)
} else if (kind == 0xF0) {
// System messages — skip to end or next status byte
j++;
while (j < packet->length && packet->data[j] < 0x80) j++;
} else {
j += 3; // Default: 3-byte message
}
}
packet = MIDIPacketNext(packet);
}
}
////////////////////////////////
// Device enumeration
static void enumerate_midi_devices(MidiEngine *engine) {
engine->device_count = 0;
// Input sources
ItemCount num_sources = MIDIGetNumberOfSources();
for (ItemCount i = 0; i < num_sources && engine->device_count < MIDI_MAX_DEVICES; i++) {
MIDIEndpointRef endpoint = MIDIGetSource((ItemCount)i);
CFStringRef name_ref = nullptr;
MIDIObjectGetStringProperty(endpoint, kMIDIPropertyName, &name_ref);
MidiDeviceInfo *dev = &engine->devices[engine->device_count];
memset(dev, 0, sizeof(*dev));
if (name_ref) {
CFStringGetCString(name_ref, dev->name, sizeof(dev->name), kCFStringEncodingUTF8);
CFRelease(name_ref);
} else {
snprintf(dev->name, sizeof(dev->name), "MIDI Source %d", (int)i);
}
dev->id = engine->device_count;
dev->is_input = true;
dev->active = false;
engine->source_to_device[i] = engine->device_count;
engine->device_count++;
}
// Output destinations
ItemCount num_dests = MIDIGetNumberOfDestinations();
for (ItemCount i = 0; i < num_dests && engine->device_count < MIDI_MAX_DEVICES; i++) {
MIDIEndpointRef endpoint = MIDIGetDestination((ItemCount)i);
CFStringRef name_ref = nullptr;
MIDIObjectGetStringProperty(endpoint, kMIDIPropertyName, &name_ref);
MidiDeviceInfo *dev = &engine->devices[engine->device_count];
memset(dev, 0, sizeof(*dev));
if (name_ref) {
CFStringGetCString(name_ref, dev->name, sizeof(dev->name), kCFStringEncodingUTF8);
CFRelease(name_ref);
} else {
snprintf(dev->name, sizeof(dev->name), "MIDI Dest %d", (int)i);
}
dev->id = engine->device_count;
dev->is_input = false;
dev->active = false;
engine->device_count++;
}
}
////////////////////////////////
// Public API
MidiEngine *midi_create() {
MidiEngine *engine = new MidiEngine();
memset(engine, 0, sizeof(*engine));
MIDIClientCreate(CFSTR("autosample"), nullptr, nullptr, &engine->client);
MIDIInputPortCreate(engine->client, CFSTR("Input"), midi_read_callback, engine, &engine->input_port);
midi_refresh_devices(engine);
return engine;
}
void midi_destroy(MidiEngine *engine) {
midi_close_all_inputs(engine);
if (engine->input_port) MIDIPortDispose(engine->input_port);
if (engine->client) MIDIClientDispose(engine->client);
delete engine;
}
void midi_open_all_inputs(MidiEngine *engine) {
ItemCount num_sources = MIDIGetNumberOfSources();
for (ItemCount i = 0; i < num_sources && (int32_t)i < MIDI_MAX_DEVICES; i++) {
MIDIEndpointRef endpoint = MIDIGetSource(i);
int32_t dev_idx = engine->source_to_device[i];
MIDIPortConnectSource(engine->input_port, endpoint, (void *)(intptr_t)dev_idx);
}
}
void midi_close_all_inputs(MidiEngine *engine) {
ItemCount num_sources = MIDIGetNumberOfSources();
for (ItemCount i = 0; i < num_sources; i++) {
MIDIEndpointRef endpoint = MIDIGetSource(i);
MIDIPortDisconnectSource(engine->input_port, endpoint);
}
for (int32_t i = 0; i < MIDI_MAX_DEVICES; i++) {
atomic_store(&engine->pending_note_on_vel[i], 0);
atomic_store(&engine->pending_note_num[i], 0);
atomic_store(&engine->pending_note_off[i], 0);
atomic_store(&engine->held_note_count[i], 0);
engine->display_velocity[i] = 0;
engine->display_note[i] = 0;
engine->release_timers[i] = 0.0f;
}
}
void midi_update(MidiEngine *engine, float dt) {
for (int32_t i = 0; i < engine->device_count; i++) {
if (!engine->devices[i].is_input) continue;
int32_t vel = atomic_exchange(&engine->pending_note_on_vel[i], 0);
int32_t note_p1 = atomic_exchange(&engine->pending_note_num[i], 0);
if (vel > 0) engine->display_velocity[i] = vel;
if (note_p1 > 0) engine->display_note[i] = note_p1 - 1;
int32_t off = atomic_exchange(&engine->pending_note_off[i], 0);
int32_t held = atomic_load(&engine->held_note_count[i]);
if (held > 0) {
engine->devices[i].active = true;
engine->devices[i].releasing = false;
engine->release_timers[i] = 0.0f;
} else if (off || (engine->devices[i].active && held <= 0)) {
engine->devices[i].active = false;
engine->devices[i].releasing = true;
engine->release_timers[i] = MIDI_RELEASE_FLASH_DURATION;
}
if (engine->release_timers[i] > 0.0f) {
engine->release_timers[i] -= dt;
if (engine->release_timers[i] <= 0.0f) {
engine->release_timers[i] = 0.0f;
engine->devices[i].releasing = false;
engine->display_velocity[i] = 0;
engine->display_note[i] = 0;
}
}
engine->devices[i].velocity = engine->display_velocity[i];
engine->devices[i].note = engine->display_note[i];
}
}
bool midi_is_input_active(MidiEngine *engine, int32_t device_index) {
if (device_index < 0 || device_index >= engine->device_count) return false;
return engine->devices[device_index].active;
}
void midi_refresh_devices(MidiEngine *engine) {
midi_close_all_inputs(engine);
enumerate_midi_devices(engine);
midi_open_all_inputs(engine);
}
int32_t midi_get_device_count(MidiEngine *engine) {
return engine->device_count;
}
MidiDeviceInfo *midi_get_device(MidiEngine *engine, int32_t index) {
if (index < 0 || index >= engine->device_count) return nullptr;
return &engine->devices[index];
}

View File

@@ -0,0 +1,426 @@
#include "platform/platform.h"
#import <Cocoa/Cocoa.h>
// macOS virtual key codes (avoids Carbon.h include)
enum {
kVK_ANSI_A = 0x00, kVK_ANSI_C = 0x08, kVK_ANSI_V = 0x09,
kVK_ANSI_X = 0x07,
kVK_Return = 0x24, kVK_Tab = 0x30, kVK_Delete = 0x33,
kVK_Escape = 0x35, kVK_ForwardDelete = 0x75,
kVK_LeftArrow = 0x7B, kVK_RightArrow = 0x7C,
kVK_DownArrow = 0x7D, kVK_UpArrow = 0x7E,
kVK_Home = 0x73, kVK_End = 0x77,
kVK_Command = 0x37, kVK_Shift = 0x38,
kVK_RightShift = 0x3C, kVK_RightCommand = 0x36,
};
static uint8_t macos_keycode_to_pkey(uint16_t keycode) {
switch (keycode) {
case kVK_ANSI_A: return PKEY_A;
case kVK_ANSI_C: return PKEY_C;
case kVK_ANSI_V: return PKEY_V;
case kVK_ANSI_X: return PKEY_X;
case kVK_Return: return PKEY_RETURN;
case kVK_Tab: return PKEY_TAB;
case kVK_Delete: return PKEY_BACKSPACE;
case kVK_ForwardDelete:return PKEY_DELETE;
case kVK_Escape: return PKEY_ESCAPE;
case kVK_LeftArrow: return PKEY_LEFT;
case kVK_RightArrow: return PKEY_RIGHT;
case kVK_UpArrow: return PKEY_UP;
case kVK_DownArrow: return PKEY_DOWN;
case kVK_Home: return PKEY_HOME;
case kVK_End: return PKEY_END;
default: return 0;
}
}
////////////////////////////////
// Forward declarations
struct PlatformWindow;
static PlatformWindow *g_current_window = nullptr;
////////////////////////////////
// Objective-C helper classes
@interface ASmplWindowDelegate : NSObject <NSWindowDelegate>
@end
@implementation ASmplWindowDelegate
- (BOOL)windowShouldClose:(id)sender {
(void)sender;
if (g_current_window) {
// Set should_close flag (accessed via the struct below)
extern void platform_macos_set_should_close();
platform_macos_set_should_close();
}
return NO; // We handle closing ourselves
}
- (void)windowDidResize:(NSNotification *)notification {
(void)notification;
if (!g_current_window) return;
extern void platform_macos_handle_resize();
platform_macos_handle_resize();
}
@end
@interface ASmplView : NSView <NSTextInputClient>
@end
@implementation ASmplView
- (BOOL)acceptsFirstResponder { return YES; }
- (BOOL)canBecomeKeyView { return YES; }
// Needed for NSTextInputClient
- (BOOL)hasMarkedText { return NO; }
- (NSRange)markedRange { return NSMakeRange(NSNotFound, 0); }
- (NSRange)selectedRange { return NSMakeRange(NSNotFound, 0); }
- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange {
(void)string; (void)selectedRange; (void)replacementRange;
}
- (void)unmarkText {}
- (NSArray<NSAttributedStringKey> *)validAttributesForMarkedText { return @[]; }
- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
(void)range; (void)actualRange;
return nil;
}
- (NSUInteger)characterIndexForPoint:(NSPoint)point { (void)point; return NSNotFound; }
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
(void)range; (void)actualRange;
return NSZeroRect;
}
- (void)insertText:(id)string replacementRange:(NSRange)replacementRange {
(void)replacementRange;
NSString *str = nil;
if ([string isKindOfClass:[NSAttributedString class]])
str = [string string];
else
str = (NSString *)string;
extern void platform_macos_insert_text(const char *utf8);
platform_macos_insert_text([str UTF8String]);
}
- (void)keyDown:(NSEvent *)event {
extern void platform_macos_key_down(uint16_t keycode, NSEventModifierFlags mods);
platform_macos_key_down([event keyCode], [event modifierFlags]);
// Feed into text input system for character generation
[self interpretKeyEvents:@[event]];
}
- (void)flagsChanged:(NSEvent *)event {
(void)event;
// Modifiers are read at key-down time, nothing to accumulate here
}
- (void)mouseDown:(NSEvent *)event {
(void)event;
extern void platform_macos_mouse_down();
platform_macos_mouse_down();
}
- (void)mouseUp:(NSEvent *)event {
(void)event;
extern void platform_macos_mouse_up();
platform_macos_mouse_up();
}
- (void)mouseMoved:(NSEvent *)event { (void)event; }
- (void)mouseDragged:(NSEvent *)event { (void)event; }
- (void)scrollWheel:(NSEvent *)event {
extern void platform_macos_scroll(float dx, float dy);
float dy = (float)[event scrollingDeltaY];
if ([event hasPreciseScrollingDeltas])
dy /= 40.0f; // Normalize trackpad deltas to match discrete wheel steps
platform_macos_scroll(0, dy);
}
- (BOOL)acceptsFirstMouse:(NSEvent *)event { (void)event; return YES; }
@end
////////////////////////////////
// PlatformWindow struct
struct PlatformWindow {
NSWindow *ns_window;
ASmplView *view;
ASmplWindowDelegate *delegate;
bool should_close;
int32_t width;
int32_t height;
int32_t pending_menu_cmd;
PlatformFrameCallback frame_callback;
void *frame_callback_user_data;
PlatformInput input;
B32 prev_mouse_down;
B32 mouse_down_state;
F32 backing_scale;
};
////////////////////////////////
// C callback helpers (called from ObjC)
void platform_macos_set_should_close() {
if (g_current_window) g_current_window->should_close = true;
}
void platform_macos_handle_resize() {
if (!g_current_window) return;
NSRect frame = [g_current_window->view bounds];
F32 scale = g_current_window->backing_scale;
g_current_window->width = (int32_t)(frame.size.width * scale);
g_current_window->height = (int32_t)(frame.size.height * scale);
if (g_current_window->frame_callback)
g_current_window->frame_callback(g_current_window->frame_callback_user_data);
}
void platform_macos_insert_text(const char *utf8) {
if (!g_current_window || !utf8) return;
PlatformInput *ev = &g_current_window->input;
while (*utf8 && ev->char_count < PLATFORM_MAX_CHARS_PER_FRAME) {
uint8_t c = (uint8_t)*utf8;
if (c < 32) { utf8++; continue; }
// Handle ASCII printable range (single-byte UTF-8)
if (c < 0x80) {
ev->chars[ev->char_count++] = (uint16_t)c;
utf8++;
} else {
// Skip multi-byte UTF-8 sequences for now (UI only handles ASCII)
if (c < 0xE0) utf8 += 2;
else if (c < 0xF0) utf8 += 3;
else utf8 += 4;
}
}
}
void platform_macos_key_down(uint16_t keycode, NSEventModifierFlags mods) {
if (!g_current_window) return;
PlatformInput *ev = &g_current_window->input;
uint8_t pkey = macos_keycode_to_pkey(keycode);
if (pkey && ev->key_count < PLATFORM_MAX_KEYS_PER_FRAME)
ev->keys[ev->key_count++] = pkey;
// Command = ctrl_held (macOS convention: Cmd+C, Cmd+V, etc.)
ev->ctrl_held = (mods & NSEventModifierFlagCommand) != 0;
ev->shift_held = (mods & NSEventModifierFlagShift) != 0;
}
void platform_macos_mouse_down() {
if (g_current_window) g_current_window->mouse_down_state = 1;
}
void platform_macos_mouse_up() {
if (g_current_window) g_current_window->mouse_down_state = 0;
}
void platform_macos_scroll(float dx, float dy) {
(void)dx;
if (g_current_window) g_current_window->input.scroll_delta.y += dy;
}
////////////////////////////////
// Menu action handler
@interface ASmplMenuTarget : NSObject
- (void)menuAction:(id)sender;
@end
@implementation ASmplMenuTarget
- (void)menuAction:(id)sender {
if (!g_current_window) return;
NSMenuItem *item = (NSMenuItem *)sender;
g_current_window->pending_menu_cmd = (int32_t)[item tag];
}
@end
static ASmplMenuTarget *g_menu_target = nil;
////////////////////////////////
// Public API
PlatformWindow *platform_create_window(PlatformWindowDesc *desc) {
// Ensure NSApplication is initialized
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
NSRect content_rect = NSMakeRect(0, 0, desc->width, desc->height);
NSWindow *ns_window = [[NSWindow alloc]
initWithContentRect:content_rect
styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable)
backing:NSBackingStoreBuffered
defer:NO];
[ns_window setTitle:[NSString stringWithUTF8String:desc->title]];
[ns_window center];
ASmplView *view = [[ASmplView alloc] initWithFrame:content_rect];
[ns_window setContentView:view];
[ns_window makeFirstResponder:view];
// Enable mouse moved events
[ns_window setAcceptsMouseMovedEvents:YES];
ASmplWindowDelegate *delegate = [[ASmplWindowDelegate alloc] init];
[ns_window setDelegate:delegate];
PlatformWindow *window = new PlatformWindow();
memset(window, 0, sizeof(*window));
window->ns_window = ns_window;
window->view = view;
window->delegate = delegate;
window->should_close = false;
window->backing_scale = (F32)[ns_window backingScaleFactor];
window->width = (int32_t)(desc->width * window->backing_scale);
window->height = (int32_t)(desc->height * window->backing_scale);
g_current_window = window;
[ns_window makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
return window;
}
void platform_destroy_window(PlatformWindow *window) {
if (!window) return;
[window->ns_window close];
if (g_current_window == window)
g_current_window = nullptr;
delete window;
}
bool platform_poll_events(PlatformWindow *window) {
@autoreleasepool {
NSEvent *event;
while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny
untilDate:nil
inMode:NSDefaultRunLoopMode
dequeue:YES])) {
[NSApp sendEvent:event];
}
}
return !window->should_close;
}
void platform_get_size(PlatformWindow *window, int32_t *w, int32_t *h) {
if (w) *w = window->width;
if (h) *h = window->height;
}
void *platform_get_native_handle(PlatformWindow *window) {
return (__bridge void *)window->view;
}
void platform_set_frame_callback(PlatformWindow *window, PlatformFrameCallback cb, void *user_data) {
window->frame_callback = cb;
window->frame_callback_user_data = user_data;
}
void platform_set_menu(PlatformWindow *window, PlatformMenu *menus, int32_t menu_count) {
(void)window;
if (!g_menu_target)
g_menu_target = [[ASmplMenuTarget alloc] init];
NSMenu *menu_bar = [[NSMenu alloc] init];
// App menu (required on macOS)
NSMenuItem *app_menu_item = [[NSMenuItem alloc] init];
NSMenu *app_menu = [[NSMenu alloc] init];
[app_menu addItemWithTitle:@"Quit autosample"
action:@selector(terminate:)
keyEquivalent:@"q"];
[app_menu_item setSubmenu:app_menu];
[menu_bar addItem:app_menu_item];
for (int32_t i = 0; i < menu_count; i++) {
NSMenuItem *top_item = [[NSMenuItem alloc] init];
NSMenu *submenu = [[NSMenu alloc] initWithTitle:
[NSString stringWithUTF8String:menus[i].label]];
for (int32_t j = 0; j < menus[i].item_count; j++) {
PlatformMenuItem *item = &menus[i].items[j];
if (!item->label) {
[submenu addItem:[NSMenuItem separatorItem]];
} else {
NSMenuItem *ns_item = [[NSMenuItem alloc]
initWithTitle:[NSString stringWithUTF8String:item->label]
action:@selector(menuAction:)
keyEquivalent:@""];
[ns_item setTag:item->id];
[ns_item setTarget:g_menu_target];
[submenu addItem:ns_item];
}
}
[top_item setSubmenu:submenu];
[menu_bar addItem:top_item];
}
[NSApp setMainMenu:menu_bar];
}
int32_t platform_poll_menu_command(PlatformWindow *window) {
int32_t cmd = window->pending_menu_cmd;
window->pending_menu_cmd = 0;
return cmd;
}
PlatformInput platform_get_input(PlatformWindow *window) {
PlatformInput result = window->input;
// Poll mouse position (Cocoa uses bottom-left origin, flip Y)
NSPoint mouse_in_window = [window->ns_window mouseLocationOutsideOfEventStream];
NSRect view_bounds = [window->view bounds];
F32 scale = window->backing_scale;
result.mouse_pos = v2f32(
(F32)mouse_in_window.x * scale,
(F32)(view_bounds.size.height - mouse_in_window.y) * scale);
// Mouse button state
result.was_mouse_down = window->prev_mouse_down;
result.mouse_down = window->mouse_down_state;
window->prev_mouse_down = result.mouse_down;
// Clear accumulated events for next frame
window->input = {};
return result;
}
void platform_clipboard_set(const char *text) {
if (!text) return;
NSPasteboard *pb = [NSPasteboard generalPasteboard];
[pb clearContents];
[pb setString:[NSString stringWithUTF8String:text] forType:NSPasteboardTypeString];
}
const char *platform_clipboard_get() {
static char buf[4096];
buf[0] = '\0';
NSPasteboard *pb = [NSPasteboard generalPasteboard];
NSString *str = [pb stringForType:NSPasteboardTypeString];
if (str) {
const char *utf8 = [str UTF8String];
if (utf8) {
size_t len = strlen(utf8);
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
memcpy(buf, utf8, len);
buf[len] = '\0';
}
}
return buf[0] ? buf : nullptr;
}

View File

@@ -0,0 +1,818 @@
#include "renderer/renderer.h"
#include "ui/ui_core.h"
#include "ui/ui_theme.h"
#import <Metal/Metal.h>
#import <QuartzCore/CAMetalLayer.h>
#import <Cocoa/Cocoa.h>
#import <CoreText/CoreText.h>
#import <CoreGraphics/CoreGraphics.h>
#include <math.h>
#define NUM_BACK_BUFFERS 2
#define MAX_VERTICES (64 * 1024)
#define MAX_INDICES (MAX_VERTICES * 3)
// Font atlas
#define FONT_ATLAS_W 1024
#define FONT_ATLAS_H 1024
#define GLYPH_FIRST 32
#define GLYPH_LAST 126
#define GLYPH_COUNT (GLYPH_LAST - GLYPH_FIRST + 1)
////////////////////////////////
// Vertex format — matches DX12 UIVertex exactly
struct UIVertex {
float pos[2];
float uv[2];
float col[4];
float rect_min[2];
float rect_max[2];
float corner_radii[4]; // TL, TR, BR, BL
float border_thickness;
float softness;
float mode; // 0 = rect SDF, 1 = textured
};
////////////////////////////////
// Glyph info
struct GlyphInfo {
F32 u0, v0, u1, v1;
F32 w, h;
F32 x_advance;
};
////////////////////////////////
// Metal shader (MSL) — port of HLSL SDF shader
static const char *g_shader_msl = R"(
#include <metal_stdlib>
using namespace metal;
struct Vertex {
float2 pos [[attribute(0)]];
float2 uv [[attribute(1)]];
float4 col [[attribute(2)]];
float2 rect_min [[attribute(3)]];
float2 rect_max [[attribute(4)]];
float4 corner_radii [[attribute(5)]];
float border_thickness [[attribute(6)]];
float softness [[attribute(7)]];
float mode [[attribute(8)]];
};
struct Fragment {
float4 pos [[position]];
float2 uv;
float4 col;
float2 rect_min;
float2 rect_max;
float4 corner_radii;
float border_thickness;
float softness;
float mode;
};
struct Constants {
float2 viewport_size;
};
vertex Fragment vertex_main(Vertex in [[stage_in]],
constant Constants &cb [[buffer(1)]]) {
Fragment out;
float2 ndc;
ndc.x = (in.pos.x / cb.viewport_size.x) * 2.0 - 1.0;
ndc.y = 1.0 - (in.pos.y / cb.viewport_size.y) * 2.0;
out.pos = float4(ndc, 0.0, 1.0);
out.uv = in.uv;
out.col = in.col;
out.rect_min = in.rect_min;
out.rect_max = in.rect_max;
out.corner_radii = in.corner_radii;
out.border_thickness = in.border_thickness;
out.softness = in.softness;
out.mode = in.mode;
return out;
}
float rounded_rect_sdf(float2 sample_pos, float2 rect_center, float2 rect_half_size, float radius) {
float2 d = abs(sample_pos - rect_center) - rect_half_size + float2(radius, radius);
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius;
}
fragment float4 fragment_main(Fragment in [[stage_in]],
texture2d<float> font_tex [[texture(0)]],
sampler font_smp [[sampler(0)]]) {
float4 col = in.col;
if (in.mode > 0.5) {
float alpha = font_tex.sample(font_smp, in.uv).r;
col.a *= alpha;
} else {
float2 pixel_pos = in.pos.xy;
float2 rect_center = (in.rect_min + in.rect_max) * 0.5;
float2 rect_half_size = (in.rect_max - in.rect_min) * 0.5;
float radius = (pixel_pos.x < rect_center.x)
? ((pixel_pos.y < rect_center.y) ? in.corner_radii.x : in.corner_radii.w)
: ((pixel_pos.y < rect_center.y) ? in.corner_radii.y : in.corner_radii.z);
float softness = max(in.softness, 0.5);
float dist = rounded_rect_sdf(pixel_pos, rect_center, rect_half_size, radius);
if (in.border_thickness > 0) {
float inner_dist = dist + in.border_thickness;
float outer_alpha = 1.0 - smoothstep(-softness, softness, dist);
float inner_alpha = smoothstep(-softness, softness, inner_dist);
col.a *= outer_alpha * inner_alpha;
} else {
col.a *= 1.0 - smoothstep(-softness, softness, dist);
}
}
if (col.a < 0.002) discard_fragment();
return col;
}
)";
////////////////////////////////
// Renderer struct
struct Renderer {
int32_t width;
int32_t height;
int32_t frame_count;
uint32_t frame_index;
F32 backing_scale;
id<MTLDevice> device;
id<MTLCommandQueue> command_queue;
CAMetalLayer *metal_layer;
id<MTLRenderPipelineState> pipeline_state;
dispatch_semaphore_t frame_semaphore;
// Double-buffered vertex/index buffers
id<MTLBuffer> vertex_buffers[NUM_BACK_BUFFERS];
id<MTLBuffer> index_buffers[NUM_BACK_BUFFERS];
// Font atlas
id<MTLTexture> font_texture;
id<MTLSamplerState> font_sampler;
GlyphInfo glyphs[GLYPH_COUNT];
F32 font_atlas_size;
F32 font_line_height;
// Text measurement (Core Text)
CTFontRef measure_font;
F32 measure_font_size;
};
////////////////////////////////
// Font atlas (Core Text + CoreGraphics)
static bool create_font_atlas(Renderer *r, F32 font_size) {
const int SS = 2;
F32 render_size = font_size * SS;
int render_w = FONT_ATLAS_W * SS;
int render_h = FONT_ATLAS_H * SS;
r->font_atlas_size = font_size;
// Create Core Text font (system font = SF Pro on macOS)
CTFontRef font = CTFontCreateUIFontForLanguage(kCTFontUIFontSystem, render_size, nullptr);
if (!font) return false;
// Get line height
F32 ascent = (F32)CTFontGetAscent(font);
F32 descent = (F32)CTFontGetDescent(font);
F32 leading = (F32)CTFontGetLeading(font);
r->font_line_height = (ascent + descent + leading) / SS;
// Create bitmap context at supersampled resolution
CGColorSpaceRef color_space = CGColorSpaceCreateDeviceGray();
CGContextRef ctx = CGBitmapContextCreate(nullptr, render_w, render_h, 8, render_w,
color_space, kCGImageAlphaNone);
CGColorSpaceRelease(color_space);
if (!ctx) { CFRelease(font); return false; }
// Clear to black
CGContextSetGrayFillColor(ctx, 0.0, 1.0);
CGContextFillRect(ctx, CGRectMake(0, 0, render_w, render_h));
// White text
CGContextSetGrayFillColor(ctx, 1.0, 1.0);
// Render each glyph
int pen_x = SS, pen_y = SS;
int row_height = 0;
NSDictionary *attrs = @{
(id)kCTFontAttributeName: (__bridge id)font,
(id)kCTForegroundColorFromContextAttributeName: @YES
};
for (int i = 0; i < GLYPH_COUNT; i++) {
char ch = (char)(GLYPH_FIRST + i);
NSString *str = [[NSString alloc] initWithBytes:&ch length:1 encoding:NSASCIIStringEncoding];
NSAttributedString *astr = [[NSAttributedString alloc] initWithString:str attributes:attrs];
CTLineRef line = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)astr);
CGRect bounds = CTLineGetBoundsWithOptions(line, 0);
int gw = (int)ceilf((float)bounds.size.width) + 2 * SS;
int gh = (int)ceilf((float)(ascent + descent)) + 2 * SS;
if (pen_x + gw >= render_w) {
pen_x = SS;
pen_y += row_height + SS;
row_height = 0;
}
if (pen_y + gh >= render_h) { CFRelease(line); break; }
// CoreGraphics has bottom-left origin; draw relative to pen position
F32 draw_x = (F32)(pen_x + SS) - (F32)bounds.origin.x;
F32 draw_y = (F32)(render_h - pen_y - SS) - ascent;
CGContextSetTextPosition(ctx, draw_x, draw_y);
CTLineDraw(line, ctx);
// UVs (same fractional math as DX12)
r->glyphs[i].u0 = (F32)pen_x / (F32)render_w;
r->glyphs[i].v0 = (F32)pen_y / (F32)render_h;
r->glyphs[i].u1 = (F32)(pen_x + gw) / (F32)render_w;
r->glyphs[i].v1 = (F32)(pen_y + gh) / (F32)render_h;
r->glyphs[i].w = (F32)gw / SS;
r->glyphs[i].h = (F32)gh / SS;
r->glyphs[i].x_advance = (F32)bounds.size.width / SS;
if (gh > row_height) row_height = gh;
pen_x += gw + SS;
CFRelease(line);
}
// Box-filter downsample
uint8_t *src = (uint8_t *)CGBitmapContextGetData(ctx);
uint8_t *atlas_data = (uint8_t *)malloc(FONT_ATLAS_W * FONT_ATLAS_H);
// CoreGraphics bitmap has bottom-left origin — flip Y during downsample
for (int y = 0; y < FONT_ATLAS_H; y++) {
for (int x = 0; x < FONT_ATLAS_W; x++) {
int sum = 0;
for (int sy = 0; sy < SS; sy++) {
for (int sx = 0; sx < SS; sx++) {
int src_y = render_h - 1 - (y * SS + sy); // flip Y
int src_idx = src_y * render_w + (x * SS + sx);
sum += src[src_idx];
}
}
float a = (float)sum / (float)(SS * SS * 255);
a = powf(a, 0.55f);
atlas_data[y * FONT_ATLAS_W + x] = (uint8_t)(a * 255.0f + 0.5f);
}
}
CGContextRelease(ctx);
CFRelease(font);
// Create Metal texture
MTLTextureDescriptor *tex_desc = [[MTLTextureDescriptor alloc] init];
tex_desc.pixelFormat = MTLPixelFormatR8Unorm;
tex_desc.width = FONT_ATLAS_W;
tex_desc.height = FONT_ATLAS_H;
tex_desc.usage = MTLTextureUsageShaderRead;
r->font_texture = [r->device newTextureWithDescriptor:tex_desc];
[r->font_texture replaceRegion:MTLRegionMake2D(0, 0, FONT_ATLAS_W, FONT_ATLAS_H)
mipmapLevel:0
withBytes:atlas_data
bytesPerRow:FONT_ATLAS_W];
free(atlas_data);
// Create sampler
MTLSamplerDescriptor *samp_desc = [[MTLSamplerDescriptor alloc] init];
samp_desc.minFilter = MTLSamplerMinMagFilterLinear;
samp_desc.magFilter = MTLSamplerMinMagFilterLinear;
samp_desc.sAddressMode = MTLSamplerAddressModeClampToEdge;
samp_desc.tAddressMode = MTLSamplerAddressModeClampToEdge;
r->font_sampler = [r->device newSamplerStateWithDescriptor:samp_desc];
return true;
}
////////////////////////////////
// Text measurement
static void ensure_measure_font(Renderer *r, F32 font_size) {
if (r->measure_font && r->measure_font_size == font_size) return;
if (r->measure_font) CFRelease(r->measure_font);
r->measure_font = CTFontCreateUIFontForLanguage(kCTFontUIFontSystem, font_size, nullptr);
r->measure_font_size = font_size;
}
Vec2F32 renderer_measure_text(const char *text, int32_t length, float font_size, void *user_data) {
Renderer *r = (Renderer *)user_data;
if (!r || length == 0) return v2f32(0, font_size);
ensure_measure_font(r, font_size);
NSString *str = [[NSString alloc] initWithBytes:text length:length encoding:NSUTF8StringEncoding];
if (!str) return v2f32(0, font_size);
NSDictionary *attrs = @{ (id)kCTFontAttributeName: (__bridge id)r->measure_font };
NSAttributedString *astr = [[NSAttributedString alloc] initWithString:str attributes:attrs];
CTLineRef line = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)astr);
CGRect bounds = CTLineGetBoundsWithOptions(line, 0);
CFRelease(line);
return v2f32((F32)bounds.size.width, (F32)bounds.size.height);
}
////////////////////////////////
// Draw batch and quad emission (same logic as DX12)
struct DrawBatch {
UIVertex *vertices;
U32 *indices;
U32 vertex_count;
U32 index_count;
};
static void emit_quad(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float u0, float v0, float u1, float v1,
float cr, float cg, float cb, float ca,
float rmin_x, float rmin_y, float rmax_x, float rmax_y,
float cr_tl, float cr_tr, float cr_br, float cr_bl,
float border_thickness, float softness, float mode)
{
if (batch->vertex_count + 4 > MAX_VERTICES || batch->index_count + 6 > MAX_INDICES)
return;
U32 base = batch->vertex_count;
UIVertex *v = &batch->vertices[base];
float px0 = x0, py0 = y0, px1 = x1, py1 = y1;
if (mode < 0.5f) {
float pad = softness + 1.0f;
px0 -= pad; py0 -= pad; px1 += pad; py1 += pad;
}
v[0].pos[0] = px0; v[0].pos[1] = py0; v[0].uv[0] = u0; v[0].uv[1] = v0;
v[1].pos[0] = px1; v[1].pos[1] = py0; v[1].uv[0] = u1; v[1].uv[1] = v0;
v[2].pos[0] = px1; v[2].pos[1] = py1; v[2].uv[0] = u1; v[2].uv[1] = v1;
v[3].pos[0] = px0; v[3].pos[1] = py1; v[3].uv[0] = u0; v[3].uv[1] = v1;
for (int i = 0; i < 4; i++) {
v[i].col[0] = cr; v[i].col[1] = cg; v[i].col[2] = cb; v[i].col[3] = ca;
v[i].rect_min[0] = rmin_x; v[i].rect_min[1] = rmin_y;
v[i].rect_max[0] = rmax_x; v[i].rect_max[1] = rmax_y;
v[i].corner_radii[0] = cr_tl; v[i].corner_radii[1] = cr_tr;
v[i].corner_radii[2] = cr_br; v[i].corner_radii[3] = cr_bl;
v[i].border_thickness = border_thickness;
v[i].softness = softness;
v[i].mode = mode;
}
U32 *idx = &batch->indices[batch->index_count];
idx[0] = base; idx[1] = base + 1; idx[2] = base + 2;
idx[3] = base; idx[4] = base + 2; idx[5] = base + 3;
batch->vertex_count += 4;
batch->index_count += 6;
}
static void emit_rect(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float cr, float cg, float cb, float ca,
float cr_tl, float cr_tr, float cr_br, float cr_bl,
float border_thickness, float softness)
{
emit_quad(batch, x0, y0, x1, y1,
0, 0, 0, 0,
cr, cg, cb, ca,
x0, y0, x1, y1,
cr_tl, cr_tr, cr_br, cr_bl,
border_thickness, softness, 0.0f);
}
static void emit_rect_vgradient(DrawBatch *batch,
float x0, float y0, float x1, float y1,
float tr, float tg, float tb, float ta,
float br, float bg, float bb_, float ba,
float cr_tl, float cr_tr, float cr_br, float cr_bl,
float softness)
{
if (batch->vertex_count + 4 > MAX_VERTICES || batch->index_count + 6 > MAX_INDICES)
return;
U32 base = batch->vertex_count;
UIVertex *v = &batch->vertices[base];
float pad = softness + 1.0f;
float px0 = x0 - pad, py0 = y0 - pad, px1 = x1 + pad, py1 = y1 + pad;
v[0].pos[0] = px0; v[0].pos[1] = py0; v[0].uv[0] = 0; v[0].uv[1] = 0;
v[1].pos[0] = px1; v[1].pos[1] = py0; v[1].uv[0] = 0; v[1].uv[1] = 0;
v[2].pos[0] = px1; v[2].pos[1] = py1; v[2].uv[0] = 0; v[2].uv[1] = 0;
v[3].pos[0] = px0; v[3].pos[1] = py1; v[3].uv[0] = 0; v[3].uv[1] = 0;
v[0].col[0] = tr; v[0].col[1] = tg; v[0].col[2] = tb; v[0].col[3] = ta;
v[1].col[0] = tr; v[1].col[1] = tg; v[1].col[2] = tb; v[1].col[3] = ta;
v[2].col[0] = br; v[2].col[1] = bg; v[2].col[2] = bb_; v[2].col[3] = ba;
v[3].col[0] = br; v[3].col[1] = bg; v[3].col[2] = bb_; v[3].col[3] = ba;
for (int i = 0; i < 4; i++) {
v[i].rect_min[0] = x0; v[i].rect_min[1] = y0;
v[i].rect_max[0] = x1; v[i].rect_max[1] = y1;
v[i].corner_radii[0] = cr_tl; v[i].corner_radii[1] = cr_tr;
v[i].corner_radii[2] = cr_br; v[i].corner_radii[3] = cr_bl;
v[i].border_thickness = 0;
v[i].softness = softness;
v[i].mode = 0;
}
U32 *idx = &batch->indices[batch->index_count];
idx[0] = base; idx[1] = base + 1; idx[2] = base + 2;
idx[3] = base; idx[4] = base + 2; idx[5] = base + 3;
batch->vertex_count += 4;
batch->index_count += 6;
}
static void emit_text_glyphs(DrawBatch *batch, Renderer *r,
Clay_BoundingBox bbox, Clay_Color color, const char *text, int32_t text_len,
uint16_t font_size)
{
if (text_len == 0 || color.a < 0.1f) return;
float cr = color.r / 255.f;
float cg = color.g / 255.f;
float cb = color.b / 255.f;
float ca = color.a / 255.f;
F32 scale = (F32)font_size / r->font_atlas_size;
F32 text_h = r->font_line_height * scale;
F32 x = floorf(bbox.x + 0.5f);
F32 y = floorf(bbox.y + (bbox.height - text_h) * 0.5f + 0.5f);
for (int32_t i = 0; i < text_len; i++) {
char ch = text[i];
if (ch < GLYPH_FIRST || ch > GLYPH_LAST) {
if (ch == ' ') {
int gi = ' ' - GLYPH_FIRST;
if (gi >= 0 && gi < GLYPH_COUNT)
x += r->glyphs[gi].x_advance * scale;
continue;
}
ch = '?';
}
int gi = ch - GLYPH_FIRST;
if (gi < 0 || gi >= GLYPH_COUNT) continue;
GlyphInfo *g = &r->glyphs[gi];
F32 gw = g->w * scale;
F32 gh = g->h * scale;
emit_quad(batch,
x, y, x + gw, y + gh,
g->u0, g->v0, g->u1, g->v1,
cr, cg, cb, ca,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 1.0f);
x += g->x_advance * scale;
}
}
////////////////////////////////
// Public API
Renderer *renderer_create(RendererDesc *desc) {
Renderer *r = new Renderer();
memset(r, 0, sizeof(*r));
r->width = desc->width;
r->height = desc->height;
r->frame_count = desc->frame_count;
if (r->frame_count > NUM_BACK_BUFFERS) r->frame_count = NUM_BACK_BUFFERS;
// Get the NSView and attach a CAMetalLayer
NSView *view = (__bridge NSView *)desc->window_handle;
[view setWantsLayer:YES];
r->device = MTLCreateSystemDefaultDevice();
if (!r->device) { delete r; return nullptr; }
r->command_queue = [r->device newCommandQueue];
CAMetalLayer *layer = [CAMetalLayer layer];
layer.device = r->device;
layer.pixelFormat = MTLPixelFormatBGRA8Unorm;
layer.framebufferOnly = YES;
NSWindow *window = [view window];
r->backing_scale = (F32)[window backingScaleFactor];
layer.contentsScale = r->backing_scale;
layer.drawableSize = CGSizeMake(r->width, r->height);
[view setLayer:layer];
r->metal_layer = layer;
r->frame_semaphore = dispatch_semaphore_create(NUM_BACK_BUFFERS);
// Compile shaders
NSError *error = nil;
id<MTLLibrary> library = [r->device newLibraryWithSource:
[NSString stringWithUTF8String:g_shader_msl] options:nil error:&error];
if (!library) {
NSLog(@"Metal shader compile error: %@", error);
delete r;
return nullptr;
}
id<MTLFunction> vert_fn = [library newFunctionWithName:@"vertex_main"];
id<MTLFunction> frag_fn = [library newFunctionWithName:@"fragment_main"];
// Vertex descriptor
MTLVertexDescriptor *vtx_desc = [[MTLVertexDescriptor alloc] init];
vtx_desc.attributes[0].format = MTLVertexFormatFloat2;
vtx_desc.attributes[0].offset = offsetof(UIVertex, pos);
vtx_desc.attributes[0].bufferIndex = 0;
vtx_desc.attributes[1].format = MTLVertexFormatFloat2;
vtx_desc.attributes[1].offset = offsetof(UIVertex, uv);
vtx_desc.attributes[1].bufferIndex = 0;
vtx_desc.attributes[2].format = MTLVertexFormatFloat4;
vtx_desc.attributes[2].offset = offsetof(UIVertex, col);
vtx_desc.attributes[2].bufferIndex = 0;
vtx_desc.attributes[3].format = MTLVertexFormatFloat2;
vtx_desc.attributes[3].offset = offsetof(UIVertex, rect_min);
vtx_desc.attributes[3].bufferIndex = 0;
vtx_desc.attributes[4].format = MTLVertexFormatFloat2;
vtx_desc.attributes[4].offset = offsetof(UIVertex, rect_max);
vtx_desc.attributes[4].bufferIndex = 0;
vtx_desc.attributes[5].format = MTLVertexFormatFloat4;
vtx_desc.attributes[5].offset = offsetof(UIVertex, corner_radii);
vtx_desc.attributes[5].bufferIndex = 0;
vtx_desc.attributes[6].format = MTLVertexFormatFloat;
vtx_desc.attributes[6].offset = offsetof(UIVertex, border_thickness);
vtx_desc.attributes[6].bufferIndex = 0;
vtx_desc.attributes[7].format = MTLVertexFormatFloat;
vtx_desc.attributes[7].offset = offsetof(UIVertex, softness);
vtx_desc.attributes[7].bufferIndex = 0;
vtx_desc.attributes[8].format = MTLVertexFormatFloat;
vtx_desc.attributes[8].offset = offsetof(UIVertex, mode);
vtx_desc.attributes[8].bufferIndex = 0;
vtx_desc.layouts[0].stride = sizeof(UIVertex);
vtx_desc.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex;
// Pipeline state
MTLRenderPipelineDescriptor *pipe_desc = [[MTLRenderPipelineDescriptor alloc] init];
pipe_desc.vertexFunction = vert_fn;
pipe_desc.fragmentFunction = frag_fn;
pipe_desc.vertexDescriptor = vtx_desc;
pipe_desc.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
pipe_desc.colorAttachments[0].blendingEnabled = YES;
pipe_desc.colorAttachments[0].sourceRGBBlendFactor = MTLBlendFactorSourceAlpha;
pipe_desc.colorAttachments[0].destinationRGBBlendFactor = MTLBlendFactorOneMinusSourceAlpha;
pipe_desc.colorAttachments[0].rgbBlendOperation = MTLBlendOperationAdd;
pipe_desc.colorAttachments[0].sourceAlphaBlendFactor = MTLBlendFactorOne;
pipe_desc.colorAttachments[0].destinationAlphaBlendFactor = MTLBlendFactorOneMinusSourceAlpha;
pipe_desc.colorAttachments[0].alphaBlendOperation = MTLBlendOperationAdd;
r->pipeline_state = [r->device newRenderPipelineStateWithDescriptor:pipe_desc error:&error];
if (!r->pipeline_state) {
NSLog(@"Metal pipeline error: %@", error);
delete r;
return nullptr;
}
// Create double-buffered vertex/index buffers
for (int i = 0; i < NUM_BACK_BUFFERS; i++) {
r->vertex_buffers[i] = [r->device newBufferWithLength:MAX_VERTICES * sizeof(UIVertex)
options:MTLResourceStorageModeShared];
r->index_buffers[i] = [r->device newBufferWithLength:MAX_INDICES * sizeof(U32)
options:MTLResourceStorageModeShared];
}
// Font atlas
if (!create_font_atlas(r, 15.0f)) {
delete r;
return nullptr;
}
// Init text measurement
r->measure_font = nullptr;
r->measure_font_size = 0;
return r;
}
void renderer_destroy(Renderer *r) {
if (!r) return;
if (r->measure_font) CFRelease(r->measure_font);
delete r;
}
bool renderer_begin_frame(Renderer *r) {
dispatch_semaphore_wait(r->frame_semaphore, DISPATCH_TIME_FOREVER);
id<CAMetalDrawable> drawable = [r->metal_layer nextDrawable];
if (!drawable) {
dispatch_semaphore_signal(r->frame_semaphore);
return false;
}
// Store drawable for end_frame (use a thread-local or just re-acquire)
// We'll re-acquire in end_frame — nextDrawable is fast if we just called it
dispatch_semaphore_signal(r->frame_semaphore);
return true;
}
void renderer_end_frame(Renderer *r, Clay_RenderCommandArray render_commands) {
dispatch_semaphore_wait(r->frame_semaphore, DISPATCH_TIME_FOREVER);
@autoreleasepool {
id<CAMetalDrawable> drawable = [r->metal_layer nextDrawable];
if (!drawable) {
dispatch_semaphore_signal(r->frame_semaphore);
return;
}
uint32_t buf_idx = r->frame_index % NUM_BACK_BUFFERS;
MTLRenderPassDescriptor *pass = [MTLRenderPassDescriptor renderPassDescriptor];
pass.colorAttachments[0].texture = drawable.texture;
pass.colorAttachments[0].loadAction = MTLLoadActionClear;
pass.colorAttachments[0].storeAction = MTLStoreActionStore;
pass.colorAttachments[0].clearColor = MTLClearColorMake(0.12, 0.12, 0.13, 1.0);
id<MTLCommandBuffer> cmd_buf = [r->command_queue commandBuffer];
id<MTLRenderCommandEncoder> encoder = [cmd_buf renderCommandEncoderWithDescriptor:pass];
[encoder setRenderPipelineState:r->pipeline_state];
[encoder setFragmentTexture:r->font_texture atIndex:0];
[encoder setFragmentSamplerState:r->font_sampler atIndex:0];
// Viewport
MTLViewport viewport = {};
viewport.width = (double)r->width;
viewport.height = (double)r->height;
viewport.zfar = 1.0;
[encoder setViewport:viewport];
// Full scissor
MTLScissorRect full_scissor = { 0, 0, (NSUInteger)r->width, (NSUInteger)r->height };
[encoder setScissorRect:full_scissor];
// Constants
float constants[2] = { (float)r->width, (float)r->height };
[encoder setVertexBytes:constants length:sizeof(constants) atIndex:1];
// Process Clay render commands
if (render_commands.length > 0) {
DrawBatch batch = {};
batch.vertices = (UIVertex *)[r->vertex_buffers[buf_idx] contents];
batch.indices = (U32 *)[r->index_buffers[buf_idx] contents];
batch.vertex_count = 0;
batch.index_count = 0;
auto flush_batch = [&]() {
if (batch.index_count == 0) return;
[encoder setVertexBuffer:r->vertex_buffers[buf_idx] offset:0 atIndex:0];
[encoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle
indexCount:batch.index_count
indexType:MTLIndexTypeUInt32
indexBuffer:r->index_buffers[buf_idx]
indexBufferOffset:0];
batch.vertex_count = 0;
batch.index_count = 0;
};
for (int32_t i = 0; i < render_commands.length; i++) {
Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(&render_commands, i);
Clay_BoundingBox bb = cmd->boundingBox;
switch (cmd->commandType) {
case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: {
Clay_RectangleRenderData *rect = &cmd->renderData.rectangle;
Clay_Color c = rect->backgroundColor;
emit_rect(&batch,
bb.x, bb.y, bb.x + bb.width, bb.y + bb.height,
c.r / 255.f, c.g / 255.f, c.b / 255.f, c.a / 255.f,
rect->cornerRadius.topLeft, rect->cornerRadius.topRight,
rect->cornerRadius.bottomRight, rect->cornerRadius.bottomLeft,
0, 1.0f);
} break;
case CLAY_RENDER_COMMAND_TYPE_BORDER: {
Clay_BorderRenderData *border = &cmd->renderData.border;
Clay_Color c = border->color;
float cr_norm = c.r / 255.f;
float cg_norm = c.g / 255.f;
float cb_norm = c.b / 255.f;
float ca_norm = c.a / 255.f;
if (border->width.top > 0) {
emit_rect(&batch, bb.x, bb.y, bb.x + bb.width, bb.y + border->width.top,
cr_norm, cg_norm, cb_norm, ca_norm, 0, 0, 0, 0, 0, 1.0f);
}
if (border->width.bottom > 0) {
emit_rect(&batch, bb.x, bb.y + bb.height - border->width.bottom, bb.x + bb.width, bb.y + bb.height,
cr_norm, cg_norm, cb_norm, ca_norm, 0, 0, 0, 0, 0, 1.0f);
}
if (border->width.left > 0) {
emit_rect(&batch, bb.x, bb.y, bb.x + border->width.left, bb.y + bb.height,
cr_norm, cg_norm, cb_norm, ca_norm, 0, 0, 0, 0, 0, 1.0f);
}
if (border->width.right > 0) {
emit_rect(&batch, bb.x + bb.width - border->width.right, bb.y, bb.x + bb.width, bb.y + bb.height,
cr_norm, cg_norm, cb_norm, ca_norm, 0, 0, 0, 0, 0, 1.0f);
}
} break;
case CLAY_RENDER_COMMAND_TYPE_TEXT: {
Clay_TextRenderData *text = &cmd->renderData.text;
emit_text_glyphs(&batch, r, bb, text->textColor,
text->stringContents.chars, text->stringContents.length,
text->fontSize);
} break;
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: {
flush_batch();
NSUInteger sx = (NSUInteger)Max(bb.x, 0.f);
NSUInteger sy = (NSUInteger)Max(bb.y, 0.f);
NSUInteger sw = (NSUInteger)Min(bb.width, (F32)r->width - (F32)sx);
NSUInteger sh = (NSUInteger)Min(bb.height, (F32)r->height - (F32)sy);
if (sw == 0) sw = 1;
if (sh == 0) sh = 1;
MTLScissorRect clip = { sx, sy, sw, sh };
[encoder setScissorRect:clip];
} break;
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: {
flush_batch();
[encoder setScissorRect:full_scissor];
} break;
case CLAY_RENDER_COMMAND_TYPE_CUSTOM: {
Clay_CustomRenderData *custom = &cmd->renderData.custom;
if (custom->customData) {
CustomRenderType type = *(CustomRenderType *)custom->customData;
if (type == CUSTOM_RENDER_VGRADIENT) {
CustomGradientData *grad = (CustomGradientData *)custom->customData;
Clay_Color tc = grad->top_color;
Clay_Color bc = grad->bottom_color;
emit_rect_vgradient(&batch,
bb.x, bb.y, bb.x + bb.width, bb.y + bb.height,
tc.r / 255.f, tc.g / 255.f, tc.b / 255.f, tc.a / 255.f,
bc.r / 255.f, bc.g / 255.f, bc.b / 255.f, bc.a / 255.f,
custom->cornerRadius.topLeft, custom->cornerRadius.topRight,
custom->cornerRadius.bottomRight, custom->cornerRadius.bottomLeft,
1.0f);
}
}
} break;
case CLAY_RENDER_COMMAND_TYPE_IMAGE:
default:
break;
}
}
flush_batch();
}
[encoder endEncoding];
[cmd_buf presentDrawable:drawable];
__block dispatch_semaphore_t sem = r->frame_semaphore;
[cmd_buf addCompletedHandler:^(id<MTLCommandBuffer> _Nonnull) {
dispatch_semaphore_signal(sem);
}];
[cmd_buf commit];
}
r->frame_index++;
}
void renderer_resize(Renderer *r, int32_t width, int32_t height) {
if (width <= 0 || height <= 0) return;
r->width = width;
r->height = height;
NSWindow *window = [(__bridge NSView *)r->metal_layer.delegate window];
if (window) {
r->backing_scale = (F32)[window backingScaleFactor];
r->metal_layer.contentsScale = r->backing_scale;
}
r->metal_layer.drawableSize = CGSizeMake(width, height);
}