From 840606d0c1ce5a07bcdbfdb1902865dc6fed2a73 Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 14:01:41 +0100 Subject: [PATCH 1/9] 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; +} From d4a48a07fccf863e3e1df0c81a3520bdb2fe9a1c Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 15:19:37 +0100 Subject: [PATCH 2/9] feat(ncurses): overhaul renderer with UTF-8, 256-colors, and visual improvements Significantly enhances the Ncurses renderer capabilities and updates the example application. Renderer Changes: - Unicode Support: - Implemented automatic UTF-8 locale detection and initialization. - Switched to wide-character handling (`wchar_t`, `mvaddnwstr`) for correct rendering of multi-byte characters (e.g., Emojis). - Used `wcwidth` for accurate string width measurement. - Color Support: - Upgraded from 3-bit (8 colors) to 256-color support (xterm-256color). - Added `Clay_Ncurses_MatchColor` to map arbitrary RGB values to the nearest color in the standard 6x6x6 color cube. - Added capability detection to fallback gracefully on simpler terminals. - Visual Fidelity: - Implemented background color inheritance (`Clay_Ncurses_GetBackgroundAt`) to simulate transparency. - Text and borders now render on top of existing background colors instead of resetting to the terminal default. - Build & POSIX: - Added `_XOPEN_SOURCE_EXTENDED` and `_XOPEN_SOURCE=700` definitions for standard compliance. Example Application (clay-ncurses-example): - Theme: - Updated to a modern dark theme (Uniform `{20, 20, 20}` background). - Switched to saturated/bright foreground colors for better contrast. - Fixes: - Replaced obsolete `usleep` with POSIX-compliant `nanosleep`. - Build: - Updated CMakeLists.txt to enforce linking against `ncursesw` (wide version). Verified with `clay-ncurses-example` on Linux (xterm-256color). --- examples/ncurses-example/CMakeLists.txt | 3 + examples/ncurses-example/main.c | 37 ++-- renderers/ncurses/clay_renderer_ncurses.c | 237 +++++++++++++++++++--- 3 files changed, 229 insertions(+), 48 deletions(-) diff --git a/examples/ncurses-example/CMakeLists.txt b/examples/ncurses-example/CMakeLists.txt index 8b6d0ab..19fe1af 100644 --- a/examples/ncurses-example/CMakeLists.txt +++ b/examples/ncurses-example/CMakeLists.txt @@ -1,8 +1,11 @@ cmake_minimum_required(VERSION 3.27) project(clay-ncurses-example C) +set(CURSES_NEED_WIDE TRUE) find_package(Curses REQUIRED) +add_compile_definitions(_XOPEN_SOURCE_EXTENDED _XOPEN_SOURCE=700) + add_executable(clay-ncurses-example main.c) target_link_libraries(clay-ncurses-example PRIVATE ${CURSES_LIBRARIES}) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index f15e349..02f2e52 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -1,7 +1,7 @@ #define CLAY_IMPLEMENTATION #include "../../clay.h" #include "../../renderers/ncurses/clay_renderer_ncurses.c" -#include // for usleep +#include // for nanosleep #define DEFAULT_SCROLL_DELTA 3.0f @@ -45,25 +45,25 @@ void RenderSidebar() { .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 + .backgroundColor = {20, 20, 20, 255}, // Uniform Dark BG + .border = { .color = {100, 100, 100, 255}, .width = { .right = 2 } } // Lighter Grey Border }) { CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({ - .textColor = {255, 255, 0, 255} + .textColor = {255, 255, 0, 255} // Bright Yellow })); CLAY(CLAY_ID("SidebarItem1"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, - .backgroundColor = {60, 60, 60, 255} + .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { - CLAY_TEXT(CLAY_STRING(" > Item 1"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + CLAY_TEXT(CLAY_STRING(" > Item 1 (Hello 🌍)"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} })); // Cyan } CLAY(CLAY_ID("SidebarItem2"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, - .backgroundColor = {60, 60, 60, 255} + .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { - CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); // White } } } @@ -105,9 +105,9 @@ void RenderPost(int index) { .childGap = 8, .layoutDirection = CLAY_TOP_TO_BOTTOM }, - .backgroundColor = {25, 25, 25, 255}, + .backgroundColor = {20, 20, 20, 255}, // Uniform BG .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 } } + .border = { .color = {80, 80, 80, 255}, .width = { .left = 1, .right = 1, .top = 1, .bottom = 1 } } }) { // Post Header: Avatar + Name + Time CLAY(CLAY_IDI("PostHeader", index), { @@ -148,9 +148,9 @@ void RenderPost(int index) { 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} })); + CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 0, 255} })); // Bright Green + CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 100, 255, 255} })); // Bright Blue + CLAY_TEXT(CLAY_STRING("[ Share ]"), CLAY_TEXT_CONFIG({ .textColor = {255, 0, 0, 255} })); // Bright Red } } } @@ -163,7 +163,7 @@ void RenderContent() { .childGap = 16, .layoutDirection = CLAY_TOP_TO_BOTTOM }, - .backgroundColor = {10, 10, 10, 255} + .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { // Sticky Header CLAY(CLAY_ID("Header"), { @@ -172,7 +172,7 @@ void RenderContent() { .padding = { .left = 16, .right=16 }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, - .backgroundColor = {0, 0, 80, 255}, + .backgroundColor = {20, 20, 20, 255}, // Uniform BG .border = { .color = {0, 100, 255, 255}, .width = { .bottom = 1 } } }) { CLAY_TEXT(CLAY_STRING("Clay Social Feed"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); @@ -186,7 +186,7 @@ void RenderContent() { .padding = { .top = 8, .bottom = 8 } }, .clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() }, - .backgroundColor = {15, 15, 15, 255} + .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { CLAY(CLAY_ID("FeedList"), { .layout = { @@ -213,7 +213,7 @@ void RenderContent() { void RenderMainLayout() { CLAY(CLAY_ID("Root"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .layoutDirection = CLAY_LEFT_TO_RIGHT }, - .backgroundColor = {0, 0, 0, 255} + .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { RenderSidebar(); RenderContent(); @@ -257,7 +257,8 @@ int main() { Clay_Ncurses_Render(commands); - usleep(32000); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 32000 * 1000 }; + nanosleep(&ts, NULL); } Clay_Ncurses_Terminate(); diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 763bd60..6ac4807 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -1,8 +1,18 @@ +#ifndef _XOPEN_SOURCE_EXTENDED +#define _XOPEN_SOURCE_EXTENDED +#endif + +#ifndef _XOPEN_SOURCE +#define _XOPEN_SOURCE 700 +#endif + #include #include #include #include #include +#include +#include #include "../../clay.h" // ------------------------------------------------------------------------------------------------- @@ -23,7 +33,7 @@ static int _scissorStackIndex = 0; // Color State // We reserve pair 0. Pairs 1..max are dynamically allocated. -#define MAX_COLOR_PAIRS_CACHE 256 +#define MAX_COLOR_PAIRS_CACHE 1024 static struct { short fg; short bg; @@ -45,6 +55,18 @@ static int _colorPairCacheSize = 0; 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); +static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, int *outY, int *outW, int *outH); +static void Clay_Ncurses_InitLocale(void); +static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text); +static void Clay_Ncurses_RenderText(Clay_StringSlice text, int x, int y, int renderWidth); + +static short Clay_Ncurses_GetBackgroundAt(int x, int y) { + chtype ch = mvinch(y, x); + int pair = PAIR_NUMBER(ch); + short fg, bg; + pair_content(pair, &fg, &bg); + return bg; +} // ------------------------------------------------------------------------------------------------- // -- Public API Implementation @@ -53,6 +75,7 @@ static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, void Clay_Ncurses_Initialize() { if (_clayNcursesInitialized) return; + Clay_Ncurses_InitLocale(); initscr(); cbreak(); // Line buffering disabled noecho(); // Don't echo input @@ -94,9 +117,10 @@ Clay_Dimensions Clay_Ncurses_GetLayoutDimensions() { Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) { (void)config; (void)userData; - // Simple 1-to-1 mapping + // Measure string width using wcwidth + int width = Clay_Ncurses_MeasureStringWidth(text); return (Clay_Dimensions) { - .width = (float)text.length * CLAY_NCURSES_CELL_WIDTH, + .width = (float)width * CLAY_NCURSES_CELL_WIDTH, .height = CLAY_NCURSES_CELL_HEIGHT }; } @@ -155,26 +179,102 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { 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 textWidth = Clay_Ncurses_MeasureStringWidth(text); int dx, dy, dw, dh; - if (!Clay_Ncurses_IntersectScissor(x, y, w, h, &dx, &dy, &dw, &dh)) continue; + if (!Clay_Ncurses_IntersectScissor(x, y, textWidth, 1, &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); + + // Inherit background from screen + short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); + + int pair = Clay_Ncurses_GetColorPair(fg, bg); 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; + // Helper to handle wide char conversion and clipping + // We pass the screen coords and expected render width + // The helper will handle converting to wchar and printing the slice + // But wait, our generic helper accepts 'x' (start) and we need to skip? + // For simplicity, let's inline or call a robust helper that takes scissor into account. + // Since 'dw' is the width we *can* draw... - if (offset >= 0 && offset < text.length) { - mvaddnstr(dy, dx, text.chars + offset, len); + // We need to skip 'dx - x' columns of the string. + // This is hard with variable width chars. + // Simpler approach: Convert entire string to wchar_t, then skip/take based on wcwidth. + + int skipCols = dx - x; + int takeCols = dw; + + // Temp buffer for wide string + // Assuming reasonable max length or malloc + int maxLen = text.length + 1; + wchar_t *wbuf = (wchar_t *)malloc(maxLen * sizeof(wchar_t)); + if (wbuf) { + // Convert UTF-8 text to wchar + // We need a null-terminated string for mbstowcs usually, + // or use mbsnrtowcs. + // Clay text is not null term. + char *tempC = (char *)malloc(text.length + 1); + memcpy(tempC, text.chars, text.length); + tempC[text.length] = '\0'; + + int wlen = mbstowcs(wbuf, tempC, maxLen); + free(tempC); + + if (wlen != -1) { + // Now we have wide chars. We need to find the substring that fits [skipCols ... skipCols+takeCols] + int currentCols = 0; + int startIdx = 0; + int endIdx = 0; + + // Find start + for (int k = 0; k < wlen; k++) { + int cw = wcwidth(wbuf[k]); + if (cw < 0) cw = 0; // Unprintable? + + if (currentCols >= skipCols) { + startIdx = k; + break; + } + currentCols += cw; + startIdx = k + 1; + } + + // Find end + currentCols = 0; // Relative to skipped part? + // Re-scan? No, continue? + // Better: track cumulative width. + + // Restart logic: + int col = 0; + int printStart = -1; + int printLen = 0; + + for (int k = 0; k < wlen; k++) { + int cw = wcwidth(wbuf[k]); + if (cw < 0) cw = 0; + + // If this char starts within the window + if (col >= skipCols && col < skipCols + takeCols) { + if (printStart == -1) printStart = k; + printLen++; + } else if (col < skipCols && col + cw > skipCols) { + // Overlap start boundary (e.g. half of a wide char?) + // ncurses handles this usually? Or we skip it. + } + + col += cw; + if (col >= skipCols + takeCols) break; + } + + if (printStart != -1) { + mvaddnwstr(dy, dx, wbuf + printStart, printLen); + } + } + free(wbuf); } attroff(COLOR_PAIR(pair)); @@ -191,7 +291,11 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { 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); + + // Inherit background from the corner of the border (assume uniform) + short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); + int pair = Clay_Ncurses_GetColorPair(color, bg); + attron(COLOR_PAIR(pair)); // Naive drawing (does not strictly respect scissor for PARTIAL borders, only fully skipped ones if outside) @@ -294,22 +398,52 @@ static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, return true; } +static short Clay_Ncurses_MatchColor(Clay_Color color) { + // If not 256 colors, fallback to 8 colors + if (COLORS < 256) { + 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; + } + + // 256 Color Match + // 1. Check standard ANSI (0-15) - simplified, usually handled by cube approximation anyway but kept for specific fidelity if needed. + + // 2. 6x6x6 Color Cube (16 - 231) + // Formula: 16 + (36 * r) + (6 * g) + b + // where r,g,b are 0-5 + + int r = (int)((color.r / 255.0f) * 5.0f); + int g = (int)((color.g / 255.0f) * 5.0f); + int b = (int)((color.b / 255.0f) * 5.0f); + + // We can compute distance but mapping to the 0-5 grid is usually "good enough" for TUI + // For better fidelity we actually map 0-255 to the specific values [0, 95, 135, 175, 215, 255] used in xterm + // But simple linear 0-5 bucket is standard shortcut. + + // Let's use simple bucket for now. + int cubeIndex = 16 + (36 * r) + (6 * g) + b; + + // 3. Grayscale (232-255) + // If r~=g~=b, check if grayscale provides better match? + // Often cube is fine. Grayscale ramp adds fine detail for darks. + // For now, cube is sufficient for general UI. + + return (short)cubeIndex; +} + 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; + return Clay_Ncurses_MatchColor(color); } static int Clay_Ncurses_GetColorPair(short fg, short bg) { @@ -337,3 +471,46 @@ static int Clay_Ncurses_GetColorPair(short fg, short bg) { return newId; } + +static void Clay_Ncurses_InitLocale(void) { + // Attempt 1: environment locale + char *locale = setlocale(LC_ALL, ""); + + // If environment is non-specific (C or POSIX), try to force a UTF-8 one. + if (!locale || strcmp(locale, "C") == 0 || strcmp(locale, "POSIX") == 0) { + // Attempt 2: C.UTF-8 (standard on many modern Linux) + locale = setlocale(LC_ALL, "C.UTF-8"); + + if (!locale) { + // Attempt 3: en_US.UTF-8 (Common fallback) + locale = setlocale(LC_ALL, "en_US.UTF-8"); + } + } +} + +static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { + // Need temporary null-terminated string for mbstowcs + // Or iterate bytes with mbtowc + int width = 0; + const char *ptr = text.chars; + int len = text.length; + + // Reset shift state + mbtowc(NULL, NULL, 0); + + while (len > 0) { + wchar_t wc; + int bytes = mbtowc(&wc, ptr, len); + if (bytes <= 0) { + // Error or null? skip byte + ptr++; + len--; + continue; + } + int w = wcwidth(wc); + if (w > 0) width += w; + ptr += bytes; + len -= bytes; + } + return width; +} From de3d63cf6100c236cf4d4dbf7dedefc51ffd1991 Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 18:05:06 +0100 Subject: [PATCH 3/9] =?UTF-8?q?feat(ncurses):=20optimize=20rendering,=20fi?= =?UTF-8?q?x=20memory=20leaks,=20and=20add=20rounded=20corners=20Significa?= =?UTF-8?q?ntly=20improves=20the=20stability,=20performance,=20and=20visua?= =?UTF-8?q?l=20quality=20of=20the=20ncurses=20renderer.=20**Renderer=20Imp?= =?UTF-8?q?rovements=20(`clay=5Frenderer=5Fncurses.c`):**=20*=20=20=20**Fl?= =?UTF-8?q?icker=20Reduction**:=20=20=20=20=20*=20=20=20Removed=20`erase()?= =?UTF-8?q?`=20call=20at=20the=20start=20of=20the=20frame=20to=20enable=20?= =?UTF-8?q?differential=20rendering.=20=20=20=20=20*=20=20=20Implemented?= =?UTF-8?q?=20"Dirty=20Check"=20optimizations=20for=20Rectangles,=20Border?= =?UTF-8?q?s,=20and=20Text.=20The=20renderer=20now=20reads=20the=20existin?= =?UTF-8?q?g=20screen=20content=20(using=20`mvinch`,=20`mvin=5Fwch`,=20`mv?= =?UTF-8?q?in=5Fwchnstr`)=20and=20only=20issues=20draw=20commands=20if=20t?= =?UTF-8?q?he=20content=20or=20color=20differs.=20=20=20=20=20*=20=20=20Ha?= =?UTF-8?q?rdened=20Rectangle=20dirty=20check=20to=20mask=20out=20volatile?= =?UTF-8?q?=20attributes=20(comparing=20only=20`A=5FCHARTEXT=20|=20A=5FCOL?= =?UTF-8?q?OR`),=20preventing=20false-positive=20redraws=20caused=20by=20i?= =?UTF-8?q?nternal=20terminal=20flags.=20*=20=20=20**Memory=20Safety**:=20?= =?UTF-8?q?=20=20=20=20*=20=20=20Fixed=20internal=20ncurses=20memory=20lea?= =?UTF-8?q?ks=20by=20calling=20`delscreen(set=5Fterm(NULL))`=20in=20`Clay?= =?UTF-8?q?=5FNcurses=5FTerminate`=20to=20properly=20free=20the=20default?= =?UTF-8?q?=20screen=20wrapper.=20*=20=20=20**Visual=20Features**:=20=20?= =?UTF-8?q?=20=20=20*=20=20=20Added=20support=20for=20**Rounded=20Corners*?= =?UTF-8?q?*:=20Borders=20with=20`cornerRadius=20>=200`=20now=20render=20u?= =?UTF-8?q?sing=20Unicode=20arc=20characters=20(`=E2=95=AD`,=20`=E2=95=AE`?= =?UTF-8?q?,=20`=E2=95=AF`,=20`=E2=95=B0`).=20=20=20=20=20*=20=20=20Upgrad?= =?UTF-8?q?ed=20standard=20borders=20to=20use=20full=20Unicode=20box-drawi?= =?UTF-8?q?ng=20characters.=20**Example=20Application=20Updates=20(`ncurse?= =?UTF-8?q?s-example/main.c`):**=20*=20=20=20**Layout=20Stability**:=20=20?= =?UTF-8?q?=20=20=20*=20=20=20Refactored=20all=20layout=20dimensions=20and?= =?UTF-8?q?=20gaps=20to=20use=20`CLAY=5FNCURSES=5FCELL=5FWIDTH`=20(8)=20an?= =?UTF-8?q?d=20`CLAY=5FNCURSES=5FCELL=5FHEIGHT`=20(16)=20macros,=20ensurin?= =?UTF-8?q?g=20strict=20grid=20alignment.=20=20=20=20=20*=20=20=20Fixed=20?= =?UTF-8?q?vertical=20jitter=20in=20"Profile=20Icon"=20and=20text=20header?= =?UTF-8?q?s=20by=20enforcing=20exact=20height=20multiples=20and=20top-ali?= =?UTF-8?q?gnment,=20eliminating=20sub-pixel=20rounding=20errors=20during?= =?UTF-8?q?=20scroll.=20*=20=20=20**New=20UI=20Elements**:=20=20=20=20=20*?= =?UTF-8?q?=20=20=20Added=20a=20**Floating=20Help=20Modal**=20(toggled=20v?= =?UTF-8?q?ia=20'H')=20to=20demonstrate=20Z-ordering=20and=20localized=20i?= =?UTF-8?q?nput=20handling.=20=20=20=20=20*=20=20=20Added=20"Server=20Stat?= =?UTF-8?q?us"=20progress=20bars=20to=20the=20Sidebar=20to=20demonstrate?= =?UTF-8?q?=20percent-based=20sizing=20and=20colored=20rectangles.=20=20?= =?UTF-8?q?=20=20=20*=20=20=20Added=20"Mixed=20Border"=20examples=20to=20t?= =?UTF-8?q?he=20Sidebar=20to=20showcase=20the=20new=20rounded=20corner=20c?= =?UTF-8?q?apabilities.=20=20=20=20=20*=20=20=20Added=20"Black"=20backgrou?= =?UTF-8?q?nd=20constant=20usage=20for=20cleaner=20code.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/ncurses-example/main.c | 188 ++++++++++++++++++---- renderers/ncurses/clay_renderer_ncurses.c | 182 +++++++++++---------- 2 files changed, 257 insertions(+), 113 deletions(-) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index 02f2e52..a0e4faa 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -4,15 +4,22 @@ #include // for nanosleep #define DEFAULT_SCROLL_DELTA 3.0f +#define BLACK_BG_COLOR {20, 20, 20, 255} // State for the example typedef struct { bool sidebarOpen; float scrollDelta; + bool showHelp; bool shouldQuit; } AppState; -static AppState appState = { .sidebarOpen = true, .scrollDelta = 0.0f, .shouldQuit = false }; +static AppState appState = { + .sidebarOpen = true, + .scrollDelta = 0.0f, + .shouldQuit = false, + .showHelp = false +}; void HandleInput() { // Reset delta per frame @@ -26,6 +33,9 @@ void HandleInput() { if (ch == 's' || ch == 'S') { appState.sidebarOpen = !appState.sidebarOpen; } + if (ch == 'h' || ch == 'H') { + appState.showHelp = !appState.showHelp; + } if (ch == KEY_UP) { appState.scrollDelta += DEFAULT_SCROLL_DELTA; } @@ -35,35 +45,149 @@ void HandleInput() { } } +void RenderProgressBar(Clay_String label, float percent, Clay_Color color) { + CLAY(CLAY_ID_LOCAL("ProgressBarWrapper"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = CLAY_NCURSES_CELL_HEIGHT } + }) { + CLAY(CLAY_ID_LOCAL("LabelRow"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_LEFT_TO_RIGHT, .childGap = CLAY_NCURSES_CELL_HEIGHT, .childAlignment = {.y = CLAY_ALIGN_Y_CENTER} } + }) { + CLAY_TEXT(label, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255}, .fontSize = 16 })); + } + + CLAY(CLAY_ID_LOCAL("BarBackground"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT) } }, + .backgroundColor = {40, 40, 40, 255}, + .cornerRadius = {1} + }) { + CLAY(CLAY_ID_LOCAL("BarFill"), { + .layout = { .sizing = { CLAY_SIZING_PERCENT(percent), CLAY_SIZING_GROW() } }, + .backgroundColor = color, + .cornerRadius = {1} + }) {} + } + } +} + +void RenderServerStatus() { + CLAY(CLAY_ID("ServerStatus"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .padding = {16, 16, 16, 16}, + .childGap = 16, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = {25, 25, 25, 255}, + .border = { .color = {60, 60, 60, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING("SERVER STATUS"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + RenderProgressBar(CLAY_STRING("CPU"), 0.45f, (Clay_Color){0, 200, 0, 255}); + RenderProgressBar(CLAY_STRING("Mem"), 0.82f, (Clay_Color){200, 150, 0, 255}); + } +} + +void RenderHelpModal() { + if (!appState.showHelp) return; + + CLAY(CLAY_ID("HelpModalOverlay"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .childAlignment = {CLAY_ALIGN_X_CENTER, CLAY_ALIGN_Y_CENTER} }, + .floating = { .zIndex = 100, .attachTo = CLAY_ATTACH_TO_ROOT, .pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_CAPTURE }, + .backgroundColor = {0, 0, 0, 150} + }) { + CLAY(CLAY_ID("HelpModalWindow"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 60), CLAY_SIZING_FIT(0) }, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_WIDTH, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = {30, 30, 30, 255}, + .cornerRadius = {4}, + .border = { .color = {255, 255, 255, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING("Ncurses Example Help"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + + CLAY(CLAY_ID("HelpLine1"), { .layout = { .sizing = {CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0)} } }) { + CLAY_TEXT(CLAY_STRING("Keys:"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 0, 255} })); + } + CLAY_TEXT(CLAY_STRING("- ARROW KEYS: Scroll Feed"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + CLAY_TEXT(CLAY_STRING("- S: Toggle Sidebar"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + CLAY_TEXT(CLAY_STRING("- H: Toggle This Help"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + CLAY_TEXT(CLAY_STRING("- Q: Quit Application"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + + CLAY(CLAY_ID("HelpCloseTip"), { .layout = { .sizing = {CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0)}, .padding = {.top = 16} } }) { + CLAY_TEXT(CLAY_STRING("Press 'H' to close."), CLAY_TEXT_CONFIG({ .textColor = {100, 100, 100, 255} })); + } + } + } +} + 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, + .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 30), CLAY_SIZING_GROW() }, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_TOP_TO_BOTTOM }, - .backgroundColor = {20, 20, 20, 255}, // Uniform Dark BG + .backgroundColor = {20, 20, 20, 255}, .border = { .color = {100, 100, 100, 255}, .width = { .right = 2 } } // Lighter Grey Border }) { CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 0, 255} // Bright Yellow })); + RenderServerStatus(); + CLAY(CLAY_ID("SidebarItem1"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, - .backgroundColor = {20, 20, 20, 255} // Uniform BG + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, + .backgroundColor = BLACK_BG_COLOR }) { - CLAY_TEXT(CLAY_STRING(" > Item 1 (Hello 🌍)"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} })); // Cyan + CLAY_TEXT(CLAY_STRING(" > Item 1 🌍"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} })); } CLAY(CLAY_ID("SidebarItem2"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } }, - .backgroundColor = {20, 20, 20, 255} // Uniform BG + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, + .backgroundColor = BLACK_BG_COLOR }) { - CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); // White + CLAY_TEXT(CLAY_STRING(" > Item 2 🌐"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + } + CLAY(CLAY_ID("SidebarItemMixed1"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = {20, 20, 20, 255}, + .cornerRadius = { .topLeft = 8 }, + .border = { .color = {255, 100, 100, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = {255, 100, 100, 255} })); + } + + CLAY(CLAY_ID("SidebarItemMixed2"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = {20, 20, 20, 255}, + .cornerRadius = { .topLeft = 8, .bottomRight = 8 }, + .border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} })); + } + + CLAY(CLAY_ID("SidebarItemMixed3"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = {20, 20, 20, 255}, + .cornerRadius = { .topLeft = 8, .topRight = 8 }, + .border = { .color = {100, 100, 255, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = {100, 100, 255, 255} })); } } } @@ -101,33 +225,33 @@ 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, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_TOP_TO_BOTTOM }, - .backgroundColor = {20, 20, 20, 255}, // Uniform BG - .cornerRadius = {8}, // Rounded corners (will render as square in TUI usually unless ACS handled) - .border = { .color = {80, 80, 80, 255}, .width = { .left = 1, .right = 1, .top = 1, .bottom = 1 } } + .backgroundColor = BLACK_BG_COLOR, + .cornerRadius = {1}, + .border = { .color = {80, 80, 80, 255}, .width = {2, 2, 2, 2} } }) { // 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 }, + .childGap = CLAY_NCURSES_CELL_WIDTH * 2, + .childAlignment = { .y = CLAY_ALIGN_Y_TOP }, .layoutDirection = CLAY_LEFT_TO_RIGHT } }) { // Avatar CLAY(CLAY_IDI("Avatar", index), { - .layout = { .sizing = { CLAY_SIZING_FIXED(32), CLAY_SIZING_FIXED(16) } }, // 2x1 cells approx + .layout = { .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 4), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, .backgroundColor = { (index * 50) % 255, (index * 80) % 255, (index * 30) % 255, 255 }, - .cornerRadius = {8} + .cornerRadius = {1} }) {} // Name & Title CLAY(CLAY_IDI("AuthorInfo", index), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 4 } + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 0 } }) { Clay_String name = { .length = strlen(NAMES[index % 8]), .chars = NAMES[index % 8] }; Clay_String title = { .length = strlen(TITLES[index % 8]), .chars = TITLES[index % 8] }; @@ -138,7 +262,7 @@ void RenderPost(int index) { // Post Body CLAY(CLAY_IDI("PostBody", index), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = 8, .bottom = 8 } } + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = CLAY_NCURSES_CELL_HEIGHT, .bottom = CLAY_NCURSES_CELL_HEIGHT } } }) { Clay_String lorem = { .length = strlen(LOREM[index % 5]), .chars = LOREM[index % 5] }; CLAY_TEXT(lorem, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); @@ -146,7 +270,7 @@ void RenderPost(int index) { // Post Actions CLAY(CLAY_IDI("PostActions", index), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = 16, .layoutDirection = CLAY_LEFT_TO_RIGHT } + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_LEFT_TO_RIGHT } }) { CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 0, 255} })); // Bright Green CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 100, 255, 255} })); // Bright Blue @@ -159,20 +283,20 @@ void RenderContent() { CLAY(CLAY_ID("ContentArea"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, - .padding = CLAY_PADDING_ALL(16), - .childGap = 16, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_TOP_TO_BOTTOM }, - .backgroundColor = {20, 20, 20, 255} // Uniform BG + .backgroundColor = BLACK_BG_COLOR }) { // Sticky Header CLAY(CLAY_ID("Header"), { .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(48) }, // 3 cells high - .padding = { .left = 16, .right=16 }, + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, // 3 cells high + .padding = { .left = CLAY_NCURSES_CELL_WIDTH * 2, .right=CLAY_NCURSES_CELL_WIDTH * 2 }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, - .backgroundColor = {20, 20, 20, 255}, // Uniform BG + .backgroundColor = BLACK_BG_COLOR, .border = { .color = {0, 100, 255, 255}, .width = { .bottom = 1 } } }) { CLAY_TEXT(CLAY_STRING("Clay Social Feed"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); @@ -186,7 +310,7 @@ void RenderContent() { .padding = { .top = 8, .bottom = 8 } }, .clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() }, - .backgroundColor = {20, 20, 20, 255} // Uniform BG + .backgroundColor = BLACK_BG_COLOR }) { CLAY(CLAY_ID("FeedList"), { .layout = { @@ -213,10 +337,10 @@ void RenderContent() { void RenderMainLayout() { CLAY(CLAY_ID("Root"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .layoutDirection = CLAY_LEFT_TO_RIGHT }, - .backgroundColor = {20, 20, 20, 255} // Uniform BG }) { RenderSidebar(); RenderContent(); + RenderHelpModal(); } } @@ -257,7 +381,7 @@ int main() { Clay_Ncurses_Render(commands); - struct timespec ts = { .tv_sec = 0, .tv_nsec = 32000 * 1000 }; + struct timespec ts = { .tv_sec = 0, .tv_nsec = 16000 * 1000 }; nanosleep(&ts, NULL); } diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 6ac4807..03e7501 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -41,13 +41,6 @@ static struct { } _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 // ------------------------------------------------------------------------------------------------- @@ -55,7 +48,6 @@ static int _colorPairCacheSize = 0; 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); -static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, int *outY, int *outW, int *outH); static void Clay_Ncurses_InitLocale(void); static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text); static void Clay_Ncurses_RenderText(Clay_StringSlice text, int x, int y, int renderWidth); @@ -77,21 +69,18 @@ void Clay_Ncurses_Initialize() { Clay_Ncurses_InitLocale(); initscr(); - cbreak(); // Line buffering disabled - noecho(); // Don't echo input - keypad(stdscr, TRUE); // Enable arrow keys - curs_set(0); // Hide cursor + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(0); - // 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; @@ -103,6 +92,12 @@ void Clay_Ncurses_Terminate() { clear(); refresh(); endwin(); + + SCREEN *s = set_term(NULL); + if (s) { + delscreen(s); + } + _clayNcursesInitialized = false; } } @@ -117,7 +112,7 @@ Clay_Dimensions Clay_Ncurses_GetLayoutDimensions() { Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) { (void)config; (void)userData; - // Measure string width using wcwidth + int width = Clay_Ncurses_MeasureStringWidth(text); return (Clay_Dimensions) { .width = (float)width * CLAY_NCURSES_CELL_WIDTH, @@ -128,9 +123,6 @@ Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElement 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) { @@ -138,7 +130,6 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { _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; @@ -148,75 +139,51 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { 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 + short bg = fg; int pair = Clay_Ncurses_GetColorPair(fg, bg); - attron(COLOR_PAIR(pair)); + chtype targetChar = ' ' | COLOR_PAIR(pair); for (int row = dy; row < dy + dh; row++) { for (int col = dx; col < dx + dw; col++) { - mvaddch(row, col, ' '); + // Robust dirty check: Mask out attributes like A_BOLD which we don't control but might be set by terminal defaults + chtype current = mvinch(row, col); + if ((current & (A_CHARTEXT | A_COLOR)) != (targetChar & (A_CHARTEXT | A_COLOR))) { + mvaddch(row, col, targetChar); + } } } - 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 textWidth = Clay_Ncurses_MeasureStringWidth(text); int dx, dy, dw, dh; if (!Clay_Ncurses_IntersectScissor(x, y, textWidth, 1, &dx, &dy, &dw, &dh)) continue; - // Color (bg = -1 for transparent/default) short fg = Clay_Ncurses_GetColorId(command->renderData.text.textColor); - - // Inherit background from screen short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); - + int pair = Clay_Ncurses_GetColorPair(fg, bg); attron(COLOR_PAIR(pair)); - // Helper to handle wide char conversion and clipping - // We pass the screen coords and expected render width - // The helper will handle converting to wchar and printing the slice - // But wait, our generic helper accepts 'x' (start) and we need to skip? - // For simplicity, let's inline or call a robust helper that takes scissor into account. - // Since 'dw' is the width we *can* draw... - - // We need to skip 'dx - x' columns of the string. - // This is hard with variable width chars. - // Simpler approach: Convert entire string to wchar_t, then skip/take based on wcwidth. - int skipCols = dx - x; int takeCols = dw; - // Temp buffer for wide string - // Assuming reasonable max length or malloc int maxLen = text.length + 1; wchar_t *wbuf = (wchar_t *)malloc(maxLen * sizeof(wchar_t)); if (wbuf) { - // Convert UTF-8 text to wchar - // We need a null-terminated string for mbstowcs usually, - // or use mbsnrtowcs. - // Clay text is not null term. char *tempC = (char *)malloc(text.length + 1); memcpy(tempC, text.chars, text.length); tempC[text.length] = '\0'; @@ -225,15 +192,13 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { free(tempC); if (wlen != -1) { - // Now we have wide chars. We need to find the substring that fits [skipCols ... skipCols+takeCols] int currentCols = 0; int startIdx = 0; int endIdx = 0; - // Find start for (int k = 0; k < wlen; k++) { int cw = wcwidth(wbuf[k]); - if (cw < 0) cw = 0; // Unprintable? + if (cw < 0) cw = 0; if (currentCols >= skipCols) { startIdx = k; @@ -243,12 +208,7 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { startIdx = k + 1; } - // Find end - currentCols = 0; // Relative to skipped part? - // Re-scan? No, continue? - // Better: track cumulative width. - - // Restart logic: + currentCols = 0; int col = 0; int printStart = -1; int printLen = 0; @@ -257,13 +217,11 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { int cw = wcwidth(wbuf[k]); if (cw < 0) cw = 0; - // If this char starts within the window if (col >= skipCols && col < skipCols + takeCols) { if (printStart == -1) printStart = k; printLen++; } else if (col < skipCols && col + cw > skipCols) { // Overlap start boundary (e.g. half of a wide char?) - // ncurses handles this usually? Or we skip it. } col += cw; @@ -271,7 +229,43 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { } if (printStart != -1) { - mvaddnwstr(dy, dx, wbuf + printStart, printLen); + cchar_t *screenChars = (cchar_t *)malloc((printLen + 8) * sizeof(cchar_t)); + if (screenChars) { + int readCount = mvin_wchnstr(dy, dx, screenChars, printLen); + if (readCount == ERR) readCount = 0; + + bool dirty = false; + if (readCount < printLen) dirty = true; + else { + for (int i = 0; i < printLen; i++) { + wchar_t wch_screen[10] = {0}; + attr_t attrs; + short color_pair; + if (getcchar(&screenChars[i], wch_screen, &attrs, &color_pair, NULL) == ERR) { + dirty = true; + break; + } + + if (wch_screen[0] != wbuf[printStart + i]) { + dirty = true; + break; + } + + if ((int)color_pair != pair) { + dirty = true; + break; + } + } + } + free(screenChars); + + if (dirty) { + mvaddnwstr(dy, dx, wbuf + printStart, printLen); + } + } else { + // Fallback if malloc fails + mvaddnwstr(dy, dx, wbuf + printStart, printLen); + } } } free(wbuf); @@ -286,54 +280,81 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { 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); - - // Inherit background from the corner of the border (assume uniform) + short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); int pair = Clay_Ncurses_GetColorPair(color, bg); - + 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. + cchar_t wc; + wchar_t wstr[2]; + // 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); + mbstowcs(wstr, "─", 2); + for (int i = lx; i < rx; i++) { + mvin_wch(y, i, &wc); + if (wc.chars[0] != wstr[0]) mvprintw(y, i, "─"); // Only print if different + } } // 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); + mbstowcs(wstr, "─", 2); + for (int i = lx; i < rx; i++) { + mvin_wch(y + h - 1, i, &wc); + if (wc.chars[0] != wstr[0]) mvprintw(y + h - 1, i, "─"); + } } // 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); + mbstowcs(wstr, "│", 2); + for (int i = ty; i < by; i++) { + mvin_wch(i, x, &wc); + if (wc.chars[0] != wstr[0]) mvprintw(i, x, "│"); + } } // 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); + mbstowcs(wstr, "│", 2); + for (int i = ty; i < by; i++) { + mvin_wch(i, x + w - 1, &wc); + if (wc.chars[0] != wstr[0]) mvprintw(i, x + w - 1, "│"); + } + } + + // Corners + if (x >= dx && x < dx + dw && y >= dy && y < dy + dh) { + if (command->renderData.border.cornerRadius.topLeft > 0) mvprintw(y, x, "╭"); + else mvprintw(y, x, "┌"); + } + if (x + w - 1 >= dx && x + w - 1 < dx + dw && y >= dy && y < dy + dh) { + if (command->renderData.border.cornerRadius.topRight > 0) mvprintw(y, x + w - 1, "╮"); + else mvprintw(y, x + w - 1, "┐"); + } + if (x >= dx && x < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) { + if (command->renderData.border.cornerRadius.bottomLeft > 0) mvprintw(y + h - 1, x, "╰"); + else mvprintw(y + h - 1, x, "└"); + } + if (x + w - 1 >= dx && x + w - 1 < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) { + if (command->renderData.border.cornerRadius.bottomRight > 0) mvprintw(y + h - 1, x + w - 1, "╯"); // + else mvprintw(y + h - 1, x + w - 1, "┘"); } - // 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; @@ -437,7 +458,6 @@ static short Clay_Ncurses_MatchColor(Clay_Color color) { // 3. Grayscale (232-255) // If r~=g~=b, check if grayscale provides better match? // Often cube is fine. Grayscale ramp adds fine detail for darks. - // For now, cube is sufficient for general UI. return (short)cubeIndex; } From 97c1a797c4df0cd02942df1fbb7bce47d64037d2 Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 18:39:25 +0100 Subject: [PATCH 4/9] [renderers/ncurses] Refactor Ncurses renderer and example with atomic functions and Doxygen docs Major refactor of the Ncurses renderer and example application to improve code readability, maintainability, and documentation coverage. **Ncurses Renderer (`renderers/ncurses/clay_renderer_ncurses.c`):** * **Atomic Rendering Functions**: Decomposed the monolithic `Clay_Ncurses_Render` function into specialized handlers: * `Clay_Ncurses_RenderRectangle` * `Clay_Ncurses_RenderText` * `Clay_Ncurses_RenderBorder` * **Scissor Management**: Encapsulated scissor stack operations into `Clay_Ncurses_PushScissor` and `Clay_Ncurses_PopScissor`. * **Visibility Logic**: Extracted visibility/clipping checks into `Clay_Ncurses_GetVisibleRect` for cleaner reuse. * **Documentation**: Added comprehensive Doxygen documentation for file headers, internal state, constants, and all functions. **Ncurses Example (`examples/ncurses-example/main.c`):** * **Architecture Refactor**: Implementation split into clear "App State", "Input Processing", and "UI Components" sections. * **UI Componentization**: Renamed and organized UI functions with a consistent `UI_` prefix (e.g., `UI_Sidebar`, `UI_FeedPost`, `UI_HelpModal`). * **State Management**: Introduced `AppState` struct to centralize application state (sidebar visibility, scroll delta, etc.). * **Input Handling**: Centralized input logic in `App_ProcessInput` loop. * **Documentation**: Added full Doxygen documentation for the example source. This refactor maintains identical runtime behavior while significantly improving the codebase's "spoken english" readability and modularity. loop. * **Documentation**: Added full Doxygen documentation for the example source. This refactor maintains identical runtime behavior while significantly improving the codebase's readability and modularity. --- examples/ncurses-example/main.c | 625 ++++++++------- renderers/ncurses/clay_renderer_ncurses.c | 907 ++++++++++++---------- 2 files changed, 849 insertions(+), 683 deletions(-) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index a0e4faa..b7b7550 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -1,67 +1,150 @@ +/** + * @file main.c + * @author Seintian + * @date 2025-12-28 + * @brief Ncurses Example Application for Clay. + * + * Demonstrates how to use Clay with the Ncurses renderer to create a terminal-based UI. + * Features include: + * - A responsive layout with a sidebar and main content area. + * - Scrollable content (feed). + * - "Floating" modal windows (Help). + * - Keyboard user input handling. + * - Custom widgets (ProgressBar). + */ + #define CLAY_IMPLEMENTATION #include "../../clay.h" #include "../../renderers/ncurses/clay_renderer_ncurses.c" #include // for nanosleep -#define DEFAULT_SCROLL_DELTA 3.0f -#define BLACK_BG_COLOR {20, 20, 20, 255} +// ------------------------------------------------------------------------------------------------- +// -- Constants & Configuration +// ------------------------------------------------------------------------------------------------- -// State for the example +/** @brief Scroll speed per key press. */ +#define DEFAULT_SCROLL_SENSITIVITY 3.0f + +/** @brief Accent color: Green. */ +#define COLOR_ACCENT_GREEN (Clay_Color){0, 200, 0, 255} + +/** @brief Accent color: Orange. */ +#define COLOR_ACCENT_ORANGE (Clay_Color){200, 150, 0, 255} + +/** @brief Accent color: Red. */ +#define COLOR_ACCENT_RED (Clay_Color){255, 100, 100, 255} + +/** @brief Accent color: Blue. */ +#define COLOR_ACCENT_BLUE (Clay_Color){100, 100, 255, 255} + +/** @brief Standard text color: White. */ +#define COLOR_TEXT_WHITE (Clay_Color){255, 255, 255, 255} + +/** @brief Dimmed text color: Grey. */ +#define COLOR_TEXT_DIM (Clay_Color){150, 150, 150, 255} + +/** @brief Background color for panels. */ +#define COLOR_PANEL_BG (Clay_Color){20, 20, 20, 255} + +/** @brief Border color for panels. */ +#define COLOR_PANEL_BORDER (Clay_Color){100, 100, 100, 255} + +// ------------------------------------------------------------------------------------------------- +// -- Application State +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Global application state. + * Stores all mutable state required for the UI logic. + */ typedef struct { - bool sidebarOpen; - float scrollDelta; - bool showHelp; - bool shouldQuit; + bool isSidebarVisible; /**< Toggles the visibility of the sidebar. */ + bool isHelpModalVisible; /**< Toggles the help overlay. */ + bool isQuitting; /**< Flag to exit the main loop. */ + float scrollDelta; /**< Accumulated scroll amount for the current frame. */ } AppState; -static AppState appState = { - .sidebarOpen = true, - .scrollDelta = 0.0f, - .shouldQuit = false, - .showHelp = false +/** @brief Static instance of application state. */ +static AppState _appState = { + .isSidebarVisible = true, + .isHelpModalVisible = false, + .isQuitting = false, + .scrollDelta = 0.0f }; -void HandleInput() { - // Reset delta per frame - appState.scrollDelta = 0.0f; +// ------------------------------------------------------------------------------------------------- +// -- Input Processing +// ------------------------------------------------------------------------------------------------- - 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 == 'h' || ch == 'H') { - appState.showHelp = !appState.showHelp; - } - if (ch == KEY_UP) { - appState.scrollDelta += DEFAULT_SCROLL_DELTA; - } - if (ch == KEY_DOWN) { - appState.scrollDelta -= DEFAULT_SCROLL_DELTA; +/** + * @brief Processes keyboard input for the current frame. + * Updates _appState directly based on key presses. + * Uses ncurses getch() (non-blocking if timeout is set). + */ +void App_ProcessInput() { + _appState.scrollDelta = 0.0f; + + int key; + while ((key = getch()) != ERR) { + switch (key) { + case 'q': + case 'Q': + _appState.isQuitting = true; + break; + case 's': + case 'S': + _appState.isSidebarVisible = !_appState.isSidebarVisible; + break; + case 'h': + case 'H': + _appState.isHelpModalVisible = !_appState.isHelpModalVisible; + break; + case KEY_UP: + _appState.scrollDelta += DEFAULT_SCROLL_SENSITIVITY; + break; + case KEY_DOWN: + _appState.scrollDelta -= DEFAULT_SCROLL_SENSITIVITY; + break; } } } -void RenderProgressBar(Clay_String label, float percent, Clay_Color color) { - CLAY(CLAY_ID_LOCAL("ProgressBarWrapper"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = CLAY_NCURSES_CELL_HEIGHT } +// ------------------------------------------------------------------------------------------------- +// -- UI Components +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Renders a progress bar widget. + * @param label The text label displayed above the bar. + * @param percentage The fill percentage (0.0 to 1.0). + * @param color The color of the filled portion. + */ +void UI_ProgressBar(Clay_String label, float percentage, Clay_Color color) { + CLAY(CLAY_ID_LOCAL("ProgressBar"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + .childGap = CLAY_NCURSES_CELL_HEIGHT + } }) { - CLAY(CLAY_ID_LOCAL("LabelRow"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_LEFT_TO_RIGHT, .childGap = CLAY_NCURSES_CELL_HEIGHT, .childAlignment = {.y = CLAY_ALIGN_Y_CENTER} } + CLAY(CLAY_ID_LOCAL("Label"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childGap = CLAY_NCURSES_CELL_HEIGHT, + .childAlignment = {.y = CLAY_ALIGN_Y_CENTER} + } }) { CLAY_TEXT(label, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255}, .fontSize = 16 })); } - CLAY(CLAY_ID_LOCAL("BarBackground"), { + CLAY(CLAY_ID_LOCAL("Track"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT) } }, .backgroundColor = {40, 40, 40, 255}, .cornerRadius = {1} }) { - CLAY(CLAY_ID_LOCAL("BarFill"), { - .layout = { .sizing = { CLAY_SIZING_PERCENT(percent), CLAY_SIZING_GROW() } }, + CLAY(CLAY_ID_LOCAL("Fill"), { + .layout = { .sizing = { CLAY_SIZING_PERCENT(percentage), CLAY_SIZING_GROW() } }, .backgroundColor = color, .cornerRadius = {1} }) {} @@ -69,8 +152,11 @@ void RenderProgressBar(Clay_String label, float percent, Clay_Color color) { } } -void RenderServerStatus() { - CLAY(CLAY_ID("ServerStatus"), { +/** + * @brief Renders the Server Status widget containing CPU and Memory usage bars. + */ +void UI_ServerStatusWidget() { + CLAY(CLAY_ID("ServerStatusWidget"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = {16, 16, 16, 16}, @@ -80,14 +166,213 @@ void RenderServerStatus() { .backgroundColor = {25, 25, 25, 255}, .border = { .color = {60, 60, 60, 255}, .width = {2, 2, 2, 2} } }) { - CLAY_TEXT(CLAY_STRING("SERVER STATUS"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); - RenderProgressBar(CLAY_STRING("CPU"), 0.45f, (Clay_Color){0, 200, 0, 255}); - RenderProgressBar(CLAY_STRING("Mem"), 0.82f, (Clay_Color){200, 150, 0, 255}); + CLAY_TEXT(CLAY_STRING("SERVER STATUS"), CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_WHITE })); + UI_ProgressBar(CLAY_STRING("CPU"), 0.45f, COLOR_ACCENT_GREEN); + UI_ProgressBar(CLAY_STRING("Mem"), 0.82f, COLOR_ACCENT_ORANGE); } } -void RenderHelpModal() { - if (!appState.showHelp) return; +/** + * @brief Renders a single item used in the sidebar. + * @param label Text to display. + * @param textColor Color of the text. + */ +void UI_SidebarItem(Clay_String label, Clay_Color textColor) { + CLAY(CLAY_ID_LOCAL("SidebarItem"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, + .backgroundColor = COLOR_PANEL_BG + }) { + CLAY_TEXT(label, CLAY_TEXT_CONFIG({ .textColor = textColor })); + } +} + +/** + * @brief Renders the application Sidebar. + * conditionally rendered based on _appState.isSidebarVisible. + */ +void UI_Sidebar() { + if (!_appState.isSidebarVisible) return; + + CLAY(CLAY_ID("Sidebar"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 30), CLAY_SIZING_GROW() }, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = COLOR_PANEL_BG, + .border = { .color = COLOR_PANEL_BORDER, .width = { .right = 2 } } + }) { + CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 0, 255} })); + + UI_ServerStatusWidget(); + + UI_SidebarItem(CLAY_STRING(" > Item 1 🌍"), (Clay_Color){0, 255, 255, 255}); + UI_SidebarItem(CLAY_STRING(" > Item 2 🌐"), COLOR_TEXT_WHITE); + + // Mixed Style Items + CLAY(CLAY_ID("SidebarItemMixed1"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, + .backgroundColor = COLOR_PANEL_BG, + .cornerRadius = { .topLeft = 8 }, + .border = { .color = COLOR_ACCENT_RED, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_RED })); + } + + CLAY(CLAY_ID("SidebarItemMixed2"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, + .backgroundColor = COLOR_PANEL_BG, + .cornerRadius = { .topLeft = 8, .bottomRight = 8 }, + .border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} })); + } + + CLAY(CLAY_ID("SidebarItemMixed3"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, + .backgroundColor = COLOR_PANEL_BG, + .cornerRadius = { .topLeft = 8, .topRight = 8 }, + .border = { .color = COLOR_ACCENT_BLUE, .width = {2, 2, 2, 2} } + }) { + CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_BLUE })); + } + } +} + +// Data for "Realistic" Content + +/** @brief Sample names for feed posts. */ +const char* NAMES[] = { "Alice", "Bob", "Charlie", "Diana", "Ethan", "Fiona", "George", "Hannah" }; + +/** @brief Sample titles for feed posts. */ +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?" }; + +/** @brief Sample body text for feed posts. */ +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." }; + +/** + * @brief Renders a single social media feed post. + * @param index The index of the post (used to generate deterministic content from sample data). + */ +void UI_FeedPost(int index) { + CLAY(CLAY_IDI("FeedPost", index), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = COLOR_PANEL_BG, + .cornerRadius = {1}, + .border = { .color = {80, 80, 80, 255}, .width = {2, 2, 2, 2} } + }) { + // Header: Avatar + Name + Time + CLAY(CLAY_IDI("PostHeader", index), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .childGap = CLAY_NCURSES_CELL_WIDTH * 2, + .childAlignment = { .y = CLAY_ALIGN_Y_TOP }, + .layoutDirection = CLAY_LEFT_TO_RIGHT + } + }) { + CLAY(CLAY_IDI("Avatar", index), { + .layout = { .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 4), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, + .backgroundColor = { (index * 50) % 255, (index * 80) % 255, (index * 30) % 255, 255 }, + .cornerRadius = {1} + }) {} + + CLAY(CLAY_IDI("AuthorInfo", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 0 } + }) { + 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 = COLOR_TEXT_WHITE })); + CLAY_TEXT(title, CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_DIM })); + } + } + + // Body + CLAY(CLAY_IDI("PostBody", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = CLAY_NCURSES_CELL_HEIGHT, .bottom = CLAY_NCURSES_CELL_HEIGHT } } + }) { + Clay_String lorem = { .length = strlen(LOREM[index % 5]), .chars = LOREM[index % 5] }; + CLAY_TEXT(lorem, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} })); + } + + // Actions + CLAY(CLAY_IDI("PostActions", index), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_LEFT_TO_RIGHT } + }) { + CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 0, 255} })); + CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 100, 255, 255} })); + CLAY_TEXT(CLAY_STRING("[ Share ]"), CLAY_TEXT_CONFIG({ .textColor = {255, 0, 0, 255} })); + } + } +} + +/** + * @brief Renders the main content area with the scrollable feed. + */ +void UI_MainContent() { + CLAY(CLAY_ID("ContentArea"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, + .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), + .childGap = CLAY_NCURSES_CELL_HEIGHT, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = COLOR_PANEL_BG + }) { + // Sticky Header + CLAY(CLAY_ID("StickyHeader"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, + .padding = { .left = CLAY_NCURSES_CELL_WIDTH * 2, .right=CLAY_NCURSES_CELL_WIDTH * 2 }, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = COLOR_PANEL_BG, + .border = { .color = {0, 100, 255, 255}, .width = { .bottom = 1 } } + }) { + CLAY_TEXT(CLAY_STRING("Clay Social Feed"), CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_WHITE })); + } + + // Scrollable Viewport + CLAY(CLAY_ID("Viewport"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, + .padding = { .top = 8, .bottom = 8 } + }, + .clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() }, + .backgroundColor = COLOR_PANEL_BG + }) { + CLAY(CLAY_ID("FeedList"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, + .childGap = 16, + .layoutDirection = CLAY_TOP_TO_BOTTOM + } + }) { + // Determine if we need to initialize scroll position + Clay_ElementData item0 = Clay_GetElementData(CLAY_IDI("FeedPost", 0)); + + for (int i = 0; i < 50; ++i) { + UI_FeedPost(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} })); + } +} + +/** + * @brief Renders the Help modal overlay. + */ +void UI_HelpModal() { + if (!_appState.isHelpModalVisible) return; CLAY(CLAY_ID("HelpModalOverlay"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .childAlignment = {CLAY_ALIGN_X_CENTER, CLAY_ALIGN_Y_CENTER} }, @@ -103,9 +388,9 @@ void RenderHelpModal() { }, .backgroundColor = {30, 30, 30, 255}, .cornerRadius = {4}, - .border = { .color = {255, 255, 255, 255}, .width = {2, 2, 2, 2} } + .border = { .color = COLOR_TEXT_WHITE, .width = {2, 2, 2, 2} } }) { - CLAY_TEXT(CLAY_STRING("Ncurses Example Help"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); + CLAY_TEXT(CLAY_STRING("Ncurses Example Help"), CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_WHITE })); CLAY(CLAY_ID("HelpLine1"), { .layout = { .sizing = {CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0)} } }) { CLAY_TEXT(CLAY_STRING("Keys:"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 0, 255} })); @@ -122,241 +407,42 @@ void RenderHelpModal() { } } -void RenderSidebar() { - if (!appState.sidebarOpen) return; - - CLAY(CLAY_ID("Sidebar"), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 30), CLAY_SIZING_GROW() }, - .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), - .childGap = CLAY_NCURSES_CELL_HEIGHT, - .layoutDirection = CLAY_TOP_TO_BOTTOM - }, - .backgroundColor = {20, 20, 20, 255}, - .border = { .color = {100, 100, 100, 255}, .width = { .right = 2 } } // Lighter Grey Border - }) { - CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({ - .textColor = {255, 255, 0, 255} // Bright Yellow - })); - - RenderServerStatus(); - - CLAY(CLAY_ID("SidebarItem1"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, - .backgroundColor = BLACK_BG_COLOR - }) { - CLAY_TEXT(CLAY_STRING(" > Item 1 🌍"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} })); - } - - CLAY(CLAY_ID("SidebarItem2"), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, - .backgroundColor = BLACK_BG_COLOR - }) { - CLAY_TEXT(CLAY_STRING(" > Item 2 🌐"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); - } - CLAY(CLAY_ID("SidebarItemMixed1"), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = {20, 20, 20, 255}, - .cornerRadius = { .topLeft = 8 }, - .border = { .color = {255, 100, 100, 255}, .width = {2, 2, 2, 2} } - }) { - CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = {255, 100, 100, 255} })); - } - - CLAY(CLAY_ID("SidebarItemMixed2"), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = {20, 20, 20, 255}, - .cornerRadius = { .topLeft = 8, .bottomRight = 8 }, - .border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} } - }) { - CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} })); - } - - CLAY(CLAY_ID("SidebarItemMixed3"), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = {20, 20, 20, 255}, - .cornerRadius = { .topLeft = 8, .topRight = 8 }, - .border = { .color = {100, 100, 255, 255}, .width = {2, 2, 2, 2} } - }) { - CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = {100, 100, 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(CLAY_NCURSES_CELL_HEIGHT), - .childGap = CLAY_NCURSES_CELL_HEIGHT, - .layoutDirection = CLAY_TOP_TO_BOTTOM - }, - .backgroundColor = BLACK_BG_COLOR, - .cornerRadius = {1}, - .border = { .color = {80, 80, 80, 255}, .width = {2, 2, 2, 2} } - }) { - // Post Header: Avatar + Name + Time - CLAY(CLAY_IDI("PostHeader", index), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, - .childGap = CLAY_NCURSES_CELL_WIDTH * 2, - .childAlignment = { .y = CLAY_ALIGN_Y_TOP }, - .layoutDirection = CLAY_LEFT_TO_RIGHT - } - }) { - // Avatar - CLAY(CLAY_IDI("Avatar", index), { - .layout = { .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 4), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, - .backgroundColor = { (index * 50) % 255, (index * 80) % 255, (index * 30) % 255, 255 }, - .cornerRadius = {1} - }) {} - - // Name & Title - CLAY(CLAY_IDI("AuthorInfo", index), { - .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 0 } - }) { - 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 = CLAY_NCURSES_CELL_HEIGHT, .bottom = CLAY_NCURSES_CELL_HEIGHT } } - }) { - 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 = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_LEFT_TO_RIGHT } - }) { - CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 0, 255} })); // Bright Green - CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 100, 255, 255} })); // Bright Blue - CLAY_TEXT(CLAY_STRING("[ Share ]"), CLAY_TEXT_CONFIG({ .textColor = {255, 0, 0, 255} })); // Bright Red - } - } -} - -void RenderContent() { - CLAY(CLAY_ID("ContentArea"), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, - .padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT), - .childGap = CLAY_NCURSES_CELL_HEIGHT, - .layoutDirection = CLAY_TOP_TO_BOTTOM - }, - .backgroundColor = BLACK_BG_COLOR - }) { - // Sticky Header - CLAY(CLAY_ID("Header"), { - .layout = { - .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, // 3 cells high - .padding = { .left = CLAY_NCURSES_CELL_WIDTH * 2, .right=CLAY_NCURSES_CELL_WIDTH * 2 }, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = BLACK_BG_COLOR, - .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 = BLACK_BG_COLOR - }) { - 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() { +/** + * @brief Renders the root layout of the application. + */ +void UI_RootLayout() { CLAY(CLAY_ID("Root"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .layoutDirection = CLAY_LEFT_TO_RIGHT }, }) { - RenderSidebar(); - RenderContent(); - RenderHelpModal(); + UI_Sidebar(); + UI_MainContent(); + UI_HelpModal(); } } +// ------------------------------------------------------------------------------------------------- +// -- Main Loop +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Application Entry Point. + * Initializes Memory, Clay, Ncurses, and runs the main event loop. + */ 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); + + // Initialize Ncurses Renderer Clay_Ncurses_Initialize(); - // Non-blocking input + // Set non-blocking input for game loop timeout(0); - while(!appState.shouldQuit) { - HandleInput(); + while(!_appState.isQuitting) { + App_ProcessInput(); Clay_Dimensions dims = Clay_Ncurses_GetLayoutDimensions(); Clay_SetLayoutDimensions(dims); @@ -372,15 +458,16 @@ int main() { }; Clay_SetPointerState(center, false); - Clay_UpdateScrollContainers(true, (Clay_Vector2){0, appState.scrollDelta}, 0.016f); + Clay_UpdateScrollContainers(true, (Clay_Vector2){0, _appState.scrollDelta}, 0.016f); } Clay_BeginLayout(); - RenderMainLayout(); + UI_RootLayout(); Clay_RenderCommandArray commands = Clay_EndLayout(); Clay_Ncurses_Render(commands); + // 60 FPS Target (approx) struct timespec ts = { .tv_sec = 0, .tv_nsec = 16000 * 1000 }; nanosleep(&ts, NULL); } diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 03e7501..5a69ca8 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -1,3 +1,14 @@ +/** + * @file clay_renderer_ncurses.c + * @author Seintian + * @date 2025-12-28 + * @brief Ncurses renderer implementation for the Clay UI library. + * + * This file provides a backend for rendering Clay UI layouts using the Ncurses library. + * It handles terminal initialization, color management using standard ANSI or 256-color modes, + * text measurement (assuming monospace cells), and primitive rendering (rectangles, text, borders). + */ + #ifndef _XOPEN_SOURCE_EXTENDED #define _XOPEN_SOURCE_EXTENDED #endif @@ -16,383 +27,101 @@ #include "../../clay.h" // ------------------------------------------------------------------------------------------------- -// -- Internal State & Context +// -- Internal State & Constants // ------------------------------------------------------------------------------------------------- +/** + * @brief Assumed width of a single terminal cell in logical units. + * Used to convert Clay's floating point layout coordinates to integer terminal grid coordinates. + */ #define CLAY_NCURSES_CELL_WIDTH 8.0f + +/** + * @brief Assumed height of a single terminal cell in logical units. + * Used to convert Clay's floating point layout coordinates to integer terminal grid coordinates. + */ #define CLAY_NCURSES_CELL_HEIGHT 16.0f -static int _clayNcursesScreenWidth = 0; -static int _clayNcursesScreenHeight = 0; -static bool _clayNcursesInitialized = false; +/** @brief Current screen width in characters. */ +static int _screenWidth = 0; + +/** @brief Current screen height in characters. */ +static int _screenHeight = 0; + +/** @brief Flag indicating if the ncurses subsystem has been successfully initialized. */ +static bool _isInitialized = false; // Scissor / Clipping State + +/** @brief Maximum depth of the scissor/clipping stack. */ #define MAX_SCISSOR_STACK_DEPTH 16 + +/** @brief Stack of clipping rectangles current active. */ static Clay_BoundingBox _scissorStack[MAX_SCISSOR_STACK_DEPTH]; + +/** @brief Current index into the scissor stack. */ static int _scissorStackIndex = 0; // Color State -// We reserve pair 0. Pairs 1..max are dynamically allocated. + +/** @brief Maximum number of color pairs to cache. */ #define MAX_COLOR_PAIRS_CACHE 1024 + +/** + * @brief Cache entry for an Ncurses color pair. + * Mapes a generic foreground/background color combination to an Ncurses pair ID. + */ static struct { - short fg; - short bg; - int pairId; + short fg; /**< Foreground color index. */ + short bg; /**< Background color index. */ + int pairId; /**< Assigned Ncurses pair ID. */ } _colorPairCache[MAX_COLOR_PAIRS_CACHE]; + +/** @brief Current number of cached color pairs. */ static int _colorPairCacheSize = 0; // ------------------------------------------------------------------------------------------------- -// -- Forward Declarations +// -- Forward Declarations & Internal Helpers // ------------------------------------------------------------------------------------------------- +/** + * @brief Converts a Clay_Color to the nearest Ncurses color index. + * @param color The Clay RGBA color. + * @return The corresponding Ncurses color index (0-255). + */ static short Clay_Ncurses_GetColorId(Clay_Color color); + +/** + * @brief Retrieves or creates a color pair for the given foreground and background. + * @param fg Foreground color index. + * @param bg Background color index. + * @return The Ncurses pair ID representing this combination. + */ 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); -static void Clay_Ncurses_InitLocale(void); + +/** + * @brief Measures the visual width of a string in terminal cells. + * Handles multibyte characters (UTF-8) correctly. + * @param text The string slice to measure. + * @return The width in cells (columns). + */ static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text); -static void Clay_Ncurses_RenderText(Clay_StringSlice text, int x, int y, int renderWidth); -static short Clay_Ncurses_GetBackgroundAt(int x, int y) { - chtype ch = mvinch(y, x); - int pair = PAIR_NUMBER(ch); - short fg, bg; - pair_content(pair, &fg, &bg); - return bg; -} - -// ------------------------------------------------------------------------------------------------- -// -- Public API Implementation -// ------------------------------------------------------------------------------------------------- - -void Clay_Ncurses_Initialize() { - if (_clayNcursesInitialized) return; - - Clay_Ncurses_InitLocale(); - initscr(); - cbreak(); - noecho(); - keypad(stdscr, TRUE); - curs_set(0); - - mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); - - start_color(); - use_default_colors(); - - getmaxyx(stdscr, _clayNcursesScreenHeight, _clayNcursesScreenWidth); - - _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(); - - SCREEN *s = set_term(NULL); - if (s) { - delscreen(s); - } - - _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; - - int width = Clay_Ncurses_MeasureStringWidth(text); - return (Clay_Dimensions) { - .width = (float)width * CLAY_NCURSES_CELL_WIDTH, - .height = CLAY_NCURSES_CELL_HEIGHT - }; -} - -void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { - if (!_clayNcursesInitialized) return; - - int newW, newH; - getmaxyx(stdscr, newH, newW); - if (newW != _clayNcursesScreenWidth || newH != _clayNcursesScreenHeight) { - _clayNcursesScreenWidth = newW; - _clayNcursesScreenHeight = newH; - } - - _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: { - 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); - int dx, dy, dw, dh; - if (!Clay_Ncurses_IntersectScissor(x, y, w, h, &dx, &dy, &dw, &dh)) continue; - - short fg = Clay_Ncurses_GetColorId(command->renderData.rectangle.backgroundColor); - short bg = fg; - int pair = Clay_Ncurses_GetColorPair(fg, bg); - - chtype targetChar = ' ' | COLOR_PAIR(pair); - for (int row = dy; row < dy + dh; row++) { - for (int col = dx; col < dx + dw; col++) { - // Robust dirty check: Mask out attributes like A_BOLD which we don't control but might be set by terminal defaults - chtype current = mvinch(row, col); - if ((current & (A_CHARTEXT | A_COLOR)) != (targetChar & (A_CHARTEXT | A_COLOR))) { - mvaddch(row, col, targetChar); - } - } - } - break; - } - case CLAY_RENDER_COMMAND_TYPE_TEXT: { - int x = (int)(box.x / CLAY_NCURSES_CELL_WIDTH); - int y = (int)(box.y / CLAY_NCURSES_CELL_HEIGHT); - Clay_StringSlice text = command->renderData.text.stringContents; - int textWidth = Clay_Ncurses_MeasureStringWidth(text); - - int dx, dy, dw, dh; - if (!Clay_Ncurses_IntersectScissor(x, y, textWidth, 1, &dx, &dy, &dw, &dh)) continue; - - short fg = Clay_Ncurses_GetColorId(command->renderData.text.textColor); - short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); - - int pair = Clay_Ncurses_GetColorPair(fg, bg); - - attron(COLOR_PAIR(pair)); - - int skipCols = dx - x; - int takeCols = dw; - - int maxLen = text.length + 1; - wchar_t *wbuf = (wchar_t *)malloc(maxLen * sizeof(wchar_t)); - if (wbuf) { - char *tempC = (char *)malloc(text.length + 1); - memcpy(tempC, text.chars, text.length); - tempC[text.length] = '\0'; - - int wlen = mbstowcs(wbuf, tempC, maxLen); - free(tempC); - - if (wlen != -1) { - int currentCols = 0; - int startIdx = 0; - int endIdx = 0; - - for (int k = 0; k < wlen; k++) { - int cw = wcwidth(wbuf[k]); - if (cw < 0) cw = 0; - - if (currentCols >= skipCols) { - startIdx = k; - break; - } - currentCols += cw; - startIdx = k + 1; - } - - currentCols = 0; - int col = 0; - int printStart = -1; - int printLen = 0; - - for (int k = 0; k < wlen; k++) { - int cw = wcwidth(wbuf[k]); - if (cw < 0) cw = 0; - - if (col >= skipCols && col < skipCols + takeCols) { - if (printStart == -1) printStart = k; - printLen++; - } else if (col < skipCols && col + cw > skipCols) { - // Overlap start boundary (e.g. half of a wide char?) - } - - col += cw; - if (col >= skipCols + takeCols) break; - } - - if (printStart != -1) { - cchar_t *screenChars = (cchar_t *)malloc((printLen + 8) * sizeof(cchar_t)); - if (screenChars) { - int readCount = mvin_wchnstr(dy, dx, screenChars, printLen); - if (readCount == ERR) readCount = 0; - - bool dirty = false; - if (readCount < printLen) dirty = true; - else { - for (int i = 0; i < printLen; i++) { - wchar_t wch_screen[10] = {0}; - attr_t attrs; - short color_pair; - if (getcchar(&screenChars[i], wch_screen, &attrs, &color_pair, NULL) == ERR) { - dirty = true; - break; - } - - if (wch_screen[0] != wbuf[printStart + i]) { - dirty = true; - break; - } - - if ((int)color_pair != pair) { - dirty = true; - break; - } - } - } - free(screenChars); - - if (dirty) { - mvaddnwstr(dy, dx, wbuf + printStart, printLen); - } - } else { - // Fallback if malloc fails - mvaddnwstr(dy, dx, wbuf + printStart, printLen); - } - } - } - free(wbuf); - } - - 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); - - 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); - - short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); - int pair = Clay_Ncurses_GetColorPair(color, bg); - - attron(COLOR_PAIR(pair)); - - cchar_t wc; - wchar_t wstr[2]; - - // Top - if (y >= dy && y < 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); - mbstowcs(wstr, "─", 2); - for (int i = lx; i < rx; i++) { - mvin_wch(y, i, &wc); - if (wc.chars[0] != wstr[0]) mvprintw(y, i, "─"); // Only print if different - } - } - // 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); - mbstowcs(wstr, "─", 2); - for (int i = lx; i < rx; i++) { - mvin_wch(y + h - 1, i, &wc); - if (wc.chars[0] != wstr[0]) mvprintw(y + h - 1, i, "─"); - } - } - // 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); - mbstowcs(wstr, "│", 2); - for (int i = ty; i < by; i++) { - mvin_wch(i, x, &wc); - if (wc.chars[0] != wstr[0]) mvprintw(i, x, "│"); - } - } - // 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); - mbstowcs(wstr, "│", 2); - for (int i = ty; i < by; i++) { - mvin_wch(i, x + w - 1, &wc); - if (wc.chars[0] != wstr[0]) mvprintw(i, x + w - 1, "│"); - } - } - - // Corners - if (x >= dx && x < dx + dw && y >= dy && y < dy + dh) { - if (command->renderData.border.cornerRadius.topLeft > 0) mvprintw(y, x, "╭"); - else mvprintw(y, x, "┌"); - } - if (x + w - 1 >= dx && x + w - 1 < dx + dw && y >= dy && y < dy + dh) { - if (command->renderData.border.cornerRadius.topRight > 0) mvprintw(y, x + w - 1, "╮"); - else mvprintw(y, x + w - 1, "┐"); - } - if (x >= dx && x < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) { - if (command->renderData.border.cornerRadius.bottomLeft > 0) mvprintw(y + h - 1, x, "╰"); - else mvprintw(y + h - 1, x, "└"); - } - if (x + w - 1 >= dx && x + w - 1 < dx + dw && y + h - 1 >= dy && y + h - 1 < dy + dh) { - if (command->renderData.border.cornerRadius.bottomRight > 0) mvprintw(y + h - 1, x + w - 1, "╯"); // - else mvprintw(y + h - 1, x + w - 1, "┘"); - } - - 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) { +/** + * @brief Calculates the intersection of a requested rectangle with the current scissor clip. + * + * @param x Requested X position (cells). + * @param y Requested Y position (cells). + * @param w Requested Width (cells). + * @param h Requested Height (cells). + * @param[out] outX Resulting visible X position. + * @param[out] outY Resulting visible Y position. + * @param[out] outW Resulting visible Width. + * @param[out] outH Resulting visible Height. + * @return true If the rectangle is at least partially visible. + * @return false If the rectangle is completely clipped (invisible). + */ +static bool Clay_Ncurses_GetVisibleRect(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 @@ -419,47 +148,423 @@ static bool Clay_Ncurses_IntersectScissor(int x, int y, int w, int h, int *outX, return true; } -static short Clay_Ncurses_MatchColor(Clay_Color color) { - // If not 256 colors, fallback to 8 colors - if (COLORS < 256) { - int r = color.r > 128; - int g = color.g > 128; - int b = color.b > 128; +/** + * @brief Gets the background color index of the character currently at the specified coordinates. + * Used for transparent rendering over existing content. + * @param x Screen X coordinate. + * @param y Screen Y coordinate. + * @return The background color index. + */ +static short Clay_Ncurses_GetBackgroundAt(int x, int y) { + chtype ch = mvinch(y, x); + int pair = PAIR_NUMBER(ch); + short fg, bg; + pair_content(pair, &fg, &bg); + return bg; +} - 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; +/** + * @brief Initializes the system locale for UTF-8 support. + * Attempts to set LC_ALL to empty (system default), "C.UTF-8", or "en_US.UTF-8" in that order. + */ +static void Clay_Ncurses_InitLocale(void) { + char *locale = setlocale(LC_ALL, ""); + if (!locale || strcmp(locale, "C") == 0 || strcmp(locale, "POSIX") == 0) { + locale = setlocale(LC_ALL, "C.UTF-8"); + if (!locale) { + locale = setlocale(LC_ALL, "en_US.UTF-8"); + } + } +} + +// ------------------------------------------------------------------------------------------------- +// -- Atomic Render Functions +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Renders a solid color rectangle. + * @param command The render command containing the rectangle data (bounds, color). + */ +static void Clay_Ncurses_RenderRectangle(Clay_RenderCommand *command) { + Clay_BoundingBox box = command->boundingBox; + 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); + + int dx, dy, dw, dh; + if (!Clay_Ncurses_GetVisibleRect(x, y, w, h, &dx, &dy, &dw, &dh)) return; + + short fg = Clay_Ncurses_GetColorId(command->renderData.rectangle.backgroundColor); + short bg = fg; + int pair = Clay_Ncurses_GetColorPair(fg, bg); + + chtype targetChar = ' ' | COLOR_PAIR(pair); + + // Optimization: Don't redraw if character is already correct (reduces flicker/bandwidth) + for (int row = dy; row < dy + dh; row++) { + for (int col = dx; col < dx + dw; col++) { + chtype current = mvinch(row, col); + if ((current & (A_CHARTEXT | A_COLOR)) != (targetChar & (A_CHARTEXT | A_COLOR))) { + mvaddch(row, col, targetChar); + } + } + } +} + +/** + * @brief Renders a text string. + * Uses multibyte to wide char conversion for correct UTF-8 rendering. + * @param command The render command containing the text data. + */ +static void Clay_Ncurses_RenderText(Clay_RenderCommand *command) { + Clay_BoundingBox box = command->boundingBox; + int x = (int)(box.x / CLAY_NCURSES_CELL_WIDTH); + int y = (int)(box.y / CLAY_NCURSES_CELL_HEIGHT); + Clay_StringSlice text = command->renderData.text.stringContents; + + int textWidth = Clay_Ncurses_MeasureStringWidth(text); + + int dx, dy, dw, dh; + // Text height is always 1 cell + if (!Clay_Ncurses_GetVisibleRect(x, y, textWidth, 1, &dx, &dy, &dw, &dh)) return; + + short fg = Clay_Ncurses_GetColorId(command->renderData.text.textColor); + short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); + int pair = Clay_Ncurses_GetColorPair(fg, bg); + + attron(COLOR_PAIR(pair)); + + // Complex multibyte string handling + // We render to a temporary buffer first to handle wide characters + int maxLen = text.length + 1; + wchar_t *wbuf = (wchar_t *)malloc(maxLen * sizeof(wchar_t)); + if (!wbuf) { + attroff(COLOR_PAIR(pair)); + return; } - // 256 Color Match - // 1. Check standard ANSI (0-15) - simplified, usually handled by cube approximation anyway but kept for specific fidelity if needed. + char *tempC = (char *)malloc(text.length + 1); + memcpy(tempC, text.chars, text.length); + tempC[text.length] = '\0'; - // 2. 6x6x6 Color Cube (16 - 231) - // Formula: 16 + (36 * r) + (6 * g) + b - // where r,g,b are 0-5 + int wlen = mbstowcs(wbuf, tempC, maxLen); + free(tempC); - int r = (int)((color.r / 255.0f) * 5.0f); - int g = (int)((color.g / 255.0f) * 5.0f); - int b = (int)((color.b / 255.0f) * 5.0f); + if (wlen != -1) { + int skipCols = dx - x; + int takeCols = dw; + int currentCols = 0; + int printStart = -1; + int printLen = 0; - // We can compute distance but mapping to the 0-5 grid is usually "good enough" for TUI - // For better fidelity we actually map 0-255 to the specific values [0, 95, 135, 175, 215, 255] used in xterm - // But simple linear 0-5 bucket is standard shortcut. + // Find start index based on columns skipped + for (int k = 0; k < wlen; k++) { + int cw = wcwidth(wbuf[k]); + if (cw < 0) cw = 0; - // Let's use simple bucket for now. - int cubeIndex = 16 + (36 * r) + (6 * g) + b; + if (currentCols >= skipCols && currentCols < skipCols + takeCols) { + if (printStart == -1) printStart = k; + printLen++; + } + currentCols += cw; + if (currentCols >= skipCols + takeCols) break; + } - // 3. Grayscale (232-255) - // If r~=g~=b, check if grayscale provides better match? - // Often cube is fine. Grayscale ramp adds fine detail for darks. + if (printStart != -1) { + mvaddnwstr(dy, dx, wbuf + printStart, printLen); + } + } - return (short)cubeIndex; + free(wbuf); + attroff(COLOR_PAIR(pair)); +} + +/** + * @brief Renders a border around a rectangle. + * Supports rounded corners using ACS_ CORNER characters. + * @param command The render command containing the border data. + */ +static void Clay_Ncurses_RenderBorder(Clay_RenderCommand *command) { + Clay_BoundingBox box = command->boundingBox; + 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); + + int dx, dy, dw, dh; + if (!Clay_Ncurses_GetVisibleRect(x, y, w, h, &dx, &dy, &dw, &dh)) return; + + short color = Clay_Ncurses_GetColorId(command->renderData.border.color); + short bg = Clay_Ncurses_GetBackgroundAt(dx, dy); + int pair = Clay_Ncurses_GetColorPair(color, bg); + + attron(COLOR_PAIR(pair)); + + // Draw Top + if (y >= dy && y < dy + dh) { + int startX = (x > dx) ? x : dx; + int endX = (x + w < dx + dw) ? (x + w) : (dx + dw); + if (x < startX) startX++; // Adjust for corner if clipped + if (x + w - 1 > endX) endX--; // Adjust for corner + + // Simplification: Check intersection with range for horizontal lines + int h_sx = x + 1; + int h_ex = x + w - 1; + + int draw_sx = (h_sx > dx) ? h_sx : dx; + int draw_ex = (h_ex < dx + dw) ? h_ex : dx + dw; + + if (draw_ex > draw_sx) { + mvwhline(stdscr, y, draw_sx, ACS_HLINE, draw_ex - draw_sx); + } + } + + // Draw Bottom + if (y + h - 1 >= dy && y + h - 1 < dy + dh) { + int h_sx = x + 1; + int h_ex = x + w - 1; + int draw_sx = (h_sx > dx) ? h_sx : dx; + int draw_ex = (h_ex < dx + dw) ? h_ex : dx + dw; + + if (draw_ex > draw_sx) { + mvwhline(stdscr, y + h - 1, draw_sx, ACS_HLINE, draw_ex - draw_sx); + } + } + + // Draw Left + if (x >= dx && x < dx + dw) { + int v_sy = y + 1; + int v_ey = y + h - 1; + int draw_sy = (v_sy > dy) ? v_sy : dy; + int draw_ey = (v_ey < dy + dh) ? v_ey : dy + dh; + + if (draw_ey > draw_sy) { + mvwvline(stdscr, draw_sy, x, ACS_VLINE, draw_ey - draw_sy); + } + } + + // Draw Right + if (x + w - 1 >= dx && x + w - 1 < dx + dw) { + int v_sy = y + 1; + int v_ey = y + h - 1; + int draw_sy = (v_sy > dy) ? v_sy : dy; + int draw_ey = (v_ey < dy + dh) ? v_ey : dy + dh; + + if (draw_ey > draw_sy) { + mvwvline(stdscr, draw_sy, x + w - 1, ACS_VLINE, draw_ey - draw_sy); + } + } + + // Corners + bool drawTop = (y >= dy && y < dy + dh); + bool drawBottom = (y + h - 1 >= dy && y + h - 1 < dy + dh); + bool drawLeft = (x >= dx && x < dx + dw); + bool drawRight = (x + w - 1 >= dx && x + w - 1 < dx + dw); + + if (drawTop && drawLeft) { + mvaddch(y, x, (command->renderData.border.cornerRadius.topLeft > 0) ? ACS_ULCORNER : ACS_ULCORNER); + } + if (drawTop && drawRight) { + mvaddch(y, x + w - 1, (command->renderData.border.cornerRadius.topRight > 0) ? ACS_URCORNER : ACS_URCORNER); + } + if (drawBottom && drawLeft) { + mvaddch(y + h - 1, x, (command->renderData.border.cornerRadius.bottomLeft > 0) ? ACS_LLCORNER : ACS_LLCORNER); + } + if (drawBottom && drawRight) { + mvaddch(y + h - 1, x + w - 1, (command->renderData.border.cornerRadius.bottomRight > 0) ? ACS_LRCORNER : ACS_LRCORNER); + } + + attroff(COLOR_PAIR(pair)); +} + +/** + * @brief Pushes a new clipping rectangle onto the scissor stack. + * The new clip is intersected with the current top of the stack. + * @param boundingBox The new clipping region. + */ +static void Clay_Ncurses_PushScissor(Clay_BoundingBox boundingBox) { + if (_scissorStackIndex >= MAX_SCISSOR_STACK_DEPTH - 1) return; + + Clay_BoundingBox current = _scissorStack[_scissorStackIndex]; + Clay_BoundingBox next = boundingBox; + + 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 }; +} + +/** + * @brief Pops the current clipping rectangle from the stack. + */ +static void Clay_Ncurses_PopScissor() { + if (_scissorStackIndex > 0) { + _scissorStackIndex--; + } +} + +// ------------------------------------------------------------------------------------------------- +// -- Public API Implementation +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Initializes the Ncurses library and internal renderer state. + * Sets up locale, screen, keyboard input, and color support. + */ +void Clay_Ncurses_Initialize() { + if (_isInitialized) return; + + Clay_Ncurses_InitLocale(); + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(0); + + mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); + + start_color(); + use_default_colors(); + + getmaxyx(stdscr, _screenHeight, _screenWidth); + + _scissorStack[0] = (Clay_BoundingBox){0, 0, (float)_screenWidth * CLAY_NCURSES_CELL_WIDTH, (float)_screenHeight * CLAY_NCURSES_CELL_HEIGHT}; + _scissorStackIndex = 0; + + _isInitialized = true; +} + +/** + * @brief Terminates the Ncurses library and cleans up. + * Returns the terminal to its normal state. + */ +void Clay_Ncurses_Terminate() { + if (_isInitialized) { + clear(); + refresh(); + endwin(); + + SCREEN *s = set_term(NULL); + if (s) { + delscreen(s); + } + + _isInitialized = false; + } +} + +/** + * @brief Returns the layout dimensions of the current Ncurses screen. + * @return The dimensions in Clay logical units. + */ +Clay_Dimensions Clay_Ncurses_GetLayoutDimensions() { + return (Clay_Dimensions) { + .width = (float)_screenWidth * CLAY_NCURSES_CELL_WIDTH, + .height = (float)_screenHeight * CLAY_NCURSES_CELL_HEIGHT + }; +} + +/** + * @brief Measures text for layout purposes. + * @param text The text to measure. + * @param config Text configuration (unused in this fixed-w renderer). + * @param userData Custom user data (unused). + * @return The dimensions of the text in Clay logical units. + */ +Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) { + (void)config; + (void)userData; + + int width = Clay_Ncurses_MeasureStringWidth(text); + return (Clay_Dimensions) { + .width = (float)width * CLAY_NCURSES_CELL_WIDTH, + .height = CLAY_NCURSES_CELL_HEIGHT + }; +} + +/** + * @brief Main rendering entry point. Processes the Clay RenderCommandBuffer and draws to the terminal. + * @param renderCommands The array of commands produced by Clay_EndLayout(). + */ +void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { + if (!_isInitialized) return; + + // Update screen dimensions if terminal successfully resized + int newW, newH; + getmaxyx(stdscr, newH, newW); + if (newW != _screenWidth || newH != _screenHeight) { + _screenWidth = newW; + _screenHeight = newH; + } + + // Reset Scissor Stack for new frame + _scissorStack[0] = (Clay_BoundingBox){0, 0, (float)_screenWidth * CLAY_NCURSES_CELL_WIDTH, (float)_screenHeight * CLAY_NCURSES_CELL_HEIGHT}; + _scissorStackIndex = 0; + + for (int i = 0; i < renderCommands.length; i++) { + Clay_RenderCommand *command = Clay_RenderCommandArray_Get(&renderCommands, i); + + switch (command->commandType) { + case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: + Clay_Ncurses_RenderRectangle(command); + break; + case CLAY_RENDER_COMMAND_TYPE_TEXT: + Clay_Ncurses_RenderText(command); + break; + case CLAY_RENDER_COMMAND_TYPE_BORDER: + Clay_Ncurses_RenderBorder(command); + break; + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: + Clay_Ncurses_PushScissor(command->boundingBox); + break; + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: + Clay_Ncurses_PopScissor(); + break; + default: + break; + } + } + + refresh(); +} + +// ------------------------------------------------------------------------------------------------- +// -- Internal Logic: Color & Measure +// ------------------------------------------------------------------------------------------------- + +/** + * @brief Approximates a true color (RGB) to the nearest available Ncurses color index. + * Supports 256-color mode (6x6x6 cube) and 8-color fallback. + * @param color The requested RGB color. + * @return The approximate Ncurses color index. + */ +static short Clay_Ncurses_MatchColor(Clay_Color color) { + // 256 Color Mode + if (COLORS >= 256) { + int r = (int)((color.r / 255.0f) * 5.0f); + int g = (int)((color.g / 255.0f) * 5.0f); + int b = (int)((color.b / 255.0f) * 5.0f); + return (short)(16 + (36 * r) + (6 * g) + b); + } + + // 8 Color Fallback + 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 short Clay_Ncurses_GetColorId(Clay_Color color) { @@ -467,18 +572,14 @@ static short Clay_Ncurses_GetColorId(Clay_Color color) { } 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 + return 0; // Cache full, fallback to default } int newId = _colorPairCacheSize + 1; @@ -492,40 +593,18 @@ static int Clay_Ncurses_GetColorPair(short fg, short bg) { return newId; } -static void Clay_Ncurses_InitLocale(void) { - // Attempt 1: environment locale - char *locale = setlocale(LC_ALL, ""); - - // If environment is non-specific (C or POSIX), try to force a UTF-8 one. - if (!locale || strcmp(locale, "C") == 0 || strcmp(locale, "POSIX") == 0) { - // Attempt 2: C.UTF-8 (standard on many modern Linux) - locale = setlocale(LC_ALL, "C.UTF-8"); - - if (!locale) { - // Attempt 3: en_US.UTF-8 (Common fallback) - locale = setlocale(LC_ALL, "en_US.UTF-8"); - } - } -} - static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { - // Need temporary null-terminated string for mbstowcs - // Or iterate bytes with mbtowc int width = 0; const char *ptr = text.chars; int len = text.length; - // Reset shift state - mbtowc(NULL, NULL, 0); + mbtowc(NULL, NULL, 0); // Reset state while (len > 0) { wchar_t wc; int bytes = mbtowc(&wc, ptr, len); if (bytes <= 0) { - // Error or null? skip byte - ptr++; - len--; - continue; + ptr++; len--; continue; } int w = wcwidth(wc); if (w > 0) width += w; From bc742a190a1fd22dce9e6cfe8d42fc9a632e5251 Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 18:54:31 +0100 Subject: [PATCH 5/9] build: disable Ncurses examples by default --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b22a381..84ac2ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +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) +option(CLAY_INCLUDE_NCURSES_EXAMPLES "Build Ncurses examples" OFF) message(STATUS "CLAY_INCLUDE_DEMOS: ${CLAY_INCLUDE_DEMOS}") From c7001047608680a0eccad179f8f46cc30cd5acdc Mon Sep 17 00:00:00 2001 From: Seintian Date: Sun, 28 Dec 2025 22:17:27 +0100 Subject: [PATCH 6/9] feat(ncurses): Add interaction callbacks and improve input handling Introduces Input Processing and Interaction helpers for the Ncurses renderer, ensuring robust mouse support and simplified event handling. **Renderer (`renderers/ncurses`):** - **`Clay_Ncurses_ProcessInput`**: Added a dedicated input processing function that handles both keyboard and mouse events. - Implemented persistent `_isMouseDown` state tracking to fix missed "fast clicks" and preserve button state during drag operations. - Adjusted `mousemask` to `BUTTON1_PRESSED | BUTTON1_RELEASED | REPORT_MOUSE_POSITION` to bypass Ncurses' internal click resolution delay. - **`Clay_Ncurses_OnClick`**: Added a helper function to easily attach click listeners. - Registers the user's callback directly via `Clay_OnHover` (avoiding allocation/proxies). - Matches the standard Clay callback signature pattern. **Example (`examples/ncurses-example`):** - **Input Loop**: Migrated main loop to use `Clay_Ncurses_ProcessInput`. - **Interactions**: - Added a "Toggle Help" button to the sidebar. - Implemented `HandleHelpToggleClick` callback, which explicitly checks for `CLAY_POINTER_DATA_RELEASED_THIS_FRAME` to validate clicks. - Added visual hover effects to sidebar items. **Documentation (`renderers/ncurses/README.md`):** - Updated "Usage" section to demonstrate `Clay_Ncurses_ProcessInput`. - Added "Input & Interaction" section documenting the new helpers. --- examples/ncurses-example/main.c | 46 ++++++--- renderers/ncurses/README.md | 114 ++++++++++++++++++++++ renderers/ncurses/clay_renderer_ncurses.c | 60 +++++++++++- 3 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 renderers/ncurses/README.md diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index b7b7550..58b6b9a 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -72,20 +72,30 @@ static AppState _appState = { .scrollDelta = 0.0f }; +// ------------------------------------------------------------------------------------------------- +// -- Callback Handlers +// ------------------------------------------------------------------------------------------------- + +void HandleHelpToggleClick(Clay_ElementId elementId, Clay_PointerData pointerInfo, void *userData) { + if (pointerInfo.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME) { + _appState.isHelpModalVisible = !_appState.isHelpModalVisible; + } +} + // ------------------------------------------------------------------------------------------------- // -- Input Processing // ------------------------------------------------------------------------------------------------- /** - * @brief Processes keyboard input for the current frame. + * @brief Processes input for the current frame. * Updates _appState directly based on key presses. - * Uses ncurses getch() (non-blocking if timeout is set). + * Uses ncurses input processing (non-blocking if timeout is set). */ void App_ProcessInput() { _appState.scrollDelta = 0.0f; - int key; - while ((key = getch()) != ERR) { + int key = Clay_Ncurses_ProcessInput(stdscr); + if (key != ERR) { switch (key) { case 'q': case 'Q': @@ -93,6 +103,7 @@ void App_ProcessInput() { break; case 's': case 'S': + // Toggle between two states _appState.isSidebarVisible = !_appState.isSidebarVisible; break; case 'h': @@ -180,7 +191,7 @@ void UI_ServerStatusWidget() { void UI_SidebarItem(Clay_String label, Clay_Color textColor) { CLAY(CLAY_ID_LOCAL("SidebarItem"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } }, - .backgroundColor = COLOR_PANEL_BG + .backgroundColor = Clay_Hovered() ? (Clay_Color){60, 60, 60, 255} : COLOR_PANEL_BG }) { CLAY_TEXT(label, CLAY_TEXT_CONFIG({ .textColor = textColor })); } @@ -210,11 +221,20 @@ void UI_Sidebar() { UI_SidebarItem(CLAY_STRING(" > Item 1 🌍"), (Clay_Color){0, 255, 255, 255}); UI_SidebarItem(CLAY_STRING(" > Item 2 🌐"), COLOR_TEXT_WHITE); + CLAY(CLAY_ID("HelpToggleButton"), { + .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) }, .childAlignment = {.y = CLAY_ALIGN_Y_CENTER} }, + .backgroundColor = Clay_Hovered() ? (Clay_Color){0, 100, 0, 255} : COLOR_PANEL_BG, + .cornerRadius = {1} + }) { + Clay_Ncurses_OnClick(HandleHelpToggleClick, NULL); + CLAY_TEXT(CLAY_STRING(" > Toggle Help"), CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_WHITE })); + } + // Mixed Style Items CLAY(CLAY_ID("SidebarItemMixed1"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, .backgroundColor = COLOR_PANEL_BG, - .cornerRadius = { .topLeft = 8 }, + .cornerRadius = { .topLeft = 1 }, .border = { .color = COLOR_ACCENT_RED, .width = {2, 2, 2, 2} } }) { CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_RED })); @@ -223,7 +243,7 @@ void UI_Sidebar() { CLAY(CLAY_ID("SidebarItemMixed2"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, .backgroundColor = COLOR_PANEL_BG, - .cornerRadius = { .topLeft = 8, .bottomRight = 8 }, + .cornerRadius = { .topLeft = 1, .bottomRight = 1 }, .border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} } }) { CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} })); @@ -232,7 +252,7 @@ void UI_Sidebar() { CLAY(CLAY_ID("SidebarItemMixed3"), { .layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER } }, .backgroundColor = COLOR_PANEL_BG, - .cornerRadius = { .topLeft = 8, .topRight = 8 }, + .cornerRadius = { .topLeft = 1, .topRight = 1 }, .border = { .color = COLOR_ACCENT_BLUE, .width = {2, 2, 2, 2} } }) { CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_BLUE })); @@ -387,7 +407,7 @@ void UI_HelpModal() { .layoutDirection = CLAY_TOP_TO_BOTTOM }, .backgroundColor = {30, 30, 30, 255}, - .cornerRadius = {4}, + .cornerRadius = {1}, .border = { .color = COLOR_TEXT_WHITE, .width = {2, 2, 2, 2} } }) { CLAY_TEXT(CLAY_STRING("Ncurses Example Help"), CLAY_TEXT_CONFIG({ .textColor = COLOR_TEXT_WHITE })); @@ -434,7 +454,7 @@ int main() { Clay_Initialize(arena, (Clay_Dimensions){0,0}, (Clay_ErrorHandler){NULL}); Clay_SetMeasureTextFunction(Clay_Ncurses_MeasureText, NULL); - + // Initialize Ncurses Renderer Clay_Ncurses_Initialize(); @@ -452,12 +472,6 @@ int main() { 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); } diff --git a/renderers/ncurses/README.md b/renderers/ncurses/README.md new file mode 100644 index 0000000..690bd7a --- /dev/null +++ b/renderers/ncurses/README.md @@ -0,0 +1,114 @@ +# Clay Ncurses Renderer + +This directory contains a backend renderer for [Clay](https://github.com/nicbarker/clay) that targets the terminal using the `ncurses` library. It allows you to build text-based user interfaces (TUI) using the same Clay layout engine used for graphical applications. + +## Features + +- **Responsive Layouts in the Terminal**: Use flex-box like layout rules to organize text and panels in a terminal window. +- **Color Support**: + - Automatically matches Clay's `RGB` colors to the nearest available terminal color. + - Supports **256-color** terminals (xterm-256color) for richer palettes. + - Graceful fallback to standard 8 ANSI colors for older terminals. +- **UTF-8 Support**: Correctly measures and renders multibyte characters (assuming the terminal is configured for UTF-8). +- **Primitives Supported**: + - `Rectangle`: Renders as solid blocks of color. + - `Text`: Renders colored text. + - `Border`: Renders lines using ACS (Alternate Character Set) box-drawing characters, supporting rounded corners and different line styles where possible. + - `Scissor/Clipping`: Fully supports nested clipping rectangles (e.g., for scroll containers). +- **Input Handling**: + - sets up standard ncurses input modes (cbreak, noecho, keypad). + - Enables mouse event reporting. + +## Usage + +To use the ncurses renderer in your Clay application: + +### 1. Include the Renderer + +```c +#define CLAY_IMPLEMENTATION +#include "clay.h" +#include "renderers/ncurses/clay_renderer_ncurses.c" +``` + +### 2. Initialization + +Initialize the renderer before your main loop. This sets up the terminal screen, colors, and input modes. + +```c +Clay_Initialize(arena, (Clay_Dimensions){0,0}, (Clay_ErrorHandler){NULL}); +Clay_SetMeasureTextFunction(Clay_Ncurses_MeasureText, NULL); +Clay_Ncurses_Initialize(); +``` + +### 3. Rendering Loop + +In your main loop, update the layout dimensions based on the terminal size, run the layout, and then pass the render commands to the ncurses renderer. + +```c +while (!shouldQuit) { + // 1. Get current terminal size + Clay_Dimensions dims = Clay_Ncurses_GetLayoutDimensions(); + Clay_SetLayoutDimensions(dims); + + // 2. Handle Input + int key = Clay_Ncurses_ProcessInput(stdscr); + if (key == 'q') break; + + // 3. Define Layout + Clay_BeginLayout(); + + // Example: Clickable Element + CLAY(CLAY_ID("Clickable"), {0}) { + Clay_Ncurses_OnClick(MyCallback, myData); + CLAY_TEXT(CLAY_STRING("Click Me"), CLAY_TEXT_CONFIG({0})); + } + + Clay_RenderCommandArray commands = Clay_EndLayout(); + + // 4. Render + Clay_Ncurses_Render(commands); +} +``` + +### 4. Input & Interaction + +The renderer provides helper functions to easy integration of mouse interactions: + +- **`Clay_Ncurses_ProcessInput(WINDOW *window)`**: Call this instead of `getch` or `wgetch`. It handles mouse events, updates the internal Clay pointer state, and returns the key code for your application to handle (e.g., keyboard shortcuts). +- **`Clay_Ncurses_OnClick(void (*userData)(...), void *userData)`**: A helper to attach a click listener to the current element. It uses `Clay_OnHover` internally. Your callback function should check if `pointerInfo.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME` to detect a valid click. + +### 5. Cleanup + +Restore the terminal to its normal state before exiting. + +```c +Clay_Ncurses_Terminate(); +``` + +## Compilation + +You must link against the `ncurses` (and potentially `tinfo`) library. + +```bash +gcc main.c -lncurses -o my_app +``` + +On some systems attempting to use wide characters/UTF-8 might require linking `ncursesw` instead: + +```bash +gcc main.c -lncursesw -o my_app +``` + +## How it Works + +The renderer maps Clay's floating-point coordinate system to the integer grid of the terminal. +- **Cell Size**: It assumes a logical "pixel" size for each character cell (defaults to 8x16 internally) to map Clay's high-precision layout to character columns and rows. +- **Double Buffering**: It uses ncurses' standard buffering mechanisms (`refresh()`) to prevent flickering during updates. +- **Clipping**: It uses a software scissor stack to determine visibility, as terminals do not natively support arbitrary clipping regions for drawing commands. + +## Limitations + +- **Images**: Rendering images is not currently supported. +- **Fonts**: Text size is fixed to the terminal's cell size. `fontSize` configs are ignored for layout measurement, though they affect the logical ID generation. +- **Pixel Precision**: Since the output is quantized to character cells, fine-grained pixel alignment (e.g., a 1px shift) will snap to the nearest cell boundary. diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 5a69ca8..1cb288f 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -425,7 +425,10 @@ void Clay_Ncurses_Initialize() { keypad(stdscr, TRUE); curs_set(0); - mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); + // We only ask for PRESS and RELEASE events. + // If we ask for CLICK events, ncurses waits to see if a release happens quickly, + // which delays the report of the PRESS event or swallows it, causing Clay to miss the "Down" state. + mousemask(BUTTON1_PRESSED | BUTTON1_RELEASED | REPORT_MOUSE_POSITION, NULL); start_color(); use_default_colors(); @@ -613,3 +616,58 @@ static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { } return width; } + +/** + * @brief Handles Ncurses input and updates Clay's internal pointer state. + * Use this instead of standard getch() in your main loop to enable mouse interaction. + * + * @param window The Ncurses window to read input from (e.g. stdscr). + * @return The key code pressed, or ERR if no input. + */ +static bool _pointerReleasedThisFrame = false; + +int Clay_Ncurses_ProcessInput(WINDOW *window) { + int key = wgetch(window); + _pointerReleasedThisFrame = false; + + // Handle Mouse + if (key == KEY_MOUSE) { + MEVENT event; + if (getmouse(&event) == OK) { + // Convert Cell Coordinates -> Clay Logical Coordinates + Clay_Vector2 mousePos = { + (float)event.x * CLAY_NCURSES_CELL_WIDTH, + (float)event.y * CLAY_NCURSES_CELL_HEIGHT + }; + + // Persistent state to handle drag/move events where button state might be absent in the event mask + static bool _isMouseDown = false; + + if (event.bstate & (BUTTON1_PRESSED | BUTTON1_DOUBLE_CLICKED | BUTTON1_TRIPLE_CLICKED)) { + _isMouseDown = true; + } + + if (event.bstate & BUTTON1_RELEASED) { + _isMouseDown = false; + } + + // Update Clay State + Clay_SetPointerState(mousePos, _isMouseDown); + } + } + + return key; +} + +/** + * @brief Helper to attach an OnClick listener to the current element. + * Registers a hover callback. The user's function must check `pointerData.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME`. + * + * @param onClickFunc Function pointer to call. + * @param userData User data passed to the callback. + */ +void Clay_Ncurses_OnClick(void (*onClickFunc)(Clay_ElementId elementId, Clay_PointerData pointerData, void *userData), void *userData) { + if (onClickFunc) { + Clay_OnHover(onClickFunc, userData); + } +} From e89f3d15e91a05dcaf6cffb0069ff17a0be45efb Mon Sep 17 00:00:00 2001 From: Seintian Date: Mon, 29 Dec 2025 20:33:20 +0100 Subject: [PATCH 7/9] feat(ncurses): event-driven scrolling & font styling - Update `renderers/ncurses/clay_renderer_ncurses.c`: - Export `CLAY_NCURSES_KEY_SCROLL_UP` and `CLAY_NCURSES_KEY_SCROLL_DOWN` key codes. - Modify `Clay_Ncurses_ProcessInput` to map mouse wheel events (`BUTTON4`, `BUTTON5`) to these key codes. - Update `Clay_Ncurses_OnClick` to trigger on `CLAY_POINTER_DATA_PRESSED_THIS_FRAME` for immediate feedback. - Update `examples/ncurses-example/main.c`: - Handle `CLAY_NCURSES_KEY_SCROLL_UP/DOWN` in `App_ProcessInput` to drive `_appState.scrollDelta`. - Simplify `HandleHelpToggleClick` to toggle visibility directly. - Apply bold and underline font styles to sidebar items. - Convert input processing to a `while` loop to process all pending events per frame. --- examples/ncurses-example/main.c | 16 +++---- renderers/ncurses/README.md | 17 +++++-- renderers/ncurses/clay_renderer_ncurses.c | 54 +++++++++++++++++++---- 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index 58b6b9a..b2f6e00 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -77,9 +77,7 @@ static AppState _appState = { // ------------------------------------------------------------------------------------------------- void HandleHelpToggleClick(Clay_ElementId elementId, Clay_PointerData pointerInfo, void *userData) { - if (pointerInfo.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME) { - _appState.isHelpModalVisible = !_appState.isHelpModalVisible; - } + _appState.isHelpModalVisible = !_appState.isHelpModalVisible; } // ------------------------------------------------------------------------------------------------- @@ -94,8 +92,8 @@ void HandleHelpToggleClick(Clay_ElementId elementId, Clay_PointerData pointerInf void App_ProcessInput() { _appState.scrollDelta = 0.0f; - int key = Clay_Ncurses_ProcessInput(stdscr); - if (key != ERR) { + int key; + while ((key = Clay_Ncurses_ProcessInput(stdscr)) != ERR) { switch (key) { case 'q': case 'Q': @@ -111,9 +109,11 @@ void App_ProcessInput() { _appState.isHelpModalVisible = !_appState.isHelpModalVisible; break; case KEY_UP: + case CLAY_NCURSES_KEY_SCROLL_UP: _appState.scrollDelta += DEFAULT_SCROLL_SENSITIVITY; break; case KEY_DOWN: + case CLAY_NCURSES_KEY_SCROLL_DOWN: _appState.scrollDelta -= DEFAULT_SCROLL_SENSITIVITY; break; } @@ -237,7 +237,7 @@ void UI_Sidebar() { .cornerRadius = { .topLeft = 1 }, .border = { .color = COLOR_ACCENT_RED, .width = {2, 2, 2, 2} } }) { - CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_RED })); + CLAY_TEXT(CLAY_STRING(" > TL BOLD"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_RED, .fontId = CLAY_NCURSES_FONT_BOLD })); } CLAY(CLAY_ID("SidebarItemMixed2"), { @@ -246,7 +246,7 @@ void UI_Sidebar() { .cornerRadius = { .topLeft = 1, .bottomRight = 1 }, .border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} } }) { - CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} })); + CLAY_TEXT(CLAY_STRING(" > Diag Under"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255}, .fontId = CLAY_NCURSES_FONT_UNDERLINE })); } CLAY(CLAY_ID("SidebarItemMixed3"), { @@ -255,7 +255,7 @@ void UI_Sidebar() { .cornerRadius = { .topLeft = 1, .topRight = 1 }, .border = { .color = COLOR_ACCENT_BLUE, .width = {2, 2, 2, 2} } }) { - CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_BLUE })); + CLAY_TEXT(CLAY_STRING(" > Top Bold Und"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_BLUE, .fontId = CLAY_NCURSES_FONT_BOLD | CLAY_NCURSES_FONT_UNDERLINE })); } } } diff --git a/renderers/ncurses/README.md b/renderers/ncurses/README.md index 690bd7a..2c073ec 100644 --- a/renderers/ncurses/README.md +++ b/renderers/ncurses/README.md @@ -75,10 +75,21 @@ while (!shouldQuit) { The renderer provides helper functions to easy integration of mouse interactions: -- **`Clay_Ncurses_ProcessInput(WINDOW *window)`**: Call this instead of `getch` or `wgetch`. It handles mouse events, updates the internal Clay pointer state, and returns the key code for your application to handle (e.g., keyboard shortcuts). -- **`Clay_Ncurses_OnClick(void (*userData)(...), void *userData)`**: A helper to attach a click listener to the current element. It uses `Clay_OnHover` internally. Your callback function should check if `pointerInfo.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME` to detect a valid click. +- **`Clay_Ncurses_ProcessInput(WINDOW *window)`**: Call this instead of `getch` or `wgetch`. It handles mouse events (including scroll wheel mapping to `Clay_UpdateScrollContainers`), updates the internal Clay pointer state, and returns the key code for your application to handle. +- **`Clay_Ncurses_OnClick(void (*userData)(...), void *userData)`**: A helper to attach a click listener to the current element. It uses `Clay_OnHover` internally. Your callback function should check if `pointerInfo.state == CLAY_POINTER_DATA_PRESSED_THIS_FRAME` for instant click feedback. -### 5. Cleanup +### 5. Font Styling + +You can apply **Bold** and **Underline** styles using the `fontId` configuration in `CLAY_TEXT`. +Use the provided macros: + +```c +CLAY_TEXT(CLAY_STRING("Bold Text"), CLAY_TEXT_CONFIG({ .fontId = CLAY_NCURSES_FONT_BOLD })); +CLAY_TEXT(CLAY_STRING("Underline"), CLAY_TEXT_CONFIG({ .fontId = CLAY_NCURSES_FONT_UNDERLINE })); +CLAY_TEXT(CLAY_STRING("Both"), CLAY_TEXT_CONFIG({ .fontId = CLAY_NCURSES_FONT_BOLD | CLAY_NCURSES_FONT_UNDERLINE })); +``` + +### 6. Cleanup Restore the terminal to its normal state before exiting. diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 1cb288f..61c144f 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -26,6 +26,13 @@ #include #include "../../clay.h" +#define CLAY_NCURSES_FONT_BOLD 1 +#define CLAY_NCURSES_FONT_UNDERLINE 2 + +// Custom Key Codes for Mouse Scrolling +#define CLAY_NCURSES_KEY_SCROLL_UP 123456 +#define CLAY_NCURSES_KEY_SCROLL_DOWN 123457 + // ------------------------------------------------------------------------------------------------- // -- Internal State & Constants // ------------------------------------------------------------------------------------------------- @@ -51,6 +58,10 @@ static int _screenHeight = 0; /** @brief Flag indicating if the ncurses subsystem has been successfully initialized. */ static bool _isInitialized = false; +// Input State +static bool _pointerReleasedThisFrame = false; +static bool _pointerPressedThisFrame = false; + // Scissor / Clipping State /** @brief Maximum depth of the scissor/clipping stack. */ @@ -234,6 +245,9 @@ static void Clay_Ncurses_RenderText(Clay_RenderCommand *command) { int pair = Clay_Ncurses_GetColorPair(fg, bg); attron(COLOR_PAIR(pair)); + attron(COLOR_PAIR(pair)); + if (command->renderData.text.fontId & CLAY_NCURSES_FONT_BOLD) attron(A_BOLD); + if (command->renderData.text.fontId & CLAY_NCURSES_FONT_UNDERLINE) attron(A_UNDERLINE); // Complex multibyte string handling // We render to a temporary buffer first to handle wide characters @@ -277,6 +291,8 @@ static void Clay_Ncurses_RenderText(Clay_RenderCommand *command) { } free(wbuf); + if (command->renderData.text.fontId & CLAY_NCURSES_FONT_BOLD) attroff(A_BOLD); + if (command->renderData.text.fontId & CLAY_NCURSES_FONT_UNDERLINE) attroff(A_UNDERLINE); attroff(COLOR_PAIR(pair)); } @@ -428,7 +444,12 @@ void Clay_Ncurses_Initialize() { // We only ask for PRESS and RELEASE events. // If we ask for CLICK events, ncurses waits to see if a release happens quickly, // which delays the report of the PRESS event or swallows it, causing Clay to miss the "Down" state. - mousemask(BUTTON1_PRESSED | BUTTON1_RELEASED | REPORT_MOUSE_POSITION, NULL); + // We only ask for PRESS and RELEASE events. + // If we ask for CLICK events, ncurses waits to see if a release happens quickly, + // which delays the report of the PRESS event or swallows it, causing Clay to miss the "Down" state. + mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); + // Disable strict click resolution to avoid input lag. + mouseinterval(0); start_color(); use_default_colors(); @@ -496,6 +517,9 @@ Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElement void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { if (!_isInitialized) return; + // Reset input state for next frame + _pointerPressedThisFrame = false; + // Update screen dimensions if terminal successfully resized int newW, newH; getmaxyx(stdscr, newH, newW); @@ -624,8 +648,6 @@ static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { * @param window The Ncurses window to read input from (e.g. stdscr). * @return The key code pressed, or ERR if no input. */ -static bool _pointerReleasedThisFrame = false; - int Clay_Ncurses_ProcessInput(WINDOW *window) { int key = wgetch(window); _pointerReleasedThisFrame = false; @@ -643,16 +665,31 @@ int Clay_Ncurses_ProcessInput(WINDOW *window) { // Persistent state to handle drag/move events where button state might be absent in the event mask static bool _isMouseDown = false; + // Update Clay State FIRST so scroll/interaction logic knows where the mouse is + Clay_SetPointerState(mousePos, _isMouseDown); + if (event.bstate & (BUTTON1_PRESSED | BUTTON1_DOUBLE_CLICKED | BUTTON1_TRIPLE_CLICKED)) { _isMouseDown = true; + if (event.bstate & BUTTON1_PRESSED) { + _pointerPressedThisFrame = true; + } } if (event.bstate & BUTTON1_RELEASED) { _isMouseDown = false; } - // Update Clay State - Clay_SetPointerState(mousePos, _isMouseDown); + // Handle Scroll Wheel + #ifdef BUTTON4_PRESSED + if (event.bstate & BUTTON4_PRESSED) { + return CLAY_NCURSES_KEY_SCROLL_UP; + } + #endif + #ifdef BUTTON5_PRESSED + if (event.bstate & BUTTON5_PRESSED) { + return CLAY_NCURSES_KEY_SCROLL_DOWN; + } + #endif } } @@ -661,13 +698,14 @@ int Clay_Ncurses_ProcessInput(WINDOW *window) { /** * @brief Helper to attach an OnClick listener to the current element. - * Registers a hover callback. The user's function must check `pointerData.state == CLAY_POINTER_DATA_RELEASED_THIS_FRAME`. + * Registers a hover callback. The user's function must check `pointerData.state == CLAY_POINTER_DATA_PRESSED_THIS_FRAME`. * * @param onClickFunc Function pointer to call. * @param userData User data passed to the callback. */ void Clay_Ncurses_OnClick(void (*onClickFunc)(Clay_ElementId elementId, Clay_PointerData pointerData, void *userData), void *userData) { - if (onClickFunc) { - Clay_OnHover(onClickFunc, userData); + if (onClickFunc && Clay_Hovered() && _pointerPressedThisFrame) { + Clay_PointerData pointerData = (Clay_PointerData){ .state = CLAY_POINTER_DATA_PRESSED_THIS_FRAME }; + onClickFunc((Clay_ElementId){0}, pointerData, userData); } } From 8643a5d2d978fa06e2cc9181827c31adcbbddfb4 Mon Sep 17 00:00:00 2001 From: Seintian Date: Mon, 29 Dec 2025 21:57:50 +0100 Subject: [PATCH 8/9] Fix Ncurses click handling and restore ncursesw build This commit addresses issues with mouse interaction reliability and build configuration in the Ncurses renderer and example. Renderer Changes: - Enable explicit xterm 1003 mouse tracking (Any Event) via escape sequences to ensure reliable hover sensing from the first frame (though not so portable). - Refactor `Clay_Ncurses_ProcessInput` to: - Correctly order `Clay_SetPointerState` calls to ensure the "Pressed" state is registered before the function returns. - Detect and return a new `CLAY_NCURSES_KEY_MOUSE_CLICK` event code for single, double, and triple clicks. - Make `Clay_Ncurses_OnClick` as a semantic wrapper around `Clay_OnHover`. - Add empty switch cases for `CLAY_RENDER_COMMAND_TYPE_IMAGE` and `CLAY_RENDER_COMMAND_TYPE_CUSTOM` to prevent unhandled enumeration warnings. Example Application Changes: - Update `main.c` to break the input processing loop immediately upon receiving `CLAY_NCURSES_KEY_MOUSE_CLICK`, ensuring the "Pressed" state is processed by the layout engine for that frame. Build System: - Update `CMakeLists.txt` to replace `FindCurses` with `find_library` and `find_path` for `ncursesw`. --- examples/ncurses-example/CMakeLists.txt | 10 ++-- examples/ncurses-example/main.c | 9 ++- renderers/ncurses/clay_renderer_ncurses.c | 68 ++++++++++++----------- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/examples/ncurses-example/CMakeLists.txt b/examples/ncurses-example/CMakeLists.txt index 19fe1af..a35fcb6 100644 --- a/examples/ncurses-example/CMakeLists.txt +++ b/examples/ncurses-example/CMakeLists.txt @@ -1,17 +1,19 @@ cmake_minimum_required(VERSION 3.27) project(clay-ncurses-example C) -set(CURSES_NEED_WIDE TRUE) -find_package(Curses REQUIRED) + +# Find ncursesw explicitly +find_library(NCURSESW_LIB NAMES ncursesw REQUIRED) +find_path(NCURSESW_INCLUDE_DIR NAMES ncurses.h PATH_SUFFIXES ncursesw) add_compile_definitions(_XOPEN_SOURCE_EXTENDED _XOPEN_SOURCE=700) add_executable(clay-ncurses-example main.c) -target_link_libraries(clay-ncurses-example PRIVATE ${CURSES_LIBRARIES}) +target_link_libraries(clay-ncurses-example PRIVATE ${NCURSESW_LIB}) 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} ../../) +target_include_directories(clay-ncurses-example PRIVATE ${NCURSESW_INCLUDE_DIR} ../../) diff --git a/examples/ncurses-example/main.c b/examples/ncurses-example/main.c index b2f6e00..95470cb 100644 --- a/examples/ncurses-example/main.c +++ b/examples/ncurses-example/main.c @@ -77,7 +77,9 @@ static AppState _appState = { // ------------------------------------------------------------------------------------------------- void HandleHelpToggleClick(Clay_ElementId elementId, Clay_PointerData pointerInfo, void *userData) { - _appState.isHelpModalVisible = !_appState.isHelpModalVisible; + if (pointerInfo.state == CLAY_POINTER_DATA_PRESSED_THIS_FRAME) { + _appState.isHelpModalVisible = !_appState.isHelpModalVisible; + } } // ------------------------------------------------------------------------------------------------- @@ -94,6 +96,11 @@ void App_ProcessInput() { int key; while ((key = Clay_Ncurses_ProcessInput(stdscr)) != ERR) { + if (key == CLAY_NCURSES_KEY_MOUSE_CLICK) { + // Stop processing input this frame to ensure Clay sees the "Pressed" state + // before a subsequent "Released" event might overwrite it. + break; + } switch (key) { case 'q': case 'Q': diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 61c144f..1402351 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -1,7 +1,7 @@ /** * @file clay_renderer_ncurses.c * @author Seintian - * @date 2025-12-28 + * @date 2025-12-29 * @brief Ncurses renderer implementation for the Clay UI library. * * This file provides a backend for rendering Clay UI layouts using the Ncurses library. @@ -29,9 +29,9 @@ #define CLAY_NCURSES_FONT_BOLD 1 #define CLAY_NCURSES_FONT_UNDERLINE 2 -// Custom Key Codes for Mouse Scrolling #define CLAY_NCURSES_KEY_SCROLL_UP 123456 #define CLAY_NCURSES_KEY_SCROLL_DOWN 123457 +#define CLAY_NCURSES_KEY_MOUSE_CLICK 123458 // ------------------------------------------------------------------------------------------------- // -- Internal State & Constants @@ -58,10 +58,6 @@ static int _screenHeight = 0; /** @brief Flag indicating if the ncurses subsystem has been successfully initialized. */ static bool _isInitialized = false; -// Input State -static bool _pointerReleasedThisFrame = false; -static bool _pointerPressedThisFrame = false; - // Scissor / Clipping State /** @brief Maximum depth of the scissor/clipping stack. */ @@ -327,7 +323,7 @@ static void Clay_Ncurses_RenderBorder(Clay_RenderCommand *command) { // Simplification: Check intersection with range for horizontal lines int h_sx = x + 1; int h_ex = x + w - 1; - + int draw_sx = (h_sx > dx) ? h_sx : dx; int draw_ex = (h_ex < dx + dw) ? h_ex : dx + dw; @@ -342,7 +338,7 @@ static void Clay_Ncurses_RenderBorder(Clay_RenderCommand *command) { int h_ex = x + w - 1; int draw_sx = (h_sx > dx) ? h_sx : dx; int draw_ex = (h_ex < dx + dw) ? h_ex : dx + dw; - + if (draw_ex > draw_sx) { mvwhline(stdscr, y + h - 1, draw_sx, ACS_HLINE, draw_ex - draw_sx); } @@ -354,7 +350,7 @@ static void Clay_Ncurses_RenderBorder(Clay_RenderCommand *command) { int v_ey = y + h - 1; int draw_sy = (v_sy > dy) ? v_sy : dy; int draw_ey = (v_ey < dy + dh) ? v_ey : dy + dh; - + if (draw_ey > draw_sy) { mvwvline(stdscr, draw_sy, x, ACS_VLINE, draw_ey - draw_sy); } @@ -441,16 +437,13 @@ void Clay_Ncurses_Initialize() { keypad(stdscr, TRUE); curs_set(0); - // We only ask for PRESS and RELEASE events. - // If we ask for CLICK events, ncurses waits to see if a release happens quickly, - // which delays the report of the PRESS event or swallows it, causing Clay to miss the "Down" state. - // We only ask for PRESS and RELEASE events. - // If we ask for CLICK events, ncurses waits to see if a release happens quickly, - // which delays the report of the PRESS event or swallows it, causing Clay to miss the "Down" state. mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); - // Disable strict click resolution to avoid input lag. mouseinterval(0); + // Force xterm mouse tracking (Any Event) to ensure we get position updates + // even when buttons are not pressed. This fixes hover detection. + puts("\033[?1003h"); + start_color(); use_default_colors(); @@ -468,6 +461,9 @@ void Clay_Ncurses_Initialize() { */ void Clay_Ncurses_Terminate() { if (_isInitialized) { + // Restore mouse tracking state + puts("\033[?1003l"); + clear(); refresh(); endwin(); @@ -517,9 +513,6 @@ Clay_Dimensions Clay_Ncurses_MeasureText(Clay_StringSlice text, Clay_TextElement void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { if (!_isInitialized) return; - // Reset input state for next frame - _pointerPressedThisFrame = false; - // Update screen dimensions if terminal successfully resized int newW, newH; getmaxyx(stdscr, newH, newW); @@ -551,6 +544,8 @@ void Clay_Ncurses_Render(Clay_RenderCommandArray renderCommands) { case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: Clay_Ncurses_PopScissor(); break; + case CLAY_RENDER_COMMAND_TYPE_IMAGE: + case CLAY_RENDER_COMMAND_TYPE_CUSTOM: default: break; } @@ -631,13 +626,17 @@ static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { wchar_t wc; int bytes = mbtowc(&wc, ptr, len); if (bytes <= 0) { - ptr++; len--; continue; + ptr++; + len--; + continue; } + int w = wcwidth(wc); if (w > 0) width += w; ptr += bytes; len -= bytes; } + return width; } @@ -650,7 +649,6 @@ static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) { */ int Clay_Ncurses_ProcessInput(WINDOW *window) { int key = wgetch(window); - _pointerReleasedThisFrame = false; // Handle Mouse if (key == KEY_MOUSE) { @@ -664,19 +662,21 @@ int Clay_Ncurses_ProcessInput(WINDOW *window) { // Persistent state to handle drag/move events where button state might be absent in the event mask static bool _isMouseDown = false; - - // Update Clay State FIRST so scroll/interaction logic knows where the mouse is - Clay_SetPointerState(mousePos, _isMouseDown); + bool shouldReturnClick = false; if (event.bstate & (BUTTON1_PRESSED | BUTTON1_DOUBLE_CLICKED | BUTTON1_TRIPLE_CLICKED)) { _isMouseDown = true; - if (event.bstate & BUTTON1_PRESSED) { - _pointerPressedThisFrame = true; - } + shouldReturnClick = true; + } + else if (event.bstate & BUTTON1_RELEASED) { + _isMouseDown = false; } - if (event.bstate & BUTTON1_RELEASED) { - _isMouseDown = false; + // Update Clay State with the final determined state for this event + Clay_SetPointerState(mousePos, _isMouseDown); + + if (shouldReturnClick) { + return CLAY_NCURSES_KEY_MOUSE_CLICK; } // Handle Scroll Wheel @@ -703,9 +703,11 @@ int Clay_Ncurses_ProcessInput(WINDOW *window) { * @param onClickFunc Function pointer to call. * @param userData User data passed to the callback. */ -void Clay_Ncurses_OnClick(void (*onClickFunc)(Clay_ElementId elementId, Clay_PointerData pointerData, void *userData), void *userData) { - if (onClickFunc && Clay_Hovered() && _pointerPressedThisFrame) { - Clay_PointerData pointerData = (Clay_PointerData){ .state = CLAY_POINTER_DATA_PRESSED_THIS_FRAME }; - onClickFunc((Clay_ElementId){0}, pointerData, userData); +void Clay_Ncurses_OnClick( + void (*onClickFunc)(Clay_ElementId elementId, Clay_PointerData pointerData, void *userData), + void *userData +) { + if (onClickFunc) { + Clay_OnHover(onClickFunc, userData); } } From 038868c32bb7d12831c33b0b9c5aea852f9472da Mon Sep 17 00:00:00 2001 From: Seintian Date: Tue, 30 Dec 2025 12:03:15 +0100 Subject: [PATCH 9/9] refactor: simplify ncurses border corner drawing logic by removing redundant conditional checks. --- renderers/ncurses/clay_renderer_ncurses.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/renderers/ncurses/clay_renderer_ncurses.c b/renderers/ncurses/clay_renderer_ncurses.c index 1402351..6cb7674 100644 --- a/renderers/ncurses/clay_renderer_ncurses.c +++ b/renderers/ncurses/clay_renderer_ncurses.c @@ -375,16 +375,16 @@ static void Clay_Ncurses_RenderBorder(Clay_RenderCommand *command) { bool drawRight = (x + w - 1 >= dx && x + w - 1 < dx + dw); if (drawTop && drawLeft) { - mvaddch(y, x, (command->renderData.border.cornerRadius.topLeft > 0) ? ACS_ULCORNER : ACS_ULCORNER); + mvaddch(y, x, ACS_ULCORNER); } if (drawTop && drawRight) { - mvaddch(y, x + w - 1, (command->renderData.border.cornerRadius.topRight > 0) ? ACS_URCORNER : ACS_URCORNER); + mvaddch(y, x + w - 1, ACS_URCORNER); } if (drawBottom && drawLeft) { - mvaddch(y + h - 1, x, (command->renderData.border.cornerRadius.bottomLeft > 0) ? ACS_LLCORNER : ACS_LLCORNER); + mvaddch(y + h - 1, x, ACS_LLCORNER); } if (drawBottom && drawRight) { - mvaddch(y + h - 1, x + w - 1, (command->renderData.border.cornerRadius.bottomRight > 0) ? ACS_LRCORNER : ACS_LRCORNER); + mvaddch(y + h - 1, x + w - 1, ACS_LRCORNER); } attroff(COLOR_PAIR(pair));