This commit is contained in:
Seintian 2025-12-30 11:03:25 +00:00 committed by GitHub
commit 73c2b739f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1375 additions and 3 deletions

17
.gitignore vendored
View file

@ -1,7 +1,18 @@
cmake-build-debug/
cmake-build-release/
.DS_Store
.idea/
node_modules/
*.dSYM
.vs/
.vs/
# CMake dependencies
_deps/
# CMake build artifacts
build/
cmake-build-debug/
cmake-build-release/
CPack*
Makefile
cmake_install.cmake
CMakeCache.txt
CMakeFiles

View file

@ -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" OFF)
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)

View file

@ -0,0 +1,19 @@
cmake_minimum_required(VERSION 3.27)
project(clay-ncurses-example C)
# 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 ${NCURSESW_LIB})
if (CMAKE_SYSTEM_NAME STREQUAL Linux)
target_link_libraries(clay-ncurses-example PRIVATE m)
endif()
target_include_directories(clay-ncurses-example PRIVATE ${NCURSESW_INCLUDE_DIR} ../../)

View file

@ -0,0 +1,499 @@
/**
* @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 <time.h> // for nanosleep
// -------------------------------------------------------------------------------------------------
// -- Constants & Configuration
// -------------------------------------------------------------------------------------------------
/** @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 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;
/** @brief Static instance of application state. */
static AppState _appState = {
.isSidebarVisible = true,
.isHelpModalVisible = false,
.isQuitting = false,
.scrollDelta = 0.0f
};
// -------------------------------------------------------------------------------------------------
// -- Callback Handlers
// -------------------------------------------------------------------------------------------------
void HandleHelpToggleClick(Clay_ElementId elementId, Clay_PointerData pointerInfo, void *userData) {
if (pointerInfo.state == CLAY_POINTER_DATA_PRESSED_THIS_FRAME) {
_appState.isHelpModalVisible = !_appState.isHelpModalVisible;
}
}
// -------------------------------------------------------------------------------------------------
// -- Input Processing
// -------------------------------------------------------------------------------------------------
/**
* @brief Processes input for the current frame.
* Updates _appState directly based on key presses.
* Uses ncurses input processing (non-blocking if timeout is set).
*/
void App_ProcessInput() {
_appState.scrollDelta = 0.0f;
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':
_appState.isQuitting = true;
break;
case 's':
case 'S':
// Toggle between two states
_appState.isSidebarVisible = !_appState.isSidebarVisible;
break;
case 'h':
case 'H':
_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;
}
}
}
// -------------------------------------------------------------------------------------------------
// -- 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("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("Track"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT) } },
.backgroundColor = {40, 40, 40, 255},
.cornerRadius = {1}
}) {
CLAY(CLAY_ID_LOCAL("Fill"), {
.layout = { .sizing = { CLAY_SIZING_PERCENT(percentage), CLAY_SIZING_GROW() } },
.backgroundColor = color,
.cornerRadius = {1}
}) {}
}
}
}
/**
* @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},
.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 = COLOR_TEXT_WHITE }));
UI_ProgressBar(CLAY_STRING("CPU"), 0.45f, COLOR_ACCENT_GREEN);
UI_ProgressBar(CLAY_STRING("Mem"), 0.82f, COLOR_ACCENT_ORANGE);
}
}
/**
* @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 = Clay_Hovered() ? (Clay_Color){60, 60, 60, 255} : 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);
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 = 1 },
.border = { .color = COLOR_ACCENT_RED, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > TL BOLD"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_RED, .fontId = CLAY_NCURSES_FONT_BOLD }));
}
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 = 1, .bottomRight = 1 },
.border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > Diag Under"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255}, .fontId = CLAY_NCURSES_FONT_UNDERLINE }));
}
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 = 1, .topRight = 1 },
.border = { .color = COLOR_ACCENT_BLUE, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > Top Bold Und"), CLAY_TEXT_CONFIG({ .textColor = COLOR_ACCENT_BLUE, .fontId = CLAY_NCURSES_FONT_BOLD | CLAY_NCURSES_FONT_UNDERLINE }));
}
}
}
// 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} },
.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 = {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 }));
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} }));
}
}
}
}
/**
* @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 },
}) {
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();
// Set non-blocking input for game loop
timeout(0);
while(!_appState.isQuitting) {
App_ProcessInput();
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_UpdateScrollContainers(true, (Clay_Vector2){0, _appState.scrollDelta}, 0.016f);
}
Clay_BeginLayout();
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);
}
Clay_Ncurses_Terminate();
free(arena.memory);
return 0;
}

125
renderers/ncurses/README.md Normal file
View file

@ -0,0 +1,125 @@
# 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 (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. 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.
```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.

View file

@ -0,0 +1,713 @@
/**
* @file clay_renderer_ncurses.c
* @author Seintian
* @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.
* 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
#ifndef _XOPEN_SOURCE
#define _XOPEN_SOURCE 700
#endif
#include <ncurses.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <locale.h>
#include <wchar.h>
#include "../../clay.h"
#define CLAY_NCURSES_FONT_BOLD 1
#define CLAY_NCURSES_FONT_UNDERLINE 2
#define CLAY_NCURSES_KEY_SCROLL_UP 123456
#define CLAY_NCURSES_KEY_SCROLL_DOWN 123457
#define CLAY_NCURSES_KEY_MOUSE_CLICK 123458
// -------------------------------------------------------------------------------------------------
// -- 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
/** @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
/** @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; /**< 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 & 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);
/**
* @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);
/**
* @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
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;
}
/**
* @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;
}
/**
* @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));
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
int maxLen = text.length + 1;
wchar_t *wbuf = (wchar_t *)malloc(maxLen * sizeof(wchar_t));
if (!wbuf) {
attroff(COLOR_PAIR(pair));
return;
}
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 skipCols = dx - x;
int takeCols = dw;
int currentCols = 0;
int printStart = -1;
int printLen = 0;
// Find start index based on columns skipped
for (int k = 0; k < wlen; k++) {
int cw = wcwidth(wbuf[k]);
if (cw < 0) cw = 0;
if (currentCols >= skipCols && currentCols < skipCols + takeCols) {
if (printStart == -1) printStart = k;
printLen++;
}
currentCols += cw;
if (currentCols >= skipCols + takeCols) break;
}
if (printStart != -1) {
mvaddnwstr(dy, dx, wbuf + printStart, printLen);
}
}
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));
}
/**
* @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, ACS_ULCORNER);
}
if (drawTop && drawRight) {
mvaddch(y, x + w - 1, ACS_URCORNER);
}
if (drawBottom && drawLeft) {
mvaddch(y + h - 1, x, ACS_LLCORNER);
}
if (drawBottom && drawRight) {
mvaddch(y + h - 1, x + w - 1, 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);
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();
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) {
// Restore mouse tracking state
puts("\033[?1003l");
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;
case CLAY_RENDER_COMMAND_TYPE_IMAGE:
case CLAY_RENDER_COMMAND_TYPE_CUSTOM:
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) {
return Clay_Ncurses_MatchColor(color);
}
static int Clay_Ncurses_GetColorPair(short fg, short bg) {
for (int i = 0; i < _colorPairCacheSize; i++) {
if (_colorPairCache[i].fg == fg && _colorPairCache[i].bg == bg) {
return _colorPairCache[i].pairId;
}
}
if (_colorPairCacheSize >= MAX_COLOR_PAIRS_CACHE) {
return 0; // Cache full, fallback to 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;
}
static int Clay_Ncurses_MeasureStringWidth(Clay_StringSlice text) {
int width = 0;
const char *ptr = text.chars;
int len = text.length;
mbtowc(NULL, NULL, 0); // Reset state
while (len > 0) {
wchar_t wc;
int bytes = mbtowc(&wc, ptr, len);
if (bytes <= 0) {
ptr++;
len--;
continue;
}
int w = wcwidth(wc);
if (w > 0) width += w;
ptr += bytes;
len -= bytes;
}
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.
*/
int Clay_Ncurses_ProcessInput(WINDOW *window) {
int key = wgetch(window);
// 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;
bool shouldReturnClick = false;
if (event.bstate & (BUTTON1_PRESSED | BUTTON1_DOUBLE_CLICKED | BUTTON1_TRIPLE_CLICKED)) {
_isMouseDown = true;
shouldReturnClick = true;
}
else 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
#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
}
}
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_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);
}
}