clay/examples/ncurses-example/main.c
Seintian d4a48a07fc 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).
2025-12-28 15:19:37 +01:00

267 lines
9.8 KiB
C

#define CLAY_IMPLEMENTATION
#include "../../clay.h"
#include "../../renderers/ncurses/clay_renderer_ncurses.c"
#include <time.h> // for nanosleep
#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 = {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} // Bright Yellow
}));
CLAY(CLAY_ID("SidebarItem1"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } },
.backgroundColor = {20, 20, 20, 255} // Uniform BG
}) {
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 = {20, 20, 20, 255} // Uniform BG
}) {
CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); // White
}
}
}
// 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 = {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 } }
}) {
// 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 = {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(16),
.childGap = 16,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {20, 20, 20, 255} // Uniform BG
}) {
// 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 = {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} }));
}
// 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 = {20, 20, 20, 255} // Uniform BG
}) {
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 = {20, 20, 20, 255} // Uniform BG
}) {
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);
struct timespec ts = { .tv_sec = 0, .tv_nsec = 32000 * 1000 };
nanosleep(&ts, NULL);
}
Clay_Ncurses_Terminate();
free(arena.memory);
return 0;
}