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); + } +}