From 840606d0c1ce5a07bcdbfdb1902865dc6fed2a73 Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 14:01:41 +0100 Subject: [PATCH] feat: Add ncurses renderer and example - **Renderer**: Implemented `clay_renderer_ncurses.c` supporting rectangles, text, borders, and clipping using standard ncurses plotting. - **Example**: Added `examples/ncurses-example` demonstrating a scrollable "Social Feed" UI with keyboard navigation. - **Build**: Added `CLAY_INCLUDE_NCURSES_EXAMPLES` option to root `CMakeLists.txt` and integrated the new example. - **CompConfig**: Updated `.gitignore` to strictly exclude `build/`, `_deps/`, and other standard CMake artifacts. --- .gitignore | 17 +- CMakeLists.txt | 5 + examples/ncurses-example/CMakeLists.txt | 14 + examples/ncurses-example/main.c | 266 +++++++++++++++++ renderers/ncurses/clay_renderer_ncurses.c | 339 ++++++++++++++++++++++ 5 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 examples/ncurses-example/CMakeLists.txt create mode 100644 examples/ncurses-example/main.c create mode 100644 renderers/ncurses/clay_renderer_ncurses.c diff --git a/.gitignore b/.gitignore index 920b172..d99fa17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ -cmake-build-debug/ -cmake-build-release/ .DS_Store .idea/ node_modules/ *.dSYM -.vs/ \ No newline at end of file +.vs/ + +# CMake dependencies +_deps/ + +# CMake build artifacts +build/ +cmake-build-debug/ +cmake-build-release/ +CPack* +Makefile +cmake_install.cmake +CMakeCache.txt +CMakeFiles diff --git a/CMakeLists.txt b/CMakeLists.txt index 191b2f7..b22a381 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ option(CLAY_INCLUDE_SDL3_EXAMPLES "Build SDL 3 examples" OFF) option(CLAY_INCLUDE_WIN32_GDI_EXAMPLES "Build Win32 GDI examples" OFF) option(CLAY_INCLUDE_SOKOL_EXAMPLES "Build Sokol examples" OFF) option(CLAY_INCLUDE_PLAYDATE_EXAMPLES "Build Playdate examples" OFF) +option(CLAY_INCLUDE_NCURSES_EXAMPLES "Build Ncurses examples" ON) message(STATUS "CLAY_INCLUDE_DEMOS: ${CLAY_INCLUDE_DEMOS}") @@ -56,6 +57,10 @@ if(WIN32) # Build only for Win or Wine endif() endif() +if(CLAY_INCLUDE_ALL_EXAMPLES OR CLAY_INCLUDE_NCURSES_EXAMPLES) + add_subdirectory("examples/ncurses-example") +endif() + # add_subdirectory("examples/cairo-pdf-rendering") Some issue with github actions populating cairo, disable for now #add_library(${PROJECT_NAME} INTERFACE) diff --git a/examples/ncurses-example/CMakeLists.txt b/examples/ncurses-example/CMakeLists.txt new file mode 100644 index 0000000..8b6d0ab --- /dev/null +++ b/examples/ncurses-example/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.27) +project(clay-ncurses-example C) + +find_package(Curses REQUIRED) + +add_executable(clay-ncurses-example main.c) + +target_link_libraries(clay-ncurses-example PRIVATE ${CURSES_LIBRARIES}) + +if (CMAKE_SYSTEM_NAME STREQUAL Linux) + target_link_libraries(clay-ncurses-example PRIVATE m) +endif() + +target_include_directories(clay-ncurses-example PRIVATE ${CURSES_INCLUDE_DIRS} ../../) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c new file mode 100644 index 0000000..f15e349 --- /dev/null +++ b/examples/ncurses-example/main.c @@ -0,0 +1,266 @@ +#define CLAY_IMPLEMENTATION +#include "../../clay.h" +#include "../../renderers/ncurses/clay_renderer_ncurses.c" +#include // for usleep + +#define DEFAULT_SCROLL_DELTA 3.0f + +// State for the example +typedef struct { + bool sidebarOpen; + float scrollDelta; + bool shouldQuit; +} AppState; + +static AppState appState = { .sidebarOpen = true, .scrollDelta = 0.0f, .shouldQuit = false }; + +void HandleInput() { + // Reset delta per frame + appState.scrollDelta = 0.0f; + + int ch; + while ((ch = getch()) != ERR) { + if (ch == 'q' || ch == 'Q') { + appState.shouldQuit = true; + } + if (ch == 's' || ch == 'S') { + appState.sidebarOpen = !appState.sidebarOpen; + } + if (ch == KEY_UP) { + appState.scrollDelta += DEFAULT_SCROLL_DELTA; + } + if (ch == KEY_DOWN) { + appState.scrollDelta -= DEFAULT_SCROLL_DELTA; + } + } +} + +void RenderSidebar() { + if (!appState.sidebarOpen) return; + + CLAY(CLAY_ID("Sidebar"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(240), CLAY_SIZING_GROW() }, // 30 cells wide + .padding = CLAY_PADDING_ALL(16), + .childGap = 16, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = {30, 30, 30, 255}, // Dark Gray + .border = { .color = {255, 255, 255, 255}, .width = { .right = 2 } } // White Border Right + }) { + CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({ + .textColor = {255, 255, 0, 255} + })); + + CLAY(CLAY_ID("SidebarItem1"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, + .backgroundColor = {60, 60, 60, 255} + }) { + CLAY_TEXT(CLAY_STRING(" > Item 1"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + } + + CLAY(CLAY_ID("SidebarItem2"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, + .backgroundColor = {60, 60, 60, 255} + }) { + CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + } + } +} + +// Helpers for "Realistic" Data +const char* NAMES[] = { + "Alice", + "Bob", + "Charlie", + "Diana", + "Ethan", + "Fiona", + "George", + "Hannah" +}; +const char* TITLES[] = { + "Just released a new library!", + "Thoughts on C programming?", + "Check out this cool algorithm", + "Why I love Ncurses", + "Clay UI is pretty flexible", + "Debugging segfaults all day...", + "Coffee break time ☕", + "Anyone going to the conf?" +}; +const char* LOREM[] = { + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.", + "Excepteur sint occaecat cupidatat non proident, sunt in culpa." +}; + +void RenderPost(int index) { + CLAY(CLAY_IDI("Post", index), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .padding = CLAY_PADDING_ALL(16), + .childGap = 8, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = {25, 25, 25, 255}, + .cornerRadius = {8}, // Rounded corners (will render as square in TUI usually unless ACS handled) + .border = { .color = {60, 60, 60, 255}, .width = { .left = 1, .right = 1, .top = 1, .bottom = 1 } } + }) { + // Post Header: Avatar + Name + Time + CLAY(CLAY_IDI("PostHeader", index), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .childGap = 12, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .layoutDirection = CLAY_LEFT_TO_RIGHT + } + }) { + // Avatar + CLAY(CLAY_IDI("Avatar", index), { + .layout = { .sizing = { CLAY_SIZING_FIXED(32), CLAY_SIZING_FIXED(16) } }, // 2x1 cells approx + .backgroundColor = { (index * 50) % 255, (index * 80) % 255, (index * 30) % 255, 255 }, + .cornerRadius = {8} + }) {} + + // Name & Title + CLAY(CLAY_IDI("AuthorInfo", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 4 } + }) { + Clay_String name = { .length = strlen(NAMES[index % 8]), .chars = NAMES[index % 8] }; + Clay_String title = { .length = strlen(TITLES[index % 8]), .chars = TITLES[index % 8] }; + CLAY_TEXT(name, CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + CLAY_TEXT(title, CLAY_TEXT_CONFIG({ .textColor = {150, 150, 150, 255} })); + } + } + + // Post Body + CLAY(CLAY_IDI("PostBody", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = 8, .bottom = 8 } } + }) { + Clay_String lorem = { .length = strlen(LOREM[index % 5]), .chars = LOREM[index % 5] }; + CLAY_TEXT(lorem, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + } + + // Post Actions + CLAY(CLAY_IDI("PostActions", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = 16, .layoutDirection = CLAY_LEFT_TO_RIGHT } + }) { + CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {100, 200, 100, 255} })); + CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {100, 150, 255, 255} })); + CLAY_TEXT(CLAY_STRING("[ Share ]"), CLAY_TEXT_CONFIG({ .textColor = {200, 100, 100, 255} })); + } + } +} + +void RenderContent() { + CLAY(CLAY_ID("ContentArea"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, + .padding = CLAY_PADDING_ALL(16), + .childGap = 16, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = {10, 10, 10, 255} + }) { + // Sticky Header + CLAY(CLAY_ID("Header"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(48) }, // 3 cells high + .padding = { .left = 16, .right=16 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = {0, 0, 80, 255}, + .border = { .color = {0, 100, 255, 255}, .width = { .bottom = 1 } } + }) { + CLAY_TEXT(CLAY_STRING("Clay Social Feed"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + } + + // Scroll Viewport + // We use SIZING_GROW for height so it fills the remaining screen space. + CLAY(CLAY_ID("Viewport"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, + .padding = { .top = 8, .bottom = 8 } + }, + .clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() }, + .backgroundColor = {15, 15, 15, 255} + }) { + CLAY(CLAY_ID("FeedList"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, // Fit height to content (allows it to be taller than viewport) + .childGap = 16, + .layoutDirection = CLAY_TOP_TO_BOTTOM + } + }) { + // Get first item pos if possible + Clay_ElementData item0 = Clay_GetElementData(CLAY_IDI("Post", 0)); + + for (int i = 0; i < 50; ++i) { // 50 Posts + RenderPost(i); + } + + CLAY_TEXT(CLAY_STRING("--- End of Feed ---"), CLAY_TEXT_CONFIG({ .textColor = {140, 140, 140, 255} })); + } + } + + CLAY_TEXT(CLAY_STRING("Controls: ARROW UP/DOWN to Scroll | Q to Quit | S to Toggle Sidebar"), CLAY_TEXT_CONFIG({ .textColor = {120, 120, 120, 255} })); + } +} + +void RenderMainLayout() { + CLAY(CLAY_ID("Root"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .layoutDirection = CLAY_LEFT_TO_RIGHT }, + .backgroundColor = {0, 0, 0, 255} + }) { + RenderSidebar(); + RenderContent(); + } +} + +int main() { + uint32_t minMemory = Clay_MinMemorySize(); + Clay_Arena arena = Clay_CreateArenaWithCapacityAndMemory(minMemory, malloc(minMemory)); + + Clay_Initialize(arena, (Clay_Dimensions){0,0}, (Clay_ErrorHandler){NULL}); + Clay_SetMeasureTextFunction(Clay_Ncurses_MeasureText, NULL); + Clay_Ncurses_Initialize(); + + // Non-blocking input + timeout(0); + + while(!appState.shouldQuit) { + HandleInput(); + + Clay_Dimensions dims = Clay_Ncurses_GetLayoutDimensions(); + Clay_SetLayoutDimensions(dims); + + // Handle Scroll Logic + Clay_ElementId viewportId = CLAY_ID("Viewport"); + Clay_ElementData viewportData = Clay_GetElementData(viewportId); + + if (viewportData.found) { + Clay_Vector2 center = { + viewportData.boundingBox.x + viewportData.boundingBox.width / 2, + viewportData.boundingBox.y + viewportData.boundingBox.height / 2 + }; + Clay_SetPointerState(center, false); + + Clay_UpdateScrollContainers(true, (Clay_Vector2){0, appState.scrollDelta}, 0.016f); + } + + Clay_BeginLayout(); + RenderMainLayout(); + Clay_RenderCommandArray commands = Clay_EndLayout(); + + Clay_Ncurses_Render(commands); + + usleep(32000); + } + + Clay_Ncurses_Terminate(); + free(arena.memory); + return 0; +} diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c new file mode 100644 index 0000000..763bd60 --- /dev/null +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -0,0 +1,339 @@ +#include +#include +#include +#include +#include +#include "../../clay.h" + +// ------------------------------------------------------------------------------------------------- +// -- Internal State & Context +// ------------------------------------------------------------------------------------------------- + +#define CLAY_NCURSES_CELL_WIDTH 8.0f +#define CLAY_NCURSES_CELL_HEIGHT 16.0f + +static int _clayNcursesScreenWidth = 0; +static int _clayNcursesScreenHeight = 0; +static bool _clayNcursesInitialized = false; + +// Scissor / Clipping State +#define MAX_SCISSOR_STACK_DEPTH 16 +static Clay_BoundingBox _scissorStack[MAX_SCISSOR_STACK_DEPTH]; +static int _scissorStackIndex = 0; + +// Color State +// We reserve pair 0. Pairs 1..max are dynamically allocated. +#define MAX_COLOR_PAIRS_CACHE 256 +static struct { + short fg; + short bg; + int pairId; +} _colorPairCache[MAX_COLOR_PAIRS_CACHE]; +static int _colorPairCacheSize = 0; + +// ------------------------------------------------------------------------------------------------- +// -- Constants +// ------------------------------------------------------------------------------------------------- + +// Standard ANSI Colors mapped to easier indices if needed, +// allows extending to 256 colors easily later. + +// ------------------------------------------------------------------------------------------------- +// -- Forward Declarations +// ------------------------------------------------------------------------------------------------- + +static short Clay_Ncurses_GetColorId(Clay_Color color); +static int Clay_Ncurses_GetColorPair(short fg, short bg); +static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, int *outY, int *outW, int *outH); + +// ------------------------------------------------------------------------------------------------- +// -- Public API Implementation +// ------------------------------------------------------------------------------------------------- + +void Clay_Ncurses_Initialize() { + if (_clayNcursesInitialized) return; + + initscr(); + cbreak(); // Line buffering disabled + noecho(); // Don't echo input + keypad(stdscr, TRUE); // Enable arrow keys + curs_set(0); // Hide cursor + + // Enable mouse events if available + mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); + + start_color(); + use_default_colors(); + + // Refresh screen dimensions + getmaxyx(stdscr, _clayNcursesScreenHeight, _clayNcursesScreenWidth); + + // Initialize Scissor Stack with full screen + _scissorStack[0] = (Clay_BoundingBox){0, 0, (float)_clayNcursesScreenWidth * CLAY_NCURSES_CELL_WIDTH, (float)_clayNcursesScreenHeight * CLAY_NCURSES_CELL_HEIGHT}; + _scissorStackIndex = 0; + + _clayNcursesInitialized = true; +} + +void Clay_Ncurses_Terminate() { + if (_clayNcursesInitialized) { + clear(); + refresh(); + endwin(); + _clayNcursesInitialized = false; + } +} + +Clay_Dimensions Clay_Ncurses_GetLayoutDimensions() { + return (Clay_Dimensions) { + .width = (float)_clayNcursesScreenWidth * CLAY_NCURSES_CELL_WIDTH, + .height = (float)_clayNcursesScreenHeight * CLAY_NCURSES_CELL_HEIGHT + }; +} + +Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) { + (void)config; + (void)userData; + // Simple 1-to-1 mapping + return (Clay_Dimensions) { + .width = (float)text.length * CLAY_NCURSES_CELL_WIDTH, + .height = CLAY_NCURSES_CELL_HEIGHT + }; +} + +void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { + if (!_clayNcursesInitialized) return; + + erase(); // Clear buffer + + // Update dimensions on render start (handle resize gracefully-ish) + int newW, newH; + getmaxyx(stdscr, newH, newW); + if (newW != _clayNcursesScreenWidth || newH != _clayNcursesScreenHeight) { + _clayNcursesScreenWidth = newW; + _clayNcursesScreenHeight = newH; + } + + // Reset Scissor Stack + _scissorStack[0] = (Clay_BoundingBox){0, 0, (float)_clayNcursesScreenWidth * CLAY_NCURSES_CELL_WIDTH, (float)_clayNcursesScreenHeight * CLAY_NCURSES_CELL_HEIGHT}; + _scissorStackIndex = 0; + + for (int i = 0; i < renderCommands.length; i++) { + Clay_RenderCommand *command = Clay_RenderCommandArray_Get(&renderCommands, i); + Clay_BoundingBox box = command->boundingBox; + + switch (command->commandType) { + case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: { + // Convert to integer coords + int x = (int)(box.x / CLAY_NCURSES_CELL_WIDTH); + int y = (int)(box.y / CLAY_NCURSES_CELL_HEIGHT); + int w = (int)(box.width / CLAY_NCURSES_CELL_WIDTH); + int h = (int)(box.height / CLAY_NCURSES_CELL_HEIGHT); + + // Apply Scissor + int dx, dy, dw, dh; + if (!Clay_Ncurses_IntersectScissor(x, y, w, h, &dx, &dy, &dw, &dh)) continue; + + // Color + short fg = Clay_Ncurses_GetColorId(command->renderData.rectangle.backgroundColor); + short bg = fg; // Solid block + int pair = Clay_Ncurses_GetColorPair(fg, bg); + + attron(COLOR_PAIR(pair)); + for (int row = dy; row < dy + dh; row++) { + for (int col = dx; col < dx + dw; col++) { + mvaddch(row, col, ' '); + } + } + attroff(COLOR_PAIR(pair)); + break; + } + case CLAY_RENDER_COMMAND_TYPE_TEXT: { + // Text is tricky with clipping. + // We need to clip the string and the position. + int x = (int)(box.x / CLAY_NCURSES_CELL_WIDTH); + int y = (int)(box.y / CLAY_NCURSES_CELL_HEIGHT); + // Text width/height + Clay_StringSlice text = command->renderData.text.stringContents; + int w = text.length; + int h = 1; + + int dx, dy, dw, dh; + if (!Clay_Ncurses_IntersectScissor(x, y, w, h, &dx, &dy, &dw, &dh)) continue; + + // Color (bg = -1 for transparent/default) + short fg = Clay_Ncurses_GetColorId(command->renderData.text.textColor); + int pair = Clay_Ncurses_GetColorPair(fg, -1); + + attron(COLOR_PAIR(pair)); + + // Calculate substring to print based on clip + // dx is the starting x on screen. x is original start. + // offset in string = dx - x + int offset = dx - x; + int len = dw; + + if (offset >= 0 && offset < text.length) { + mvaddnstr(dy, dx, text.chars + offset, len); + } + + attroff(COLOR_PAIR(pair)); + break; + } + case CLAY_RENDER_COMMAND_TYPE_BORDER: { + int x = (int)(box.x / CLAY_NCURSES_CELL_WIDTH); + int y = (int)(box.y / CLAY_NCURSES_CELL_HEIGHT); + int w = (int)(box.width / CLAY_NCURSES_CELL_WIDTH); + int h = (int)(box.height / CLAY_NCURSES_CELL_HEIGHT); + + // TODO: Robust border culling. For now, check if the whole rect intersects AT ALL + int dx, dy, dw, dh; + if (!Clay_Ncurses_IntersectScissor(x, y, w, h, &dx, &dy, &dw, &dh)) continue; + + short color = Clay_Ncurses_GetColorId(command->renderData.border.color); + int pair = Clay_Ncurses_GetColorPair(color, -1); + attron(COLOR_PAIR(pair)); + + // Naive drawing (does not strictly respect scissor for PARTIAL borders, only fully skipped ones if outside) + // Truly correct way handles each line. + // Top + if (y >= dy && y < dy + dh) { + int sx = x + 1, sw = w - 2; + // Intersect line with scissor X + int lx = (sx > dx) ? sx : dx; + int rx = (sx + sw < dx + dw) ? (sx + sw) : (dx + dw); + if (lx < rx) mvhline(y, lx, ACS_HLINE, rx - lx); + } + // Bottom + if (y + h - 1 >= dy && y + h - 1 < dy + dh) { + int sx = x + 1, sw = w - 2; + int lx = (sx > dx) ? sx : dx; + int rx = (sx + sw < dx + dw) ? (sx + sw) : (dx + dw); + if (lx < rx) mvhline(y + h - 1, lx, ACS_HLINE, rx - lx); + } + // Left + if (x >= dx && x < dx + dw) { + int sy = y + 1, sh = h - 2; + int ty = (sy > dy) ? sy : dy; + int by = (sy + sh < dy + dh) ? (sy + sh) : (dy + dh); + if (ty < by) mvvline(ty, x, ACS_VLINE, by - ty); + } + // Right + if (x + w - 1 >= dx && x + w - 1 < dx + dw) { + int sy = y + 1, sh = h - 2; + int ty = (sy > dy) ? sy : dy; + int by = (sy + sh < dy + dh) ? (sy + sh) : (dy + dh); + if (ty < by) mvvline(ty, x + w - 1, ACS_VLINE, by - ty); + } + // Corners (simple visibility check) + if (x >= dx && x < dx + dw && y >= dy && y < dy + dh) mvaddch(y, x, ACS_ULCORNER); + if (x + w - 1 >= dx && x + w - 1 < dx + dw && y >= dy && y < dy + dh) mvaddch(y, x + w - 1, ACS_URCORNER); + if (x >= dx && x < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) mvaddch(y + h - 1, x, ACS_LLCORNER); + if (x + w - 1 >= dx && x + w - 1 < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) mvaddch(y + h - 1, x + w - 1, ACS_LRCORNER); + + attroff(COLOR_PAIR(pair)); + break; + } + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: { + if (_scissorStackIndex < MAX_SCISSOR_STACK_DEPTH - 1) { + Clay_BoundingBox current = _scissorStack[_scissorStackIndex]; + Clay_BoundingBox next = command->boundingBox; + + // Intersect next with current + float nX = (next.x > current.x) ? next.x : current.x; + float nY = (next.y > current.y) ? next.y : current.y; + float nR = ((next.x + next.width) < (current.x + current.width)) ? (next.x + next.width) : (current.x + current.width); + float nB = ((next.y + next.height) < (current.y + current.height)) ? (next.y + next.height) : (current.y + current.height); + + _scissorStackIndex++; + _scissorStack[_scissorStackIndex] = (Clay_BoundingBox){ nX, nY, nR - nX, nB - nY }; + } + break; + } + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: { + if (_scissorStackIndex > 0) { + _scissorStackIndex--; + } + break; + } + default: break; + } + } + + refresh(); +} + +// ------------------------------------------------------------------------------------------------- +// -- Internal Helpers +// ------------------------------------------------------------------------------------------------- + +static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, int *outY, int *outW, int *outH) { + Clay_BoundingBox clip = _scissorStack[_scissorStackIndex]; + + // Convert clip to cell coords + int cx = (int)(clip.x / CLAY_NCURSES_CELL_WIDTH); + int cy = (int)(clip.y / CLAY_NCURSES_CELL_HEIGHT); + int cw = (int)(clip.width / CLAY_NCURSES_CELL_WIDTH); + int ch = (int)(clip.height / CLAY_NCURSES_CELL_HEIGHT); + + // Intersect + int ix = (x > cx) ? x : cx; + int iy = (y > cy) ? y : cy; + int right = (x + w < cx + cw) ? (x + w) : (cx + cw); + int bottom = (y + h < cy + ch) ? (y + h) : (cy + ch); + + int iw = right - ix; + int ih = bottom - iy; + + if (iw <= 0 || ih <= 0) return false; + + *outX = ix; + *outY = iy; + *outW = iw; + *outH = ih; + return true; +} + +static short Clay_Ncurses_GetColorId(Clay_Color color) { + // 3-bit Color Mapping (Simple thresholding) + int r = color.r > 128; + int g = color.g > 128; + int b = color.b > 128; + + if (r && g && b) return COLOR_WHITE; + if (!r && !g && !b) return COLOR_BLACK; + if (r && g) return COLOR_YELLOW; + if (r && b) return COLOR_MAGENTA; + if (g && b) return COLOR_CYAN; + if (r) return COLOR_RED; + if (g) return COLOR_GREEN; + if (b) return COLOR_BLUE; + + return COLOR_WHITE; +} + +static int Clay_Ncurses_GetColorPair(short fg, short bg) { + // Check cache + for (int i = 0; i < _colorPairCacheSize; i++) { + if (_colorPairCache[i].fg == fg && _colorPairCache[i].bg == bg) { + return _colorPairCache[i].pairId; + } + } + + // Create new + if (_colorPairCacheSize >= MAX_COLOR_PAIRS_CACHE) { + // Full? Just return last one or default. + // Real impl: LRU eviction. + return 0; // Default + } + + int newId = _colorPairCacheSize + 1; + init_pair(newId, fg, bg); + + _colorPairCache[_colorPairCacheSize].fg = fg; + _colorPairCache[_colorPairCacheSize].bg = bg; + _colorPairCache[_colorPairCacheSize].pairId = newId; + _colorPairCacheSize++; + + return newId; +}